LoginSignup
6
5

More than 1 year has passed since last update.

Rust 向けに SDL2 のラッパを書いた

Last updated at Posted at 2022-12-02

この記事は限界開発鯖 Advent Calendar 2022 の 3 日目です.

きっかけ

2020 年 5 月, 私はこの限界開発鯖に参加しました. そのころ, 当時限界開発鯖のメンバーだった loxygenmusical_typer というゲーム を作っていました. これは流れる歌にあわせて歌詞をひたすらタイピングするゲームです. pygame を用いて作られていて, 非常によくできていました. しかし, 肝心のソースコードが少し読みづらく, 特にゲームのステート管理が GameInfo という 1 つのクラスに一任されていて肥大化していました (しばらくの間, 肥大化したステートのコードをサーバー内で GameInfo などと揶揄していました).

しかしゲームは面白かったので, そのコードの改善をしてみたくなりました. そこで, @kawaemon は Go 言語で, 私は Rust 言語で再実装し始めることになりました. 無駄に「ゲーム用ライブラリ/フレームワークを使わない」縛りを設けたせいで少し遠回りになってしまいました. ウインドウシステムなどのライブラリ整備やローレベルな部分のデバッグなど紆余曲折を得て, 2022 年 7 月になってようやく本家とほとんど同じようなレベルに到達しました.

付属のサンプル譜面で遊んでいるゲーム画面

こちらが再実装したもののレポジトリになります. ウインドウ, 入力イベント, 描画などに SDL2 を使用しており, それを Rust から動かすためのバインディングのライブラリも作成しました.

既存ライブラリへの不満

Rust で SDL2 を利用するのであれば, Rust-SDL2 が既に存在しています. 当初はこれを用いて作成しようとしましたが, 描画周りを作ったりしているうちに徐々に不満点が溜まってきました. 例えば,

  • モジュールが細分化されすぎているのにまとまりがなく, どこに何があるのか分からなくて利用しにくい.
  • SDL2 のオブジェクトを Rc で管理しているオーバーヘッドがもったいない.
  • API の引数が SDL2 のものそのまま過ぎて, Rust らしい書き方がやりづらい.
  • エラーの型がすべて String になっており, エラーハンドリングしにくい.
  • Canvas による描画システムはメインスレッドでしか呼べず !Send になっているのに, 描画処理に可変参照を要求されるので Canvas のコンテキストが取り回しづらい.
  • 入力イベントの enum が巨大すぎて, イベントの受け取りのパターンマッチが大変.
  • :

というような具合です. せっかくなので, 勉強になるだろうとバインディングのライブラリを自作してみることにしました.

目指した設計

先ほどの不満点を踏まえて, 新しいライブラリに持たせたい特徴を考えました. すなわち,

  • Rust フレンドリーに, C 特有の煩わしさをできる限りラップする
  • 元の API でわかりにくい, 間違えやすい, 非推奨な部分を親切に書き換える
  • 外部のライブラリによって拡張できるようにする

これは今までのよりもリッチなものなので, rich-sdl2-rust という名前にしました. これらの方針を元に, できる限りトレイトやライフタイムを駆使して設計することとしました. また, rust-bindgen を用いて C のヘッダから生の FFI 部分は自動生成し, それを rich-sdl2-rust-sys クレートとして用意しました.

工夫の紹介

しかし, さすがにここでライブラリの全貌を解説するわけにはいきません. ドキュメントはしっかり用意してあります ので, ここではライブラリで実施した工夫をいくつかご紹介します.

スレッド安全性を除去してオーバーヘッドを削減

元々, SDL の API はメインスレッドから呼び出さないと安全に動作しません. そのため, メインスレッドからしか呼び出せないような設計にしつつ, 余分なオーバーヘッドを避けます. そこで, 専用の struct を用意してこれを使わないと API にアクセスできないようにします. 更に Cell をフィールドに置くことで SendSync の自動実装が外れるようにします. 実際には PhantomData でそう見せかけているだけなので, メモリは消費しません.

