UnityでHaskellのコードを実行する方法の模索


概要

Unityでのゲーム制作には基本的にはC#を使う。しかし、場合によっては他の言語を用いたいことがあり、Unityではネイティブプラグインという方法で、外部で生成されたDLLを利用することが可能である。
筆者は、ダンジョン生成のプログラムをHaskellで作成し、それをUnity内で利用したいという経緯があったので、今回、HaskellのシンプルなコードをCabalとStackを利用してDLLを作成し、それをUnityから利用する効率的な方法を模索した。

環境はWindows 10 64bit

1. 準備

Stackで新しいプロジェクトを作る。

stack new haskell-foreign-library-test

一度ビルドstack buildして、package.yamlからcabalファイル(haskell-foreign-library-test.cabal)を生成する(バックでhpackが動いている)。

後述するが、ライブラリ作成にはCabalの機能foreign-libraryを使う。しかし、現在(2019/01/04)、cabalファイルを生成するhpackは、これに未対応なようので、泣く泣くpackage.yamlは削除しておく。

hpackのforeign-library対応は、議論されているようだ。

ついでに.gitignoreのcabalの行を削除して、cabalファイルをバージョン管理に含めておきたいところ。

2. HaskellプログラムのDLL作成

Haskell側の全てのコードはここ(masatoko/haskell-foreign-library-test)で見られる。

2.1. Haskellのコード

Haskellのコードは非常にシンプルなものにした。受け取った整数に1を足して返すだけの関数plus_oneだ。
これをforeign exportで出力しておく。

src/Lib.hs
module Lib where

foreign export ccall plus_one :: Int -> IO Int

plus_one :: Int -> IO Int
plus_one x = return $ x + 1

2.2. hs_init関数のラッパーをC言語で作成

参考(8.2. GHCでFFIを使う)

