LoginSignup
27
12

More than 3 years have passed since last update.

はじめてのRustをFFIとゲームエンジンで。

Last updated at Posted at 2020-12-24

このエントリは Rust 3 Advent Calendar 2020 の25日目の記事です。

個人開発でゲームエンジンを作っている LRIKI と申します。

去年のちょうどこの時期は こんなかんじ でしたが、この一年間でようやくゲームっぽいものが作れるようになりました。

image.png

さて普段は公私共に C++er ですが、以前から Rust がイイらしい噂を聞いていて、ずっと気になっていました。しかしイマイチそれを学ぶ動機が持てず、「Rust 勉強したいなー」とか言い続けている間に数年が過ぎ、また今年も終わろうとしています。

ただ今年は何となくいいかんじに動くようになってきたオレオレエンジンがあります。さらにこのエンジンはいろいろな言語から呼び出せる Flat な C-API を持ってます。勉強しながらグラフィカルにいろいろできたほうがモチベにつながる気がするので、ひとまず導入ということで表題の通り、Rust の 他言語関数インターフェイス(Foreign Function Interface, FFI) を使ってエンジンの機能を呼び出してみたいと思います。

注意

この記事は Rust の入門チュートリアルすら見たことのない人間が適当にコードを書いてアプリを動かすまでの記録です。Rust のお作法のようなものは全くわかっていません。

変なこと書いてあればご指摘等いただけると助かります。特に文字列エンコーディングの変換あたり。

やりたいこと・確認したいこと

自作のゲームエンジン LuminoC-API を Rust からフルに使うにはどうすればよいか?を追ってみます。

  • 関数を呼び出すにはどうすればよいか
  • 文字列を渡すにはどうればよいか
  • Rust の関数をコールバックで呼び出すにはどうればよいか
  • 構造体のポインタを渡すにはどうればよいか

このあたりができれば、API として公開されているすべての機能にアクセスできるはずです。

開発環境

  • Windows10
  • Visual Studio Code
  • PowerShell

Rust をインストールする

ソースコードをコンパイルするためには rustc コマンドが必要らしいので、これをインストールしてみます。

公式サイトからインストーラを入手することができますが、Chocolatey でパッケージが公開されていたので、後々の管理しやすさを考えてこちらからインストールしました。

# インストール
PS> choco install -y rust

# 確認
PS> rustc --version
rustc 1.48.0 (7eac88abb 2020-11-16)

ちなみに実際は rustc を直接叩くことは少なく、cargo というツールを使うようです。

Hello, world!

Hello, World! も今回が初めてです。ちゃんと 写経 して動作確認します。

fn main() {
    println!("Hello, world!");
}
# コンパイル
PS> rustc main.rs

# 実行
PS> ./main
Hello, world!

Lumino の DLL を入手する

Rust を動かすことができたので、Lumino を呼び出す準備をしていきます。Lumino 自体は C++ で書かれており、API は DLL として提供しています。機能を呼び出すにはこの DLL をリンクして、エクスポートされている関数を呼び出すことになります。

さてその DLL なのですが、まだ公開準備中で、正式な形ではリリースされていません。しかし Lumino のリポジトリは master ブランチで CI/CD を実行しており、最新ビルドのバイナリがダウンロードできるようになっています。ので、それを使ってみます。いくつかある成果物の中の Windows x64 用のパッケージをダウンロードします。

image.png

zip 内の Engine/MSVC2019-x64-MT/bin/LuminoEngine.dll を、先ほど作成した main.rs と同じディレクトリにコピーしておきます。

C の関数を呼び出してみる

まずはウィンドウを表示して、クローズボタンでアプリを閉じるところまでできるようにしてみます。

さて、簡単なゲームアプリケーションの基本フローは次ようになります。

  • 初期化を行う
  • メインループを回す
  • 終了処理を行う

Lumino はこれらに対応した関数が export されているので、これを呼び出せるようにします。宣言は次のようになっています。

LNResult LNEngine_Initialize();
LNResult LNEngine_Update(LNBool* outReturn);
LNResult LNEngine_Terminate();

LNResultLNBool は int32_t のエイリアスです。

これらを Rust で使うためのコードは次のようになりました。

#[link(name = "LuminoEngine")]
extern "C" {
    fn LNEngine_Initialize() -> i32;
    fn LNEngine_Update(outReturn: *mut i32) -> i32;
    fn LNEngine_Terminate() -> i32;
}

