はじめに
以前の記事 では、obs-plugintemplate を使って C で Tint Filter を実装しました。本記事では、同じ Tint Filter を Rust で実装し、OBS 上で動作させます。
Rust は安全性と高パフォーマンスを両立するシステムプログラミング言語です。OBS プラグインのような C ABI が必要なネイティブコードの開発にも利用できます。ただし、OBS 向けの Rust バインディングは 2026 年 3 月時点で実用可能なものが存在しません。そこで今回は手書きの FFI で OBS SDK を呼び出す方式を紹介します。
本記事の完成コードは GitHub で公開しています。
本記事は以前の記事の内容を前提とします。
- OBSプラグイン開発をWindows+Visual Studio 2022で始める - obs-plugintemplateを使ったビルド方法
- OBSプラグインの設定変更からリリースまで(Windows) - zip化とGitHub Releasesによる配布
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 点が必要です。
- OBS が要求する C ABI シンボルを Rust からエクスポートする
- OBS SDK の関数を FFI 宣言で呼び出せるようにする
-
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.json の name フィールドも書き換えていますが、Rust 版プラグインにとってこれらの変更は不要です。
obs-plugintemplate の CMake は buildspec.json の name を 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.release の debug = 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_s に sizeof(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_raw は Box のメモリを解放せずにポインタを返し、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.json は windows-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 をセットアップする際は、ローカルとの環境差異に注意してください。