/// A root controller for SDL2.
pub struct Sdl {
    _phantom: PhantomData<Cell<u8>>,
}

assert_not_impl_all!(Sdl: Send, Sync);

ライフタイムと PhantomData でより厳密に

他の API は Sdl を借用するように要求しており, これにより SDL の API が初期化されて有効な間だけ利用できます. こちらも実際には PhantomData で保持しているかのように扱っているため, メモリの消費は最小限です.

/// A video controller by SDL2.
pub struct Video<'sdl> {
    _phantom: PhantomData<&'sdl Sdl>,
}

assert_not_impl_all!(Video: Send, Sync);

impl<'sdl> Video<'sdl> {
    /// Constructs a video controller from a root controller.
    #[must_use]
    pub fn new(_: &'sdl Sdl) -> Self {
        let ret = unsafe { bind::SDL_InitSubSystem(bind::SDL_INIT_VIDEO) };
        if ret != 0 {
            Sdl::error_then_panic("Sdl video")
        }
        Self {
            _phantom: PhantomData,
        }
    }
    // ...
}

bitflagstyped_builder の活用

ビットフラグなら bitflags を, Builder パターンなら typed_builder を活用して, 楽に安全なオプションを提供するようにしています.

bitflags! {
    /// A flag to represent what type is used in audio data.
    pub struct AudioFormatFlag: u8 {
        /// Whether a type is floating-point number.
        const FLOAT = 1 << 0;
        /// Whether a type is big endian.
        const BIG_ENDIAN = 1 << 4;
        /// Whether a type is signed.
        const SIGNED = 1 << 7;
    }
}

/// A builder for the [`Window`].
#[derive(Debug, TypedBuilder)]
pub struct WindowBuilder {
    #[builder(default = "Untitled".into(), setter(into))]
    title: String,
    #[builder(default = WindowCoord::centered())]
    x: WindowCoord,
    #[builder(default = WindowCoord::centered())]
    y: WindowCoord,
    #[builder(default = 640)]
    width: u32,
    #[builder(default = 480)]
    height: u32,
    // ...
}

つまづいた点

これは通常のライブラリ開発と違ってバインディングを扱うので, やはり解決するのに時間がかかった問題が多々ありました. ここではそういった少し分かりにくい問題と, その解決の過程を解説しておきます.

前提知識として, 未定義動作 (Undefined Behaviour, 以降 UB と略します) について話しておきます. Rust では, 不正なデータへのアクセスや無責任な操作のことを起こしてはいけない未定義動作として定義しています. 例えば, 以下のようなものが UB になります (参考文献).

  • データ競合. 1 つの領域へ同時に複数の CPU が書き込むことがあるような処理.
  • ダングリングポインタの参照外し. 解放済みの領域を読み取る処理. 読み取るだけでなく, その参照 &T を作ることも含む.
  • アライメントされていないポインタの参照外し. 読み取るだけでなく, その参照 &T を作ることも含む.
  • ポインタのエイリアシングルールを破る. 同じ領域への &T&mut T が同時に発生する処理.
  • 間違った ABI での関数呼び出し. 引数の型のサイズが一致しないなど.
  • その型として不正な値が入ったオブジェクトが, 正常な値としてアクセスできる状態で存在すること. つまりこれらを無理やり作るコードが存在するだけでアウト.
    • 01 以外の値が入った bool.
    • 定義に無い値が格納された enum.
    • ヌル値の関数ポインタ.
    • サロゲートまたは char::MAX より大きい値が入った char.
    • 未初期化値の整数, 浮動小数点数, ポインタ.
    • その struct ごとの条件を破った値が入った構造体.
    • :
  • :

全体的に, C/C++ よりも厳しめになっています. しかしそのおかげで, Rust はアグレッシブな最適化ができるようです.