fn main() {
    unsafe {
        // 初期化処理
        LNEngine_Initialize();

        // メインループ
        let mut running: i32 = 1;
        while running != 0 {
            LNEngine_Update(&mut running);
        }

        // 終了処理
        LNEngine_Terminate();
    }
}

またコンパイルするときに -L オプションを指定して、カレントディレクトリもライブラリの検索対象に含めるようにします。

PS> rustc -L. main.rs
PS> ./main

これで空っぽのウィンドウが表示されるようになりました。

image.png

extern

共有ライブラリ (.dll や .so) に定義された関数を呼び出すためにはそれらの宣言を extern ブロック に書く必要があるようです。

#[link(name = "LuminoEngine")] でリンクするライブラリの名前を指定します。これでコンパイル時に自動的に検索してくれるようになりました。

extern "C" の "C" 部分は ABI を指定します。デフォルトはプラットフォーム標準の C-ABI ですが、そのほかにも "stdcall" などたくさんの選択肢があるようです。Windows API を呼び出すときはお世話になりそうです。

ミュータブル(mut)

例えば C++ で int a = 0; と変数を定義したとき、この変数は後から変更可能です。しかし Rust では let a: i32 = 0; と書くと、以降この変数は変更できないようです。C++ で考えると、常に const が付いているようなイメージです。

変更可能にするには、変数宣言に mut を付けます。

let mut a: i32 = 0;
a += 1;    // ok

さて、 LNEngine_Update() は受け取ったアドレスに対して、「アプリが動作中かどうか」を示す値を設定します。普段は 1(true) で、ウィンドウのクローズボタンが押されたりすると、0(false) が設定されます。

Rust で参照先を変更可能なポインタを得るには、&mut を使うようです。

let mut running: i32 = 0;
LNEngine_Update(&mut running);
println!("{}", running);    // => 1

なお、mut を付けない場合は参照先を変更できないポインタが得られるようです。

let mut a: i32 = 0;
let p1: *mut i32 = &mut a;
let p2: *const i32 = &a;

unsafe

extern ブロックに定義した関数は unsafe function となり、unsafe ブロック内でのみ呼び出せるようになります。また unsafe ブロック内では生ポインタに対する強力なサポートがあり、unsafe superpowers と呼ばれているようです。しかし名前の通り null 安全等 Rust のセーフティが働かなくなるため、今回のような FFI 以外での乱用はやめた方がよさそうです。

本稿では unsafe function をそのまま呼び出していますが、こういったものをライブラリとして公開する際は Rust 側でラップして、ユーザープログラムでは unsafe を書かずに済むような API を考えるべきだと思いました。

型エイリアス

先ほどの通り、LNResult と LNBool は int32_t のエイリアスとなっています。それぞれ固有の意味を持っていますので、パッと見たときに区別しやすいよう、Rust 側でもエイリアスを付けておきたいところです。Rust のタイプエイリアス は次のように書くことができました。

type LNResult = i32;
type LNBool = i32;

扱える値を定数として定義し、次のように書き直してみました。

type LNResult = i32;
const LN_OK: LNResult = 0;

type LNBool = i32;
const LN_FALSE: LNBool = 0;
const LN_TRUE: LNBool = 1;

#[link(name = "LuminoEngine")]
extern "C" {
    fn LNEngine_Initialize() -> LNResult;
    fn LNEngine_Update(outReturn: *mut LNBool) -> LNResult;
    fn LNEngine_Terminate() -> LNResult;
}

文字コード変換

ファイルパスを指定して画像ファイルを開いたり、画面上に文字を表示するために、Lumino へ文字列を渡せるようにする必要があります。Lumino の基本の文字列エンコーディングは UTF-16 です。API も UTF-16(char16_t) で文字列を受け取るようになっています。しかし Rust は UTF-8 でエンコードされた文字列 を扱うようですので、API を呼び出すときに変換する必要があります。

例として、ウィンドウに GUI 部品のボタンを表示してみたいと思います。コードは次のようになりました。

type LNResult = i32;
const LN_OK: LNResult = 0;

type LNBool = i32;
const LN_FALSE: LNBool = 0;
const LN_TRUE: LNBool = 1;

type LNHandle = u32;
const LN_NULL_HANDLE: LNHandle = 0;