外部からplus_oneを利用するには、使う前にGHCのランタイムシステムを初期化し、使ってから終了させなければならない。
hs_init() -> plus_one() -> hs_exit() という流れ。
しかし、hs_init()は引数をとり、外部(C#)から利用する際、引数を渡すのが面倒になるので、hs_init()のラッパー関数hs_init_wrapper()を作成しておく。

crsc/mylib.h
#pragma once

extern "C" {
void hs_init_wrapper(void);
}
csrc/mylib.cpp
#include "HsFFI.h"
#include "mylib.h"
#include "Lib_stub.h" // 今回は不要。ここでplus_one()を使うことができる。

void hs_init_wrapper(void) {
  int argc = 2;
  char *argv[] = {(char *)"+RTS", (char *)"-A32m", NULL};
  char **pargv = argv;
  hs_init(&argc, &pargv);
}

mylib.cppでインクルードされているLib_stub.hは自動生成される。
.stack-work/dist/*/build/mylib/mylib-tmp/Lib_stub.hに出力されていた。)
中身を見てみよう。

Lib_stub.h
#include "HsFFI.h"
#ifdef __cplusplus
extern "C" {
#endif
extern HsInt plus_one(HsInt a1);
#ifdef __cplusplus
}
#endif

plus_oneがエクスポートされている。
外部からより使いやすくするために、関数を加工して、また別の関数として出力しておくのもいいと思う。

2.3. ビルド

stack buildの1コマンドでDLLを生成してくれたら嬉しい。その方法を調べた。
Cabalはv2.0.0.2(2017年7月)からforeign-libraryでDLL(macの場合はdylib)を作成できるようになったらしい。これを使いたい。公式の説明

haskell-foreign-library-test.cabal
library
  hs-source-dirs:
      src
  other-modules:
      Lib
  build-depends:
      base >=4.7 && <5
  default-language: Haskell2010

foreign-library mylib
  type:
      native-shared

  if os(Windows)
    options: standalone

  other-modules:
      Lib
  build-depends:
      base >=4.7 && <5
  hs-source-dirs:
      src
  c-sources:
      csrc/mylib.cpp
  default-language: Haskell2010

これでビルドすればDLLが作成される。

stack build

2.4. 生成されたDLLを確認

生成されたDLLのパスは、stack buildのログの最後のほうに表示されている。
今回は、.stack-work/install/426b9cbc/lib/mylib.dll に生成されていた。

nmコマンドで中身を確認。

nm mylib.dll

以下の行を発見し、関数が出力されていることを確認した。

0000000067881620 T hs_init_wrapper
0000000067886c20 T hs_exit
0000000067881580 T plus_one

3. UnityでのDLLの利用

生成したDLLをUnityで使ってみた。
開発時のEditorの挙動で少し厄介なことがあったので、それの対策が必要だった。

3.1. DLLの配置

Assetsフォルダ配下にPluginsフォルダを作成し、そこにDLLを配置すればUnityが認識してくれる。
今回は素直にAssets/Plugins/mylib.dllに配置した。

3.2. 各種設定を64ビットにする

生成されたDLLは64ビットのバイナリなので、Platform settings > Windows > x86_x64 だけにチェックを入れる。

また、Build SettingsのArchitectureをx86_64にしておく。

3.3. 利用するコードの記述

以下は適当な変数counterを1秒ごとに1足して、画面上のテキストに反映させるだけのプログラムである。
しかし、1点だけ注意することがある。

MylibWrapper.cs
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.UI;

public class MylibWrapper : MonoBehaviour {

    [DllImport("mylib")]
    public static extern void hs_init_wrapper();
    [DllImport("mylib")]
    public static extern void hs_exit();
    [DllImport("mylib")]
    public static extern int plus_one(int x);

    public Text text; // counterを確認するためのText

    int counter = 0;

    private void Awake() {
        hs_init_wrapper();
    }

    private void Update() {
        counter = plus_one(counter);
        print(counter);
        text.text = $"{counter}";
    }

    void OnApplicationQuit() {
        // エディター上でhs_exit()すると次回のhs_init()でエラー。
        // エディター上ではhs_exit()を実行させないのを一時的な解決方法とする。
#if UNITY_EDITOR

#else
        hs_exit();
#endif
    }
}

エディター上での2度目の起動時に落ちる問題の解決

ghcのランタイムシステムは、hs_exit()した後にhs_init()できないようになっている。
しかし、Unityでは、エディター上でロードされたDLLはUnityを終了するまでアンロードされないため、
エディター上でゲームを開始して、hs_init_wrapper()し、ゲームを停止した時にhs_exit()し、次回のゲーム開始時に再びhs_init_wrapper()を実行するような仕組みにすると、hs_init_ghc: reinitializing the RTS after shutdown is not currently supportedというエラーが発生して落ちてしまう。

ソースコードを見ると、rts_shutdownフラグがこれを拒んでいる。

それであれば、エディター上ではhs_exit()を実行しないという方法しか残されていない。
ソースコードを見ると、hs_init()を複数回実行しても、hs_init_countで呼び出し回数をカウントされるものの、2回目以降は何の処理もしておらず、問題ないだろう。(本当に?)
よって、エディター上ではゲーム開始のたびにhs_init_wrapper()が呼ばれるが、ゲーム終了時にはhs_exit()を呼ばないという方針とした。#if UNITY_EDITOR ~ #else ~ #endif の部分。

4. 実行

エディター上でも、ビルドした実行ファイルでも、うまく動作した。

5. まとめ

64bitという制約付きであるものの、HaskellのコードをUnity上で動作させることができた。

しかし、いくつか懸念もある。

  • 【懸念1】 AndroidやiOSで動作するアプリを作れるのか?

Mobile Haskellというプロジェクトがあるので、これを使ってDLLを作れるかどうかにかかっていそう。検証は苦しそうなので今はしない。

  • 【懸念2】 SwitchやPS4で動作するゲームを作れるのか?

それぞれのハードウェアで動作するバイナリ(DLL)が作れるのかどうか。ハードウェアの仕様がわからないので、これも未知数。

Windows上でゲームを公開するだけならば不安は無いが、将来的にモバイル端末やコンシューマー機で公開する可能性がある場合は、これが原因で断念せざるをえなくなる可能性があるので注意が必要。

  • 活用方法の検討

    • Haskellでダンジョン生成をしてUnityに取り込む。
    • C#で書きたくない複雑な関数をHaskellで記述してUnityで使う。
    • Haskellでゲームロジックを書いて、全ての描画はUnityに任せる。
  • 追記1-Haskell関数からStringを返す(2020/01/05)

戻り値をStringにする方法はこのブログ記事が参考になりました。

  • 追記2-エディターが重くなる問題について(2020/01/06)

本記事のplus_oneのようなシンプルな関数ではなくて、プロシージャルなダンジョンを生成するような、少し複雑な関数を実行したところ、関数の実行後にエディタがかくかくしてしまい、とても開発できるような状況でなくなってしまう問題が発生することがわかりました。原因不明であり、対処方法が判明するまではこの方法はやめておくべきかもしれません。
GHCのランタイムの処理がバックグラウンドで残っていて、Unityの処理を邪魔しているんですかね?どんなコードが問題を発生させるのかを調べたいですが、それが分かったとしても、問題を避けるようなコーディングをせねばならず、そんな不自由なプログラミングはしたくはありませんが。。
ひとまずはオフラインでダンジョンをファイルに書き出しておいて、それをUnityで読み込むという平凡な仕組みにしましたが、筆者はHaskell活用の夢をまだ諦めてはいないぞ。