2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OBSプラグインをRustで作る: 手書きFFIでTint Filterを移植する

2
Last updated at Posted at 2026-03-02

はじめに

以前の記事 では、obs-plugintemplate を使って C で Tint Filter を実装しました。本記事では、同じ Tint Filter を Rust で実装し、OBS 上で動作させます。

Rust は安全性と高パフォーマンスを両立するシステムプログラミング言語です。OBS プラグインのような C ABI が必要なネイティブコードの開発にも利用できます。ただし、OBS 向けの Rust バインディングは 2026 年 3 月時点で実用可能なものが存在しません。そこで今回は手書きの FFI で OBS SDK を呼び出す方式を紹介します。

本記事の完成コードは GitHub で公開しています。

本記事は以前の記事の内容を前提とします。

obs-plugintemplate のセットアップと Tint Filter (C 版) の実装を済ませた状態から始めてください。

この記事でやること

  • Rust の cdylib クレートから OBS プラグインの DLL を生成する
  • OBS が要求するエクスポートシンボルを Rust で定義する
  • C 版の Tint Filter を Rust に移植し、OBS で動作させる
  • catch_unwind で panic が FFI 境界を越えないようにする
  • GitHub Actions で fmt / clippy / build を自動チェックする CI を構築する

対象読者

  • 前回までの記事を読了済みの方
  • Rust は初めて、または触り始めたばかりの方

検証環境

  • OS: Windows 11
  • OBS Studio: 32.1.0-rc2 (64 bit)
  • OBS SDK: 31.1.1 (obs-plugintemplate 経由で取得)
  • Rust: 1.93.1 (stable)
  • Cargo: 1.93.1

全体の構成

Rust で OBS プラグインを作るには、以下の 3 点が必要です。

  1. OBS が要求する C ABI シンボルを Rust からエクスポートする
  2. OBS SDK の関数を FFI 宣言で呼び出せるようにする
  3. obs.lib (インポートライブラリ) をリンクする

ディレクトリ構成は次のようになります。

.
├── Cargo.toml                  # cdylib クレート定義
├── build.rs                    # obs.lib のリンク設定
├── src/
│   ├── lib.rs                  # OBS モジュールエントリポイント
│   ├── ffi.rs                  # OBS SDK への FFI 宣言
│   └── tint_filter.rs          # Tint Filter の実装
├── data/
│   ├── effects/tint.effect     # シェーダー (C 版と同一)
│   └── locale/
│       ├── en-US.ini
│       └── ja-JP.ini
└── sample-plugin-using-rust/   # obs-plugintemplate (SDK 取得用)
    └── .deps/                  # OBS SDK (ヘッダ + obs.lib)

補足: obs-plugintemplate のディレクトリ名やコミット内容について

本記事では obs-plugintemplate のクローンを sample-plugin-using-rust にリネームし、buildspec.jsonname フィールドも書き換えていますが、Rust 版プラグインにとってこれらの変更は不要です。

obs-plugintemplate の CMake は buildspec.jsonname を C プラグインの出力名 (DLL 名) に使いますが、Rust 版では C プラグインのビルド自体を行いません。CMake は OBS SDK のダウンロードのためだけに実行しており、SDK のダウンロード処理に name フィールドは関与しません。

実質的に参照されるのは build.rs と CI 定義に書かれたディレクトリパスだけです。obs-plugintemplate のクローンはそのまま使い、build.rs のパスを合わせれば十分です。

1. Cargo プロジェクトのセットアップ

Cargo.toml

プロジェクトルートに Cargo.toml を作成します。crate-type = ["cdylib"] を指定すると、Cargo は .dll (Windows) / .so (Linux) / .dylib (macOS) を生成します。

[package]
name = "sample-plugin-using-rust"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[profile.release]
debug = true

profile.releasedebug = true は、クラッシュ時にスタックトレースを取得するための設定です。

build.rs

build.rs は Cargo のビルドスクリプトです。obs-plugintemplate が取得した OBS SDK のインポートライブラリ obs.lib のパスを Cargo に伝えます。