#[link(name = "LuminoEngine")]
extern {
    fn LNEngine_Initialize() -> LNResult;
    fn LNEngine_Update(outReturn: *mut LNBool) -> LNResult;
    fn LNEngine_Terminate() -> LNResult;
    fn LNUIButton_CreateWithText(text: *const u16, outReturn: *mut LNHandle) -> LNResult;
    fn LNUI_Add(element: LNHandle) -> LNResult;
}

fn main() {
    unsafe {
        LNEngine_Initialize();

        // UTF-16 文字列を作る
        let text = "Click me";
        let mut u16text: Vec<u16> = text.encode_utf16().collect();
        u16text.push(0);    // '\0'

        // ボタンを作る
        let mut button: LNHandle = 0;
        LNUIButton_CreateWithText(u16text.as_ptr(), &mut button);

        // メインウィンドウに追加する
        LNUI_Add(button);

        let mut running: LNBool = 1;
        while running != LN_FALSE {
            LNEngine_Update(&mut running);
        }

        LNEngine_Terminate();
    }
}

image.png

Rust の文字列

Rust の文字列リテラルは &str として表される 文字スライス となるようです。C/C++ では char 配列や char ポインタとして表されますが、これが string_view になるイメージです。

さてところで、他の言語では特に迷うことなくできていた、String 型変数への文字列リテラルの代入ができませんでした。

let s: String = "a"; // error: expected struct `String`, found `&str`

String は言語機能に組み込まれた型ではなく、C++ と同じく標準ライブラリとして提供される文字列コンテナらしいです。リテラルから String を作るには、明示的な変換を行う必要がありました。

// スライスのまま扱う場合
let s: &str = "a";

// String へ変換する場合
let s: String = String::from("a");

// 普段は型推論に任せ、型を省略することが多い
let s = "a";

UTF-16 へ変換する

ボタンを作成するための C 関数の宣言は次のようになっています。

typedef char16_t LNChar;

NResult LNUIButton_CreateWithText(const LNChar* text, LNHandle* outUIButton);

LNUIButton_CreateWithText は指定された文字列を表示するボタンを作ったあと、第2引数で指定された変数に、ボタンオブジェクトをコントロールするためのハンドルを格納する関数です。このハンドルを使ってボタンをウィンドウへ追加したり、位置を変更したりできます。

対応する Rust 側の宣言は次のようになります。

type LNChar = u16;

fn LNUIButton_CreateWithText(text: *const LNChar, outReturn: *mut LNHandle) -> LNResult;

さてこの関数へ渡す文字列の変換ですが、Rust 側で文字列を UTF-16 へ変換するには encode_utf16() が使えるようでした。次のように Vec を一時バッファとして使っていますが、変換しただけだと文字列が NULL 終端されていないため、push(0) した後、生ポインタを取り出しています。

let text = "Text";
let mut u16buffer: Vec<u16> = text.encode_utf16().collect();
u16buffer.push(0);    // '\0'
let utf16text: *const LNChar = u16text.as_ptr();

コールバック関数

ボタンクリックに代表されるように、シーンのロードが終わった時や衝突判定が発生したときなど、様々なイベントの発生通知を受け取れるようにするため、コールバックを使えるようにする必要があります。

例として、ボタンがクリックされたら Lumino のデバッグ用の文字列表示機能を使って、"click!" という文字列を表示してみました。

type LNResult = i32;
const LN_OK: LNResult = 0;

type LNBool = i32;
const LN_FALSE: LNBool = 0;
const LN_TRUE: LNBool = 1;

type LNHandle = u32;
const LN_NULL_HANDLE: LNHandle = 0;

type LNUIEventHandlerCallback = extern "C" fn(handler: LNHandle) -> LNResult;

#[link(name = "LuminoEngine")]
extern {
    fn LNEngine_Initialize() -> LNResult;
    fn LNEngine_Update(outReturn: *mut LNBool) -> LNResult;
    fn LNEngine_Terminate() -> LNResult;
    fn LNUIButton_CreateWithText(text: *const u16, outReturn: *mut LNHandle) -> LNResult;
    fn LNUI_Add(element: LNHandle) -> LNResult;

    fn LNUIEventHandler_Create(callback: LNUIEventHandlerCallback, outDelegate: *mut LNHandle) -> LNResult;
    fn LNUIButton_ConnectOnClicked(uibutton: LNHandle, handler: LNHandle, outReturn: *mut LNHandle) -> LNResult;
    fn LNDebug_Print(text: *const u16) -> LNResult;
}

