はじめに
こんにちは、26卒のUnityエンジニアでRust初心者のあつあつです!
今回は自作したサウンドエンジン、Neziaの設計について、特に「データ指向設計(DoD)」の観点から書いてみたいと思います。
なぜ作るのか — DoDなサウンドエンジンが見当たらない
定番の商用ミドルウェア —— Wwise・FMOD・CRI ADX —— はいずれもオブジェクトベースの設計で、コンテナやアクター、イベントといったオブジェクトを組み合わせてサウンドを構築します。
DoDを設計の柱に掲げているものは、少なくとも公に語られているものでは見かけません。
※ 各社の内部実装は非公開なので「中身が完全に非DoD」と断言はできません。あくまで公に掲げられている設計思想の話です。
だったら、最初からDoDで作成したサウンドエンジンを書いたらどうなるんだろう? パフォーマンス勝てるのかな?という疑問から開発が始まりました。
もちろん、勉強という側面も強いです!
サウンドスレッドという特殊な土俵
DoDの話に入る前に、サウンドエンジンがゲームロジックと決定的に違う点を、独立した話題として押さえておきます。
サウンドスレッドのリアルタイム制約です。これはデータの持ち方とは別の、サウンド固有の事情です。
Neziaは メインスレッド と サウンドスレッド の2スレッド構成をとります。
サウンドスレッドはOSのオーディオドライバから一定間隔(たとえば48kHz・256サンプルなら約5msごと)に呼び出され、その都度バッファを埋めて返します。
次の呼び出しまでに埋めきれないと、音はプツッと途切れます(バッファアンダーラン)。締切は待ってくれません。
この締切を守るため、サウンドスレッドでは次の操作を禁止しています。
| 禁止する操作 | 理由 |
|---|---|
| ロック(Mutex / RwLock 等) | 競合時にブロックされ、締切を破る |
ヒープ確保・解放(Box, Vec::push 等) |
アロケータ内部のロックやシステムコールで遅延が不確定になる |
| I/O・システムコール全般 | カーネルのスケジューリング次第で遅延する |
許されるのは、事前確保済みバッファへの読み書き、アトミック操作、lock-freeなリングバッファ経由のメッセージ受け渡し、固定長配列上の演算くらいです。
ではアセットのロードやオブジェクトの生成・破棄は誰がやるのか。それはリアルタイム制約のないメインスレッドの仕事です。2つのスレッドは、ロックを使わず、lock-freeなリングバッファ(コマンド/イベント)や共有メモリ経由で通信します。
まとめると、サウンドエンジンの土俵は「確保もロックもできないスレッドで、数千の音源を毎フレーム締切内に処理しきる」こと。
こういった制約もある中で、データの持ち方(DoD)の方を見ていきます。
データ指向設計とは
データ指向設計(DoD)を一言でいうと、**「データの持ち方をCPUキャッシュに合わせる設計」**です。
オブジェクト指向だと、ひとつのオブジェクトが自分の持ち物(音量・ピッチ・再生位置…)をぜんぶ抱え込みます。
これらはメモリ上に点在するので、数千個を毎フレーム処理しようとすると問題が出ます。
CPUはメモリをキャッシュライン(64バイト)単位で引っ張ってくるのに、欲しいデータが飛び飛びに散らばっていると、走査のたびにキャッシュミスを連発してしまうのです。
DoDはこれを逆転させ、データを種別ごとに密な配列へ並べ替えます。音量は音量だけの配列、ピッチはピッチだけの配列、という具合です。
OOP: [vol pit cur ...][vol pit cur ...][vol pit cur ...] ← 1 個に全部入り、点在
DoD: volume[]: [vol vol vol vol ...] ← 種別ごとに密
pitch[] : [pit pit pit pit ...]
こうすると「全音源の音量をまとめて処理」が、連続したメモリの線形走査になります。キャッシュにも分岐予測にも優しい。これがDoDのキモです。
Unityエンジニアの方ならおそらくUnityのDOTS(ECS)が思い浮かぶでしょう。
ただ、同じDoDでも「データをどう並べて管理するか」にはいくつか流派があります。次の節で、Neziaが採ったスパースセット方式を、DOTSのアーキタイプ方式と比べながら見ていきます。
スパースセット:アーキタイプ方式との違い
「データを種別ごとに密配列で持つ」を実現する方式は、ECSの世界でも大きく2つに分かれます。
アーキタイプ方式(Unity DOTSなど)
同じコンポーネント構成を持つエンティティを「アーキタイプ」としてまとめ、そのデータを **16 KiBの「チャンク」**に詰めます。
チャンク内は コンポーネント種別ごとの並列配列(SoA) + エンティティIDの配列で、占有スロットは隙間なく並びます(エンティティを消すと末尾を穴に移動して埋める)。
同じ構成のエンティティが完全に連続して並ぶので、走査がとにかく速い。
反面、エンティティにコンポーネントを足し引きすると所属アーキタイプが変わり、データを別のチャンクへ丸ごと引っ越すコストがかかります。
DOTS chunk: pos[]: [pos pos pos pos ...] ← アーキタイプ(構成)ごとに
vel[]: [vel vel vel vel ...] 16 KiB チャンクへ詰める
スパースセット方式(Neziaの採用)
スロットの管理を「スパースセット」(後述)に任せ、コンポーネントデータは種別ごとの密配列に持つ方式です。
エンティティの追加・削除はその場でO(1)。構成が変わってもデータの大移動は起きません。
「そのぶん走査が遅いのでは?」と思うかもしれませんが、Neziaの場合そうはなりません。
ひとつのWorld内では vol・pitch・state… といった密配列がすべて同じ添字で並びます(dense indexを共有する)。
エンティティ i のデータは各配列の i 番目にまとまっているので、まとめて舐めるときも完全な連続アクセスになり、アーキタイプ方式と同じくらいキャッシュに優しいです。
※ 「走査が完璧に連続にならない」のは EnTT のような汎用スパースセットECSの話です。あちらは各コンポーネントが独立した並び順を持つため、複数コンポーネントを結合して走査するときに間接参照が入ります。Neziaは1つのWorld内で dense index を共有しているので、この間接参照は生じません(そのかわり、後述するように"持てるコンポーネントの組み合わせ"の自由度は捨てています)。
ではアーキタイプ方式と比べて何を捨てているのか。DOTSのアーキタイプは「エンティティが実行時にコンポーネントを付け外しし、無数の構成パターンが現れる」状況を効率よくさばくための仕組みです。
一方サウンドエンジンが扱うオブジェクトは バス・Source・エフェクト と種類が決まっていて、それぞれが持つデータも固定です。
動的な構成の組み合わせ爆発が起きないので、アーキタイプ方式の強力さは要りません。さらにソースは生成・破棄がとにかく激しい(銃声ひとつ足音ひとつが数百ミリ秒で生まれて消える)ので、追加・削除がO(1)で済むのも好都合です。
だったら、1つのスパースセット+固定の密配列という素朴な作りで十分、というのがNeziaの判断でした。
では中身を見ていきます。密配列に詰めると言っても、ただ詰めるだけだと困ることがあります。
音源は再生が終われば消えますし、新しい音源は途中で湧きます。配列の途中が虫食いになると、線形走査のうまみが消えてしまいます。
かといって生成のたびに添字がズレると、外から「あの音源」を指す手段がなくなってしまいます。
スパースセットは、この「外向けの安定したID」と「内部のぎっしり詰まった配列」を両立させる仕組みです。Neziaの実装はこういう構成になっています(コメントは実コードのものです)。
pub struct SparseSet {
/// EntityId.index -> 密配列インデックスへのマッピング
sparse: Vec<Option<SparseEntry>>,
/// 密配列インデックス -> EntityId.index への逆マッピング
dense_to_sparse: Vec<u32>,
/// 解放済みスロットの再利用リスト
free_list: Vec<u32>,
next_index: u32,
capacity: usize,
}
ポイントが2つあります。
ひとつは、SparseSet自身はコンポーネントデータを持たないことです。スロットの割り当て・解放・世代検証だけを一元管理し、実データ(volume配列など)は各Worldが別に持ちます。
SparseSetが返す密インデックスに合わせて、各Worldが同期的にpush / swap_removeします。「ID管理」と「データの中身」を分離しているわけです。
もうひとつは、削除の仕方です。要素を消すときは、末尾の要素を空いた穴に詰めます(swap-remove)。
これは先ほどのDOTSのチャンクが「エンティティを消すと末尾を穴に移動して埋める」のとまったく同じやり方で、これで密配列は常に隙間なく詰まったままになり、線形走査がそのまま効きます。
dense_to_sparseという逆引きを持っているのは、この「末尾を引っ越しさせたとき、引っ越してきた要素のID側の参照を貼り替える」ために必要だからです。結果、追加・削除・参照がいずれもO(1)になります。
二層ID:名前(共有)と席番号(個体)を分ける
サウンドオブジェクトには、「共有される不変な側面」と「個体ごとに変わる側面」があります。たとえば波形やアセット定義は共有でき、再生位置や音量は個体ごとに持つ必要があります。
NeziaはこれをIDのレベルで分け、IDを二層にしています。
論理ID(Hash ID) は、オーサリングツール側の文字列("MasterBus", "sfx/explosion"など)をハッシュ化したu32値です。
アセットに紐づく不変の識別子で、同じ文字列からは常に同じ値が出ます。
これが「共有される側」── アセット参照や永続化、デバッグ表示に使う"名前"です。値域が ~4Gと巨大なので、これを直接配列の添字には使えません。
物理ID(Entity ID) は、スパースセットが発行する一時的な値です。
#[repr(C)]
pub struct EntityId {
pub index: u32, // 密配列への O(1) アクセスに使う席番号
pub generation: u32, // スロット再利用時の無効化検出に使う
}
indexは密配列の席番号、generationは「その席が何代目か」です。これが「個体ごとの状態」を指す側で、再起動や再生成で変わり得ます。
この2つをHashMap<HashId, EntityId>で橋渡しします。
論理ID (Hash ID) ──→ HashMap ──→ 物理ID (Entity ID)
0x3F8B2A1C { index: 5, generation: 2 }
(不変の名前) (密配列の席・一時的)
大事なのは、このHashMap検索を、ランタイム初期化やイベント発火のときだけに限定することです。
通常のフレーム処理では物理ID(=密配列の席番号)だけで回すので、ホットパスにHashMapは出てきません。
「重い名前解決は処理の入口に寄せ、毎フレームのループは配列インデックスだけで殴る」という割り切りです。これは前述の「線形走査を効かせたい」という動機とも一貫しています。
generationがなぜ要るのか
最初これがピンと来なかったのですが、考えるとすぐ必要性が分かりました。
スロットは解放されるとfree_listに戻り、後で別の音源に再利用されます。もし席番号(index)だけで音源を参照していたら、こういう事故が起きます。
- 音源Aが席5に座る。あなたは「席5=A」というハンドルを持つ。
- Aが再生終了して席5が空く。
- 新しい音源Bが席5に座る。
- あなたの古いハンドル「席5」が、いつのまにかBを指している。
そこで、席を再利用するたびにgenerationをインクリメントします。ハンドルは{ index: 5, generation: 2 }のように世代込みで持ち、アクセス時に「席5の現在の世代」と照合します。
食い違えば「このハンドルはもう無効」と判定できます。いわば ABA問題のハンドル版で、これを理解できたのは個人的に大きかったです。
実はDoDとサウンドの制約はめっちゃ噛み合う!
序盤で話したサウンドスレッドの制約と、ここまで話したDoDによるデータの持ち方は、本来まったく別の問題です。
前者はスレッドの実行環境の話、後者はメモリレイアウトの話。しかし、この2つの相性はとても良いものでした。
DoDの「事前に確保した密配列を、頭から順に舐める」というスタイルを、サウンドスレッドの制約と並べてみます。
- 実行時に確保しない(密配列は最初に取ってある)→ no-alloc を自然に満たす
- ロックを取り合う共有可変状態を増やさない(種別ごとに分かれている)→ no-lock と相性がいい
- ポインタを追って飛び回らないので、分岐もキャッシュミスもページフォルトも減る → 締切内に収めやすい
結果的に、速さや挑戦のためにDoDを選択しましたが、相性としてもとても良いものでした。
おわりに
「DoDでサウンドエンジンを作ったら、パフォーマンス的にどこまでやれるんだろう?」という疑問から始めましたが、作ってみて一番の収穫は、サウンドとDoDの相性の良さでした。
偶然の産物!
今後もNeziaの開発は頑張っていこうと思っています。
いきなり Wwise や FMOD に肩を並べようとするのではなく、最初の目標は Unity の AudioSource + AudioListener + AudioMixer 一式をドロップインで置き換えられる状態です。
今後はUnity上でのワークフローの確立のためにエンジンのデーモンを作成しています。また生成AI特化の機能など、DoD以外にもさまざまな挑戦をしていこうと思います!
そしてその後は本格的なミドルウェアと同じ土俵に立てるか。冒頭の「どこまでやれるのか?」を確かめていくフェーズです。
もしよろしければ使ってみて感想などいただけると嬉しいです!ではまた!
Unity Integrationも並行で開発中です!