use std::env;
use std::path::PathBuf;

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let deps_lib = manifest_dir
        .join("sample-plugin-using-rust")
        .join(".deps")
        .join("lib");

    println!("cargo:rustc-link-search=native={}", deps_lib.display());
    println!("cargo:rustc-link-lib=obs");
}

2. OBS モジュールのエントリポイント (lib.rs)

OBS プラグインの DLL は、以下のシンボルをエクスポートする必要があります。

シンボル C マクロ 役割
obs_module_set_pointer OBS_DECLARE_MODULE() OBS がモジュールポインタを渡す
obs_module_ver OBS_DECLARE_MODULE() API バージョンを返す
obs_module_load - 初期化処理 (フィルタ登録など)
obs_module_unload - 終了処理
obs_module_set_locale OBS_MODULE_USE_DEFAULT_LOCALE ロケール設定
obs_module_free_locale OBS_MODULE_USE_DEFAULT_LOCALE ロケール解放
obs_module_text OBS_MODULE_USE_DEFAULT_LOCALE 文字列検索
obs_current_module OBS_DECLARE_MODULE() モジュールポインタ取得

C では OBS_DECLARE_MODULE()OBS_MODULE_USE_DEFAULT_LOCALE() のマクロが自動で定義しますが、Rust ではマクロを展開した内容を手書きします。

mod ffi;
mod tint_filter;

use std::os::raw::c_char;
use std::panic::AssertUnwindSafe;
use std::ptr;
use std::sync::atomic::{AtomicPtr, Ordering};

use ffi::{ObsModule, TextLookup};

// --- panic 捕捉ヘルパー ---

/// FFI 境界を越える panic を捕捉し、ログに出力してデフォルト値を返す。
pub(crate) fn catch_ffi_panic<F, R>(callback_name: &std::ffi::CStr, default: R, f: F) -> R
where
    F: FnOnce() -> R,
{
    match std::panic::catch_unwind(AssertUnwindSafe(f)) {
        Ok(val) => val,
        Err(_) => {
            unsafe {
                ffi::blog(
                    ffi::LOG_ERROR,
                    c"[sample-plugin-using-rust] panic caught in %s".as_ptr(),
                    callback_name.as_ptr(),
                );
            }
            default
        }
    }
}

// --- モジュールポインタ ---

static MODULE_PTR: AtomicPtr<ObsModule> = AtomicPtr::new(ptr::null_mut());
static LOCALE_LOOKUP: AtomicPtr<TextLookup> = AtomicPtr::new(ptr::null_mut());

pub(crate) fn current_module() -> *mut ObsModule {
    MODULE_PTR.load(Ordering::Acquire)
}

// --- OBS_DECLARE_MODULE 相当 ---

#[no_mangle]
pub extern "C" fn obs_module_set_pointer(module: *mut ObsModule) {
    MODULE_PTR.store(module, Ordering::Release);
}

#[no_mangle]
pub extern "C" fn obs_module_ver() -> u32 {
    ffi::LIBOBS_API_VER
}

// --- obs_module_load / unload ---

#[no_mangle]
pub extern "C" fn obs_module_load() -> bool {
    catch_ffi_panic(c"obs_module_load", false, || {
        tint_filter::register();
        unsafe {
            ffi::blog(
                ffi::LOG_INFO,
                c"[sample-plugin-using-rust] plugin loaded successfully".as_ptr(),
            );
        }
        true
    })
}

ここで重要なのは catch_ffi_panic です。Rust の panic が extern "C" 関数の境界を越えると未定義動作になります。std::panic::catch_unwind で panic を捕捉し、安全なデフォルト値を返すことでこの問題を防ぎます。

#[no_mangle] は Rust のシンボル名マングリングを無効化し、C ABI 互換の名前でエクスポートするために必要です。

3. FFI 宣言 (ffi.rs)

OBS SDK のヘッダに対応する Rust 側の型と関数を宣言します。

不透明型