// ボタンがクリックされたときに呼び出される関数
extern fn on_clicked(handler: LNHandle) -> LNResult {
    unsafe {
        let text = "click!";
        let mut u16text: Vec<u16> = text.encode_utf16().collect();
        u16text.push(0);

        // デバッグ用の文字列表示
        LNDebug_Print(u16text.as_ptr());
    }
    return LN_OK;
}

fn main() {
    unsafe {
        LNEngine_Initialize();

        let text = "Click me";
        let mut u16text: Vec<u16> = text.encode_utf16().collect();
        u16text.push(0);    // '\0'

        let mut button: LNHandle = 0;
        LNUIButton_CreateWithText(u16text.as_ptr(), &mut button);
        LNUI_Add(button);

        // on_click を参照する関数オブジェクトを作成する
        let mut handler: LNHandle = 0;
        LNUIEventHandler_Create(on_clicked, &mut handler);

        // 関数オブジェクトとボタンの OnClicked シグナルを接続する
        let mut connection: LNHandle = 0;
        LNUIButton_ConnectOnClicked(button, handler, &mut connection);

        let mut running: LNBool = 1;
        while running != LN_FALSE {
            LNEngine_Update(&mut running);
        }

        LNEngine_Terminate();
    }
}

GIF 2020-12-23 10-06-56.gif

まず関数ポインタの定義です。これも関数宣言の中に直接書くと長くなってしまうので、エイリアスにします。

type LNUIEventHandlerCallback = extern "C" fn(handler: LNHandle) -> LNResult;

定義には extern が必要です。これが無いと、「FFI-safe じゃない関数が extern ブロックで使われてるよ」と怒られます。ちなみに ABI の指定は省略可能で、省略した場合は "C" になるようです。

次にボタンがクリックされたときに呼び出される関数です。上記関数ポインタの定義に合わせるだけですが、C から呼び出される関数ということで、ここでも extern の指定が必要でした。指定が無いと、関数を C-API に渡すところでコンパイルエラーとなります。

extern "C" fn on_clicked(handler: LNHandle) -> LNResult { ... }

あとは Lumino の都合に合わせてコールバックを登録していくだけです。

Lumino では、ボタンクリックのイベントハンドラなどへ生の関数ポインタを登録することはできません。一度関数オブジェクトのようなものを作成し、関数ポインタを包んでおく必要があります。

let mut handler: LNHandle = 0;
LNUIEventHandler_Create(on_clicked, &mut handler);

最後に関数オブジェクトとボタンの OnClicked シグナルを接続すると、マウスクリック時に on_clicked が呼ばれるようになります。

let mut connection: LNHandle = 0;
LNUIButton_ConnectOnClicked(button, handler, &mut connection);

ちなみに connection は接続を解除するために使います。

構造体の受け渡し

ベクトルや行列・色など、決まったメモリレイアウトを持つデータ構造をやり取りできるようにする必要がありますので、最後に構造体を使えるようにしてみます。

例として、ボタンがクリックされたらボタンの背景色を変更できるようにしてみます。

type LNResult = i32;
const LN_OK: LNResult = 0;

type LNBool = i32;
const LN_FALSE: LNBool = 0;
const LN_TRUE: LNBool = 1;

type LNHandle = u32;
const LN_NULL_HANDLE: LNHandle = 0;

type LNUIEventHandlerCallback = extern fn(handler: LNHandle) -> LNResult;

#[repr(C)]
struct LNColor {
    r: f32,
    g: f32,
    b: f32,
    a: f32,
}

#[link(name = "LuminoEngine")]
extern {
    fn LNEngine_Initialize() -> LNResult;
    fn LNEngine_Update(outReturn: *mut LNBool) -> LNResult;
    fn LNEngine_Terminate() -> LNResult;
    fn LNUIButton_CreateWithText(text: *const u16, outReturn: *mut LNHandle) -> LNResult;
    fn LNUI_Add(element: LNHandle) -> LNResult;

    fn LNUIEventHandler_Create(callback: LNUIEventHandlerCallback, outDelegate: *mut LNHandle) -> LNResult;
    fn LNUIButton_ConnectOnClicked(uibutton: LNHandle, handler: LNHandle, outReturn: *mut LNHandle) -> LNResult;
    fn LNDebug_Print(text: *const u16) -> LNResult;

    fn LNUIColors_DeepOrange(shades: u32, outColor: *mut LNColor) -> LNResult;
    fn LNUIElement_SetBackgroundColor(uielement: LNHandle, color: *const LNColor) -> LNResult;
}

static mut button: LNHandle = LN_NULL_HANDLE;