さて, コンパイラはこういった UB が「絶対に起こらないように」最適化することが許されています. UB を起こすようなコードを検知した場合に, そのコードが実行されないよう削ってしまうことがあるのです.

CStringCStr

SDL の API に C 文字列 (ヌル終端文字列) を渡す機会がしょっちゅうありました. Rust の標準ライブラリには, 確保した C 文字列である CString と C 文字列のスライスである CStr があります. こちらから文字列を渡す場合は, Rust の &str だとヌル終端の 1 個ぶんが存在しません. そのため CString を使って確保し直すことになります.

let cstr = CString::new("Hello").unwrap();

実際に文字列を渡すにはその CString のポインタを渡すわけですが, 作ったあとにすぐ as_ptr してしまうと問題が発生します. 一時オブジェクトが解放されてしまうので, ダングリングポインタが発生します. 正しくは, CString のオブジェクトを変数に保持し続けたまま, そのポインタを取得するように書く必要があります.

let ptr = CString::new("Hello").unwrap().as_ptr();
// ここで既に `CString` のオブジェクトは drop 済
unsafe {
    // ダングリングポインタへアクセスしている, UB だ!
    *ptr;
}

let cstr = CString::new("Hello").unwrap();
let ptr = cstr.as_ptr();
unsafe {
    // ok
    *ptr;
}

なお, CStringDrop 実装では先頭要素に 0 を書き込む という安全措置が施されています. これのおかげでバッファオーバーランのような事態が未然に防がれているのはありがたかったです.

// Turns this `CString` into an empty string to prevent
// memory-unsafe code from working by accident. Inline
// to prevent LLVM from optimizing it away in debug builds.
#[stable(feature = "cstring_drop", since = "1.13.0")]
impl Drop for CString {
    #[inline]
    fn drop(&mut self) {
        unsafe {
            *self.inner.get_unchecked_mut(0) = 0;
        }
    }
}

ただし空文字列が渡されている状態になるため, 実装ミスには気づきにくかったです. 実際には, ウインドウのタイトルを設定する API のバインディングを実装していたときに, デスクトップ環境側から「タイトルに空文字列を設定しようとしている」という警告が出たタイミングで気づきました.

同様に, 一時オブジェクトのポインタを得る行為は UB となるので注意しましょう. クロージャなどの関数で返す値も同様ですのでご注意ください.

// area は `Option<Rect>` 型の引数.
// `impl From<Rect> for SDL_Rect` が存在する.
unsafe {
    bind::hoge(area.map_or(std::ptr::null(), |area| {
        // &SDL_Rect が *const SDL_Rect に型強制されるが, これはダングリングポインタ.
        &area.into()
    }));
    // よって UB が発生する!
}

MaybeUninit の UB

SDL の API のいくつかは, 未初期化の構造体を確保して, その場所のポインタを渡して初期化してもらうものがあります. こういった場合, 標準ライブラリの MaybeUninit が便利です.

/// Returns the viewport rectangle of the renderer.
pub fn viewport(&self) -> Rect {
    let mut raw_rect = MaybeUninit::uninit();
    unsafe {
        bind::SDL_RenderGetViewport(self.as_ptr(), raw_rect.as_mut_ptr());
        raw_rect.assume_init().into()
    }
}

ただし, まだ未初期化のときに assume_init を使うコードは即 UB となります.

build.rs の設定

こちらは rich-sdl2-rust-sys の話です. bindgenbuild.rs というコンパイル前に実行されるファイルで操作するものとなっています. ここで用意した wrapper.h ヘッダを読み込み, それを OUT_DIR という cargo から指定された環境下に出力する設定を組みます.

rich-sdl2-rust-sys/build.rs
println!("cargo:rerun-if-changed=wrapper.h");