OBS の内部構造体はポインタ経由でしかアクセスしないため、中身が空の enum で不透明型を表現します。

pub enum ObsModule {}
pub enum ObsSource {}
pub enum ObsData {}
pub enum GsEffect {}
pub enum GsEparam {}

obs_source_info 構造体

obs_source_info は OBS にフィルタ (ソース) を登録するための構造体です。C 版では約 50 個のフィールドがありますが、Rust でも全フィールドを #[repr(C)] で定義し、C とバイナリ互換にする必要があります。

使用しないコールバックフィールドは Option<unsafe extern "C" fn()> で定義します。Option に包まれた関数ポインタはポインタサイズと同一であり、None は null ポインタに対応します。

type Cb = Option<unsafe extern "C" fn()>;

#[repr(C)]
pub struct ObsSourceInfo {
    pub id: *const c_char,
    pub type_: c_int,
    pub output_flags: u32,
    pub get_name: Option<unsafe extern "C" fn(type_data: *mut c_void) -> *const c_char>,
    pub create: Option<unsafe extern "C" fn(settings: *mut ObsData, source: *mut ObsSource) -> *mut c_void>,
    pub destroy: Option<unsafe extern "C" fn(data: *mut c_void)>,
    pub get_width: Cb,
    pub get_height: Cb,
    // ... (残りのフィールドは None で初期化)
}

重要: obs_register_source_ssizeof(obs_source_info) を渡すため、Rust 側の構造体サイズが C 側と一致しなければなりません。コンパイル時にサイズを検証します。

#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ObsSourceInfo>() == 408);

Vec4 の再実装

OBS の vec4_from_rgba は C のインライン関数であり、obs.lib にはエクスポートされていません。Rust で再実装します。

#[repr(C, align(16))]
#[derive(Clone, Copy)]
pub struct Vec4 {
    pub ptr: [f32; 4],
}

impl Vec4 {
    pub fn from_rgba(rgba: u32) -> Self {
        let u = rgba.to_ne_bytes();
        Vec4 {
            ptr: [
                u[0] as f32 / 255.0,
                u[1] as f32 / 255.0,
                u[2] as f32 / 255.0,
                u[3] as f32 / 255.0,
            ],
        }
    }
}

align(16) は C 側の __m128 メンバに合わせたアライメント指定です。

4. Tint Filter の実装 (tint_filter.rs)

C 版と対比しながら Rust 版を見ていきます。

フィルタデータ

C 版では bzalloc で確保したメモリを void * で受け渡しします。Rust では Box を使い、Box::into_raw で OBS に所有権を移譲します。

struct TintFilterData {
    context: *mut ObsSource,
    effect: *mut GsEffect,
    param_tint_color: *mut GsEparam,
    param_strength: *mut GsEparam,
    tint_color: Vec4,
    strength: f32,
}

create コールバック

unsafe extern "C" fn create(settings: *mut ObsData, source: *mut ObsSource) -> *mut c_void {
    crate::catch_ffi_panic(c"create", ptr::null_mut(), || unsafe {
        let mut data = Box::new(TintFilterData { /* ... */ });

        ffi::obs_enter_graphics();
        // エフェクトファイルの読み込み
        let path = ffi::obs_find_module_file(
            crate::current_module(),
            c"effects/tint.effect".as_ptr(),
        );
        // ...
        ffi::obs_leave_graphics();

        Box::into_raw(data) as *mut c_void  // 所有権を OBS に移譲
    })
}

C 版の bzalloc / bfree の代わりに Box::new / Box::from_raw を使います。Box::into_rawBox のメモリを解放せずにポインタを返し、Box::from_raw はポインタから Box を復元します。

destroy コールバック

unsafe extern "C" fn destroy(data: *mut c_void) {
    crate::catch_ffi_panic(c"destroy", (), || unsafe {
        let f = Box::from_raw(data as *mut TintFilterData);
        ffi::obs_enter_graphics();
        if !f.effect.is_null() {
            ffi::gs_effect_destroy(f.effect);
        }
        ffi::obs_leave_graphics();
        // f は Box の drop で自動解放される
    });
}