extern fn on_clicked(handler: LNHandle) -> LNResult {
    unsafe {
        let text = "click!";
        let mut u16text: Vec<u16> = text.encode_utf16().collect();
        u16text.push(0);
        LNDebug_Print(u16text.as_ptr());

        // 色を取得
        let mut color = LNColor{r: 0.0, g: 0.0, b: 0.0, a: 0.0};
        LNUIColors_DeepOrange(3, &mut color);

        // ボタンの背景色に設定する
        LNUIElement_SetBackgroundColor(button, &color);
    }
    return LN_OK;
}

fn main() {
    unsafe {
        LNEngine_Initialize();

        let text = "Click me";
        let mut u16text: Vec<u16> = text.encode_utf16().collect();
        u16text.push(0);

        LNUIButton_CreateWithText(u16text.as_ptr(), &mut button);
        LNUI_Add(button);

        let mut handler: LNHandle = 0;
        LNUIEventHandler_Create(on_clicked, &mut handler);

        let mut connection: LNHandle = 0;
        LNUIButton_ConnectOnClicked(button, handler, &mut connection);

        let mut running: LNBool = 1;
        while running != LN_FALSE {
            LNEngine_Update(&mut running);
        }

        LNEngine_Terminate();
    }
}

GIF 2020-12-23 12-33-23.gif

構造体と repr(C)

repr は構造体のメモリレイアウトを指定するために使います。メモリレイアウトの指定が無いと、struct のフィールドの並び順やパディングは 保証されなくなる そうです。C# にもこういった属性がありましたが、C-API とやりとりをするための構造体は repr(C) を指定して、C で期待するものと同じレイアウトにする必要があります。

構造体を使うときは、すべてのフィールドを明示的に初期化する必要があるようです。

let mut color = LNColor{r: 0.0, g: 0.0, b: 0.0, a: 0.0};

数が増えてくると煩わしく感じますが、struct にはコンストラクタのようなものはないので、static 関数で初期化して使うことが多いようです。

構造体を作ったら、あとはプリミティブ型の変数と同じようにポインタを取り出せます。

// 色 (オレンジ) を color へ格納
LNUIColors_DeepOrange(3, &mut color);

// ボタンの背景色に設定する
LNUIElement_SetBackgroundColor(button, &color);

ちなみに Lumino の LNUIColors_** シリーズの関数では、Material Design のカラーパレット をベースとした色を得ることができます。

グローバル変数

一般的にグローバル変数の使用は避けるべきですが、C-API のラッパーを作るとき、Rust 側のオブジェクトと Lumino のハンドルを対応付けるグローバルな配列が必要になったりするため、試してみることにしました。

グローバル変数は static または const 修飾とともに、グローバルスコープに定義できます。ただ調べてみると Rust は所有権を追跡するためにグローバル変数に多くの制限をかけており、変更は unsafe ブロック内からしかできないようです。

static mut button: LNHandle = LN_NULL_HANDLE;

extern fn on_click(handler: LNHandle) -> LNResult {
    button = xxxx;  // error[E0133]: use of mutable static is unsafe and requires unsafe function or block
    unsafe {

また、グローバル変数を使うための crate がいくつかあるようです。

まとめと今後について

Lumino の機能を呼び出すために必要な FFI 周りを一通り試してみました。GUIツールキットのチュートリアルじゃないです。ゲームエンジンです。ほんとうです。

Lumino の C-API はプログラマが直接叩くことはあまり想定されておらず、ラッパーを機械的に作りやすいように、使いやすさよりも一貫性や移植性を重視したデザインを目指しています。今回使った以外にも非同期処理やサブクラス化といった様々な機能がありますが、これらを使うときも多分足りると思います。

なので次は Rust として使いやすい API としてラップできるように、Rust らしさを学んでいくフェーズになるかなと思います。いやその前にちゃんと基礎から学び始めないとか… こういうのレビューしてくれるサービスとかないのかな。

さて、Rust を触るのは本当に今回が初めてでした。試せたのもドキュメントをつまみ読みしたほんの一部だけです。
しかしそれでも、C++ 長い間使っていて煩わしく思っていたところを上手くカバーしているな、という雰囲気を感じました。

とりあえず Rust のチュートリアルをちゃんと流した後、また戻ってきたいと思います。

最後になりますが、ここまで読んでいただきありがとうござます。
思ったより長くなってしまいましたが、誰かのお役に立てれば幸いです。

27
12
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
12