let mut builder = bindgen::Builder::default();
{
    builder = builder
        .header("wrapper.h")
        .clang_args(&includes)
        .allowlist_function("SDL_.*")
        .allowlist_type("SDL_.*")
        .allowlist_var("SDL_.*")
        .generate_comments(false)
        .prepend_enum_name(false)
        .parse_callbacks(Box::new(bindgen::CargoCallbacks));
}
// 他にコンパイル時分岐での設定があります...
let bindings = builder.generate().expect("bindgen builder was invalid");

let root_dir = env::var("OUT_DIR").expect("OUT_DIR not found");
let root = PathBuf::from(root_dir);
bindings
    .write_to_file(root.join("bind.rs"))
    .expect("writing `bind.rs` failed");

こうして生成したファイルを, 今度は src/lib.rs から include! マクロを使って読み込みます. これでファイルが展開されて, ライブラリとして利用できるようになります.

rich-sdl2-rust-sys/src/lib.rs
//! Rust FFI to `SDL2/SDL.h`
#![allow(warnings)]

include!(concat!(env!("OUT_DIR"), "/bind.rs"));

しかし, ことはそう単純ではありません. こういったバインディングするライブラリのクレートでは, リンクするライブラリファイルの準備が必要です. ライブラリのリンクには, 以下のようなパターンが考えられます.

  1. pkgconfig などでパスが通っているライブラリと静的/動的リンクする
  2. ユーザが指定したパスの静的/動的ライブラリとリンクする
  3. こちらでソースコードをダウンロードしてコンパイルし, それと静的/動的リンクする

それぞれを feature や環境変数で切り替えられるようにする必要があり, Windows と macOS の両方に対応させようとしたので思ったよりも苦戦しました. これはほとんどがコマンドの引数, ファイルの配置, コンパイルの手順やリンクするライブラリの指定といった地道な作業の積み重ねです.

ドキュメント生成

この rich-sdl2-rust は, ほとんどの構造体や関数にドキュメントコメントを記述しています. しかし, いくつかの機能は feature によって利用できるかどうかが制御されています. ドキュメント生成でこれを追加するために, nightly の機能である #![cfg_attr(feature = "nightly", feature(doc_cfg))]lib.rs に記述しています.

また, doc.rs のドキュメント生成環境だとコンパイルできません. wrapper.h#include している SDL および関連する標準ライブラリなどのヘッダが存在しないからです. そのため, GitHub Pages を用いて自前で生成したドキュメントのウェブページをデプロイすることにしています.

.github/workflows/publish.yaml
name: Publish

on:
  push:
    tags:
      - "v*"

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  BUILD_DIR: "${{ github.workspace }}/build/"

jobs:
  # 中略
  doc:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dawidd6/action-download-artifact@v2
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          workflow: rust.yml
          name: doc-artifact

      - name: Deploy docs
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: .

要改善点

  • 全くテストできていないので, まだ潜在的なバグがあるかもしれません.
  • 元の API とは使い方が変わっている部分が多いです.
    • 特に Renderer, Surface, Texture あたりのチュートリアル的なドキュメントが必要かもしれません.
  • SDL のリポジトリ構成にならってクレートごとにリポジトリを作成しましたが, 同じリポジトリ内に [workspace] としてクレートをまとめてもよかったかもしれません.
    • ライブラリの二重リンクの問題などがあったため, v0.12.1 ですべて rich-sdl2-rust のクレートにまとめました.

感想

Rust の標準ライブラリには, std::ffi 配下に C 互換向けの便利な機能が多く搭載されています. おかげさまで思ったよりスムーズに実装が進み, 良い開発体験が得られました. 特にドキュメントが親切で, よくあるミスや代替手段, 気をつけるべき条件などを確認しやすくなっているのは, 丁寧に改善が繰り返されていることを感じさせます.

肝心の musical-typer-rust は, 元々優先度が低く, 他に色々とやることがあったり UB を起こしている箇所を特定したりする手間が入ったりして, かなり開発に遅れが出ました. その代わりに簡潔なアプリ/ゲーム開発ができるライブラリが完成したので, だいぶ満足しています.

6
5
0

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
6
5