C 版では bfree(f) で明示的に解放しますが、Rust では Box がスコープを抜ける際に自動的にメモリを解放します。

video_render コールバック

描画処理は C 版とほぼ同一です。

unsafe extern "C" fn video_render(data: *mut c_void, _effect: *mut GsEffect) {
    crate::catch_ffi_panic(c"video_render", (), || unsafe {
        let f = &mut *(data as *mut TintFilterData);

        if f.effect.is_null() {
            ffi::obs_source_skip_video_filter(f.context);
            return;
        }

        if !ffi::obs_source_process_filter_begin(
            f.context, ffi::GS_RGBA, ffi::OBS_ALLOW_DIRECT_RENDERING,
        ) {
            return;
        }

        ffi::gs_effect_set_vec4(f.param_tint_color, &f.tint_color);
        ffi::gs_effect_set_float(f.param_strength, f.strength);
        ffi::obs_source_process_filter_end(f.context, f.effect, 0, 0);
    });
}

フィルタ登録

C 版では構造体の指示付き初期化子 (.id = "tint_filter") を使いますが、Rust では全フィールドを列挙し、使用しないフィールドに None を指定します。

pub fn register() {
    let info = ObsSourceInfo {
        id: c"tint_filter_rust".as_ptr(),
        type_: ffi::OBS_SOURCE_TYPE_FILTER,
        output_flags: ffi::OBS_SOURCE_VIDEO,
        get_name: Some(get_name),
        create: Some(create),
        destroy: Some(destroy),
        get_properties: Some(get_properties),
        update: Some(update),
        video_render: Some(video_render),
        // ... 残りは None ...
    };

    unsafe {
        ffi::obs_register_source_s(&info, std::mem::size_of::<ObsSourceInfo>());
    }
}

5. ビルドとインストール

ビルド

cargo build --release

生成される DLL は target/release/sample_plugin_using_rust.dll です。

インストール

Cargo は DLL 名のハイフンをアンダースコアに変換します。OBS のプラグインディレクトリにコピーする際、元のハイフン区切りにリネームしてください。

cp target/release/sample_plugin_using_rust.dll \
   "C:/ProgramData/obs-studio/plugins/sample-plugin-using-rust/bin/64bit/sample-plugin-using-rust.dll"

data/ 配下のエフェクトファイルとロケールファイルも忘れずにコピーします。

cp -r data/effects "C:/ProgramData/obs-studio/plugins/sample-plugin-using-rust/data/"
cp -r data/locale  "C:/ProgramData/obs-studio/plugins/sample-plugin-using-rust/data/"

6. GitHub Actions CI

コードの品質を継続的に検証するため、GitHub Actions で fmt / clippy / build を自動実行する CI を構築します。

obs-plugintemplate を submodule にする

Rust プロジェクトのルートに obs-plugintemplate のクローンを配置している場合、そのまま git add すると「embedded git repository」の警告が出ます。CI で submodule のコンテンツを取得するために、正しく submodule として登録してください。

git submodule add https://github.com/obsproject/obs-plugintemplate.git sample-plugin-using-rust

ワークフロー定義

.github/workflows/ci.yml を作成します。

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  CARGO_TERM_COLOR: always

jobs:
  check:
    name: fmt / clippy / build
    runs-on: windows-2022
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache OBS SDK
        id: cache-obs-sdk
        uses: actions/cache@v4
        with:
          path: sample-plugin-using-rust/.deps
          key: obs-sdk-${{ hashFiles('sample-plugin-using-rust/buildspec.json') }}

      - name: Download OBS SDK via CMake
        if: steps.cache-obs-sdk.outputs.cache-hit != 'true'
        working-directory: sample-plugin-using-rust
        run: cmake --preset windows-ci-x64

      - name: cargo fmt
        run: cargo fmt --check

      - name: cargo clippy
        run: cargo clippy -- -D warnings

      - name: cargo build
        run: cargo build --release

実際に動かすまでに 3 つの問題に遭遇しました。

