Unity に mruby 組み込んでる

実験的に、Unityにmrubyを組み込んで使ってみてる。

基本的には Unity内のコードは全て c# で書くわけだけど、ゲームの基盤部分もすべてc#で書くだけに、それとは別に、ゲームのコンテンツ部分だけを集中して記述できる分離された薄いレイヤがあると便利だ、という話がある。

たとえば、ゲーム内のキャラクタの会話や演技、そういった設定たちが、再コンパイルせずに実行中にホットリロードで変更を確認しつつ開発を進められると嬉しい。

そこで、 mruby を使ってみようかなと思い立った。

もちろん、なにもmrubyを組み込まなくたって、構造化されたただのデータ(たとえばyamlとか…) なんかでも充分、ゲームの内容を表現することはできそうだ。

しかし、それはそれで、決して安い買い物とも言えないような気がする。 複雑なデータ構造は、invalidでないことを完璧に保証するつまらないコードを書くのが案外大変だし、専用のエディタをつくるのはもっと大変だ。 ( あの人間に優しいことで有名なRailsですら、database.yamlを間違えて書いたまま起動したとき、何行目の何が間違っているかなんて教えてくれないんだから…)

しかし、言語を組み込んでしまえば、サポートされない記法を書いちゃった場合のエラーメッセージが自ずと手厚くサポートしてもらえる。構造化された内部DSLをつくるのもrubyは得意中の得意だし。

というわけで 試しに mruby つかってる。

Unity ネイティブプラグイン

さて、mruby で Unity のゲームを操縦するためには、

mrubyコード → c/c++ 等のネイティブコード → Unity C#

と、3つの段階を踏むことになる。

こう考えるとちょっと面倒臭いんだけど、 Unity はネイティブコードをビルドに含める仕組みがあるので、難しいことで悩むことはない。

Unity の Assets 内のどの階層であろうと、 Plugin と名付けられたディレクトリは特別扱いされ、この中に C#のdll、あるいはネイティブの c/c++ ソース、あるいはビルド済みのネイティブのライブラリを置くことで、ビルド時にコンパイルないしリンクしてくれることになってる。 これがいわゆる Unityの「プラグイン」という仕組みで、ネイティブコードの方を「ネイティブプラグイン」と呼ぶ。

Plugin/ 以下にはプラットフォームごとに実装を置く必要があって、たとえば、

こんなかんじのサブディレクトリをきったら、それぞれの名前に対応するプラットフォームの実装をがんばって用意して配置していく。

今回は、あらかじめビルドしたmruby をビルドして組み込みたいので、Plugins/ 以下には、ビルド済みバイナリを置いていくことになる。

C#のメソッドをネイティブコードから叩く

というわけで、ネイティブプラグインをビルドしてみよう。

僕はmacOSで開発をしてるので、まずは XCodeを開く。

  1. XCode を開いて、新規プロジェクトをつくる。
  2. プロジェクトテンプレートは macOS → Framework & Library 内の 「Bundle」 を選択。
  3. iOSで動かしたい場合は、iOS → Framework & Library 内の 「Cocoa Touch Static Library」。
  4. ちなみに、後からプロジェクトの設のターゲットを追加して、同じ操作をすると、macOSiOS 向けビルドが同じプロジェクトでやれます。

まずは、 Unity C# のメソッドをcから操縦するネイティブプラグインをつくってみるところからやってみる。

c から Unity のメソッドを叩くやりかたは、だいたい2とおりある。 1. Unityが提供している UnitySendMessage(const char *gameObject, const char *methodName, const char *message) をcから叩く 2. C#からcへ関数ポインタを渡してあげる

今回は、2の方法をとることにした。

( 1 のUnitySendMessage はめっちゃfuzzyだし遅いけど、今回の用途では充分に要件を満たすことはできそう。ところが、一旦ビルドしたやつをUnity側にリンクさせる方法をとる場合、Unity.app内にヘッダがバンドルされてない上、そもそもシンボルがなくてビルドがこけてしまう。ソースを直接 Plugins/ に置いてあげる場合でないと使えないのかもしれない )

以下は、C#の関数ポインタを受けとるcのコード。

r4u.h

// 文字列を1つ引数にとる void 関数のポインタ型
typedef void (*r4u_string_action)(const char *);

// 文字列を1つ引数にとるc# の関数ポインタを受け取る関数
void r4u_bind_debug_log(r4u_string_action);

// それを呼び出すだけの関数
void r4u_call_debug_log();

r4u.c

string_action debug_log;