問題 1: submodule が展開されない

actions/checkout@v4 はデフォルトで submodule を取得しません。CMake ステップで CMakePresets.json が見つからずに失敗します。

CMake Error: Could not read presets from .../sample-plugin-using-rust:
File not found: .../sample-plugin-using-rust/CMakePresets.json

解決策: checkout に submodules: true を追加します。

- uses: actions/checkout@v4
  with:
    submodules: true

問題 2: Windows SDK バージョンの不一致

submodule の問題を解決しても、CMake の configure で次のエラーが発生します。

CMake Error at CMakeLists.txt:5 (project):
  Generator
    Visual Studio 17 2022
  given platform specification with
    version=10.0.22621
  field, but no Windows SDK with that version was found.

obs-plugintemplate の CMakePresets.jsonwindows-x64 プリセットで "architecture": "x64,version=10.0.22621" を指定しています。これはローカル開発環境の Windows 11 SDK 10.0.22621.0 に合わせた設定です。

問題は windows-latest ランナーにあります。2026 年 2 月以降、GitHub Actions の windows-latest は Windows Server 2025 (SDK 10.0.26100) を指すようになりました。SDK バージョンが一致しないため CMake が失敗します。

解決策: ランナーを windows-2022 に固定し、CI 用プリセット windows-ci-x64 を使います。

runs-on: windows-2022  # SDK 10.0.22621 が利用可能
run: cmake --preset windows-ci-x64  # CI 用プリセット

obs-plugintemplate は windows-x64 (ローカル用) と windows-ci-x64 (CI 用) の 2 つの Windows プリセットを提供しています。CI では必ず windows-ci-x64 を使用してください。

windows-latest の指すイメージは定期的に更新されます。obs-plugintemplate のプリセットが要求する Windows SDK バージョンとランナーの SDK バージョンが一致するか、CI 構築時に必ず確認してください。

問題 3: OBS SDK のキャッシュキーが空になる

submodule を正しく取得していない状態では、hashFiles('sample-plugin-using-rust/buildspec.json') がファイルを見つけられず空文字を返します。キャッシュキーが obs-sdk- (ハッシュなし) になり、意図しないキャッシュヒットが発生する可能性があります。

問題 1 を解決すれば自動的にこの問題も解消されますが、キャッシュキーの挙動は把握しておくべきです。CI のログで Cache not found for input keys: obs-sdk- のようにハッシュ部分が空になっていないか確認してください。

7. 動作確認

OBS を起動し、任意の映像ソースにフィルタを追加します。Tint Filter (Rust) が表示され、色味と強度の調整ができれば成功です。

C 版との主な違い

観点 C 版 Rust 版
メモリ管理 bzalloc / bfree Box::new / Box::from_raw
文字列リテラル "text" (暗黙の null 終端) c"text" (明示的な CStr リテラル)
panic 安全性 不要 catch_unwind で FFI 境界を保護
構造体初期化 指示付き初期化子で必要なフィールドだけ指定 全フィールドを列挙 (未使用は None)
マクロ OBS_DECLARE_MODULE() 展開結果を手書き
インライン関数 vec4_from_rgba をそのまま使用 Rust で再実装

まとめ

Rust の cdylib クレートから OBS プラグインの DLL を生成し、手書き FFI で Tint Filter を移植しました。既存の Rust バインディングに頼らずとも、OBS の C API を直接呼び出すことで Rust によるプラグイン開発が可能です。

FFI 境界では catch_unwind による panic 捕捉と、unsafe ブロックへの安全性コメント付与を徹底しています。OBS の終了やフィルタの追加/削除を繰り返してもクラッシュしないことを確認しました。

GitHub Actions CI では、submodule の取得設定と Windows SDK バージョンの不一致という、ローカル開発では発生しない CI 固有の問題に遭遇しました。obs-plugintemplate が提供する windows-ci-x64 プリセットと windows-2022 ランナーの組み合わせで解決できます。CI をセットアップする際は、ローカルとの環境差異に注意してください。

参考

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?