void r4u_bind_debug_log(r4u_string_action action) {
   debug_log = action;
}

void r4u_call_debug_log(const char *arg) {
    debug_log(arg);
}

これで完成。 macOSのターゲットを選んでビルドすると、 ◯◯.bundle ができるので、 あとは、Unity の Plugins/macOS/ ディレクトリへそっと置いておこう。

C#の関数ポインタをネイティブ側へ渡す

続いてUnity側から、今つくったネイティブプラグインとのやりとりを書いていく。

C# には元々 P/Invoke と呼ばれる仕組みがあって、ネイティブコードとのやりとりを言語がサポートしている。Unityでも基本的には同じ記法を利用して ネイティブプラグインと通信する。

public class R4uBinding : MonoBehaviour
{
    // 相互に受け渡す関数ポインタ型は、delegate として表現する必要がある
    public delegate void StringAction(string arg);

    // R4u.bundle に実装されている関数をc#から使う宣言
    [DllImport("R4u")]
    // さっき書いたcの関数の宣言。
    public static extern void r4u_call_debug_log(string message);

    [DllImport("R4u")]
    // さっき書いたcの関数の宣言。 (次のステップで、これを直接呼ぶのではなくrubyコード経由で叩く)
    public static extern void r4u_bind_debug_log(StringAction action);

    void Start()
    {
       // こんなかんじで、関数ポインタを登録すると、
        r4u_bind_debug_log(DebugLogCallback);

       // c コードから自由にそれを呼べるようになっているはず
        r4u_call_debug_log("Hello! Hello! Hello1");
    }

    // ネイティブ側に渡せるのは、この属性のついた static メソッドのみ、という制約があるらしい
    [MonoPInvokeCallback(typeof(StringAction))]
    static void DebugLogCallback(string arg)
    {
        Debug.Log(arg);
    }
}

コンソールに Hello! Hello! Hello! がでたら成功。

mrubyのビルド

C#の 関数ポインタを受けとることができたので、あとは この関数ポインタをmruby 経由で操縦するバインディングを書くだけ。

まずは 適当な場所に mruby/mruby をクローンする。

ドキュメントは リポジトリ内の doc/ 内に書いてあるようだ。これを見たり、野良ブログで紹介してくれている build_config.rb を参考にビルドする。

iOS/Android についても既にDSL的にはサポートがあるようで、自分のマシン上のそれぞれのプラットフォームのビルド環境があれば、 toolchain での指定を変えるだけで、うまくビルドしてくれた。

# デフォルトでは 「host 」という名前でビルドされる
MRuby::Build.new do |conf|
  toolchain :clang
  enable_debug
  conf.gembox 'default'
end

# iOS
MRuby::CrossBuild.new('iphone-arm64') do |conf|
  toolchain :clang

  isysroot = '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk'

  conf.cc do |cc|
    cc.flags << '-arch arm64'
    cc.flags << "-isysroot #{isysroot}"
    cc.flags << '-Wall'
    cc.flags << '-Werror-implicit-function-declaration'
  end

  conf.linker do |linker|
    linker.flags << '-sdk iphoneos'
    linker.flags << '-arch arm64'
    linker.flags << "-isysroot #{isysroot}"
  end
end

# Android たぶんこんなかんじ。 https://github.com/mruby/mruby/blob/master/doc/guides/compile.md
# MRuby::CrossBuild.new('android') do |conf|
#   toolchain :android
#   conf.gembox 'default'
# end

上のような設定ファイルをつくったら、ファイルパスを指定して ./minirakeする

$ MRUBY_CONFIG=../../mruby_build_config.rb  ./minirake

すると mrubyのリポジトリ以下のようにそれぞれのビルドができる。

build
├── host
│  ├── LEGAL
│  ├── bin
│  ├── lib
│  ├── mrbgems
│  ├── mrblib
│  └── src
└── iphone-arm64
   ├── lib
   ├── mrblib
   └── src

XCode のプロジェクトに mrubyをリンクする

mrubyができあがったら、 build/host/lib/libmruby.a を、XCodeプロジェクトからリンクする設定をしておく。

  1. 「Build Settings」 の Header Search Paths に、 mrubyリポジトリ内にある include ディレクトリを追加
  2. 例) $(PROJECT_DIR)/../../ext/mruby/include
  3. 同じく Build Settings の Library Search Paths に、ターゲットのプラットフォームに対応する libmruy.a があるディレクトリを追加しておく
  4. 例) $(PROJECT_DIR)/../../ext/mruby/build/host/lib
  5. ターゲットごとに、「Build Phases」の Link Binary With Libraries という項目に、libmruby.a をドラッグ&ドロップする

設定できたらテストしてみる。 mruby の hello worldXCodeからコンパイルできたら成功。

#include <mruby.h>
#include <mruby/compile.h>

void hoge() {
    mrb_state *mrb = mrb_open();
    mrb_load_string(mrb, "5.times {|i| p 'hello' }");
    mrb_close(mrb);
}

C# から rubyコードを渡してあげて実行する

あとは、さっき作ったネイティブプラグインの、c#関数ポインタ呼び出し部分をrubyで操縦できるようにするだけ!

typedef void (*r4u_string_action)(const char *);

void r4u_bind_debug_log(r4u_string_action);

void r4u_start();  // 初期化関数
void r4u_dispose(); // 後始末関数
void r4u_exec(const char *src); // rubyコードを渡すと実行してくれる関数
#include "r4u.h"

#include <mruby.h>
#include <mruby/compile.h>

struct r4u_context {
    mrb_state *mrb;
    struct RClass *module;
    
    r4u_string_action debug_log;
    r4u_string_action debug_log_warning;
    r4u_string_action debug_log_error;
};

struct r4u_context r4u; 

mrb_value _debug_log(mrb_state* mrb, mrb_value self)
{
    mrb_value arg;
    mrb_get_args(mrb, "o", &arg);
    mrb_value message = mrb_inspect(mrb, arg);
    char *message_cstr = mrb_str_to_cstr(mrb, message);
    debug_log(message_cstr);
    return mrb_nil_value();
}

void r4u_bind_debug_log(r4u_string_action action) {
    r4u.debug_log = action;
}

void r4u_start() {
    r4u.mrb = mrb_open();
    r4u.module = mrb_define_module(r4u.mrb, "R4u");
    
    mrb_define_module_function(r4u.mrb, r4u.module, "debug_log", _debug_log, MRB_ARGS_REQ(1));
}

void r4u_dispose() {
    mrb_close(r4u.mrb);
    
    r4u.debug_log("[R4u] dispose");
}

void r4u_exec(const char *src) {
    r4u.debug_log(src);
    
    mrbc_context *context = mrbc_context_new(r4u.mrb);
    int ai = mrb_gc_arena_save(r4u.mrb);
    mrb_load_string_cxt(r4u.mrb, src, context);
    
   // このブロックはエラー処理
    if (r4u.mrb->exc != 0) {
        mrb_value exc = mrb_obj_value(r4u.mrb->exc);
        mrb_value backtrace = mrb_get_backtrace(r4u.mrb);
        r4u.debug_log_error(mrb_str_to_cstr(r4u.mrb, mrb_inspect(r4u.mrb, backtrace)));
        
        mrb_value inspect = mrb_inspect(r4u.mrb, exc);
        r4u.debug_log(mrb_str_to_cstr(r4u.mrb, inspect));
        r4u.mrb->exc = 0;
    }
    
    mrb_gc_arena_restore(r4u.mrb, ai);
    mrbc_context_free(r4u.mrb, context);
}

Unity側も修正したら完成。 C# からは、好きなrubyコードを書いて実行できる。

using System.Runtime.InteropServices;
using AOT;
using UnityEngine;

public class HogeBinding : MonoBehaviour
{
    public delegate void StringAction(string arg);

    [DllImport("R4u")]
    public static extern void r4u_start();

    [DllImport("R4u")]
    public static extern void r4u_dispose();

    [DllImport("R4u")]
    public static extern void r4u_exec(string src);

    [DllImport("R4u")]
    public static extern void r4u_bind_debug_log(StringAction action);

    [MonoPInvokeCallback(typeof(StringAction))]
    static void DebugLogCallback(string arg)
    {
        Debug.Log(arg);
    }

    void Start()
    {
        r4u_start();
        r4u_bind_debug_log(DebugLogCallback);

        // 好きなrubyコードを実行できる。あらかじめバインドしたC#関数もコールできる
        r4u_exec("include R4u\n" + "3.times{ debug_log 12345 }");
    }

    void OnDestroy()
    {
        r4u_dispose();
    }
}

うむ!

課題

  • このやりかただと、c#の操作したい関数が増えるたびに、mruby から のバインディングを書く手間がけっこう多い。
  • もし ダイナミックなバインディングが不可能であれば、コード生成する仕組みとかを入れても良いのかもしれない。
  • XCodeで完結しない Android NDK とかのビルドのワークフローをちゃんとしないと面倒

もうちょっと仕組みを整備する必要があるけど、個人開発では十分使っていけそう。