EmptyEngine: ゲームエンジン実装例

前回Editor-Onlyなゲームエンジンを紹介しました。 Editor-Onlyという体で説明してましたが、「実装例」として結局エンジンランタイムを一揃いリポジトリ内に用意しちゃってます。 今回はこのランタイム実装例のおおまかな構造を紹介します。

github.com

全体像

いくつものモジュールに分割されています。

graph BT
    Serialization --> Storage
    World --> Serialization
    World --> ObjectModel
    Graphics --> ObjectModel
    Mesh --> Graphics
    Text --> Graphics
    WebGpu --> Mesh
    WebGpu --> Graphics
    PlayerLoop --> ObjectModel
    Audio --> ObjectModel
    Collision --> ObjectModel
    Windowing

EmptyEngine.ObjectModelでUnityライクなオブジェクト/コンポーネントによるシーン表現を定義しています。 後述のプレイヤーループを分離した都合からデータ指向との両立は難しくなりました。 ゲームの組みやすさも含めて今回はコンポーネントベースとしています。

そのEmptyEngine.ObjectModelに依存するモジュールが複数あり、各種コンポーネント等を提供している作りです。 全部のモジュールを使ってもいいし、要件に合わなければ別の実装に入れ替えてもいいです。

シリアライザの分離

殆どのゲームエンジンではシリアライズ形式の変更はできませんし、変更したくなっても後戻りできない…!みたいな状況になりがちです。

これを避けたくて、今回のエンジンでは各機能モジュールからシリアライザに依存しないようにしました。 代償として、機能モジュールからの要求が暗黙的なものになってます。 逆に言えばこれらの要件さえ満たせば、後からシリアライザを別物に変更できます。

  • 公開フィールド/プロパティをシリアライズしてほしい
  • プリミティブ型と、それらから成る構造体/配列をシリアライズしてほしい
  • コンストラクタインジェクションしてほしい
  • etc...

EmptyEngine.Serializationでは上記を満たす型ごとのシリアライザをSourceGeneratorに書かせ、MessagePackに詰めています。

コンポーネント/アセットへのDI

このエンジンの特徴として、デシリアライザがDIを行う点が挙げられます。 コンポーネント/アセットはコンストラクタ注入で依存サービスを受けられます。

public sealed partial class PlatformerObject(SceneWorld world, RenderWorld renderWorld, DefaultUpdateRegistry registry)
    : UpdatableComponent(registry)
{
    ...
}

デシリアライザが引数付きコンストラクタにも対応している場合、多くの実装ではシリアライズされたデータから引数を決定します。 一方で、EmptyEngine.Serializationではコンストラクタに引数があれば対象型のインスタンスをコンテナから引いて渡すことにしています。 後付けのDIと違い、コンポーネント/アセット側はコンテナやシリアライザへの依存を一切持たずにDIの恩恵を受けられる状態です。

全部コンテナに入れよう

各モジュールは、必要があればサービスインスタンスをコンテナに登録する想定で作っています。 例えば、シーン状態を抱えるSceneWorldオブジェクトはグローバルなものではなく、インスタンス1つをSingletonスコープで登録しています。 GameObjectの生成など、シーンへの操作を行うコンポーネントでは必ずそのSceneWorldをDIで受けて使う形です。

この作りで嬉しい点は、全ての状態の破棄がコンテナの破棄で済むところです。 UnityではFast Enter Play Modeへの移行に伴ってstaticフィールドの初期化等を漏れなく行う必要が出ちゃいましたが… 今回の実装ではPlay/Editの切り替えに伴ってコンテナの破棄/再生成で状態をリセットできます。

(これはstaticを使わないで済む仕組みを用意したという話であり、もちろん各モジュール側でstatic状態を持たないような実装が必要です)

Updateは後付け

デフォルトのプレイヤーループを用意していません。 各プロジェクトが自由に組めたほうがいいと考えています。

ObjectModel/Serializationはコンポーネントのライフサイクルのみを知っていて、Updateの概念を知りません。 各コンポーネントが「私はUpdate対象ですよ」を自己申告してまとめて呼び出せるようにしています。 上記コード例のPlatformerObjectのように、登録先もコンストラクタで受けます。

「Update対象の登録先」はインスタンス毎の管理ができます、「常に描画フレームレートで呼ぶUpdate」「ポーズ中は呼ばれないUpdate」等を定義して各コンポーネントがどの種別に入るか自己申告できます。 今のところ上記シリアライザがKeyed DIに対応していないので、種別ごとに継承しただけの別の型を用意してサービスとして登録しています。

graph TB
    MainLoop --> |ポーズ中じゃなければ呼び出し|DefaultUpdateRegistry
    MainLoop --> |常に呼び出し|IgnorePauseUpdateRegistry

    DefaultUpdateRegistry --- |登録|PlatformerObject["PlatformerObject<br>Component"]
    IgnorePauseUpdateRegistry --- |登録|TweenUI["TweenUI<br>Component"]

この後付けUpdateの利点がプレイヤーループの自由度であり、代償がデータ指向との相性の悪さと捉えています。 また、レンダラーコンポーネントなどの必ず描画フレームに紐づく処理は別枠で各モジュールの中に閉じ込められています。

.NET 10で実装

ランタイムとして.NET 10を使い、エンジンの殆どがC#実装です。 コンポーネント等のゲーム側のロジックもC#実装であり、単純にエンジン実装パッケージ群を参照したゲームコードがdotnetツールチェインでビルドされています。

Windows/Mac向けにはnet10.0で、iOSビルドはTargetFrameworkの変更で動くようにしています。 Web向けにはWebAssembly Browser AppのSDKを利用してビルドしています。 上記のシリアライザ含め各モジュールはAOTコンパイルでも動作するようにしています。

LinuxやAndroidは、同様に対応できるでしょ…と思いつつ試してません…

マルチプラットフォーム対応

ランタイム部分のマルチプラットフォーム対応はdotnetに丸投げでしたが、 その他の機能部分のマルチプラットフォーム対応もあまり自前では行わないようにしています。

描画はwebgpu.h相当のインターフェイスをプラットフォーム共通で使い、Webビルドではemscripten組み込み実装、それ以外ではDawnを利用しています。

テクスチャのエンコーディングにはBasis Universalを利用しています。 ビルドターゲット毎の再エンコード等は不要で、Web環境など末端のGPU機能が未知の場合にも便利です。

音声再生はNVorbisでVorbisのデコードを行い、WebAudio APIまたはOpenALでPCMを流す形です。

Windowingは3種類のバックエンドが存在し、仕方なく小さな抽象化を挟んでいます。

  • DesktopではSilk
  • iOSではdotnetのiOS向けworkloadの機能
  • Webでは自前実装

Program.csでやること

ゲームエンジンと言いながらデフォルトのエントリポイントを用意していません。 上記の構成を踏まえ、ゲームプログラムがやるべきことを示すと次のような感じです。

  • コンテナを作って各種サービスを登録
  • Windowを作ってハンドルを描画モジュールに教える
  • 任意のタイミングのループでゲームロジックを更新
  • Play/Edit切り替え時にコンテナを作り直し

実際の例はここにあります。

おしまい

やはり一番思想が強いのは前回紹介したエディタの部分ですが… ランタイム実装例としても次のような特徴を持たせています。

  • モジュール毎に捨てて別実装に入れ替えられる
  • DIコンテナの利用を前提とした構成
  • シリアライザに依存しない機能実装

このエンジンを使った成果物としてGenso Flood Frontがあります。 名前もついてない「EmptyEngine実装例エンジン」ですが、既存エンジンに頼らずにゲームを作れるようになりました。 やったね。

EmptyEngine: Editor-Onlyなゲームエンジン作った

AIエージェントがゲームランタイムをゴリゴリ書いてくれる! もう全員オレオレエンジン使ったらいいんじゃないかな…? でもエディタのことを考えるとそうもいかないよね…

と思い、任意のランタイムに接続できるEditor-Onlyなゲームエンジンを作ってみました。

github.com

VS Codeのサイドバーに開いたEmptyEngineのヒエラルキーとインスペクタ

ランタイムとエディタの分離

昨今自作エンジンランタイムの実装ハードルは下がっていて、プロジェクトごとに別物を使いたい事情も多々あります。 ここで、エディタを再利用できるようにしたいというのが今回のモチベーションです。

エディタの選択によってランタイムが固定されてしまうのも、その逆も避けたい。 ランタイムとエディタが密な関係を持つことなく、好きに組み合わせられる仕組みを作れないか実践してみました。

実際に、リポジトリ内で複数ランタイムを用意してどちらもエディタから操作できるようになっています。

  • 独自実装エンジン
    • .NET実装
    • UnityライクなGameObject/Componentベース
  • Bevy(既存OSS)

全体像

多くのゲームエンジンにはホットリロードの仕組みがあります。 再コンパイルして、動いているプロセスに適用して…という仕組みを抱えているわけですが… それってdotnet watchがやってくれてない…?という考えで少々乱暴な構成になっています。

graph LR
    subgraph EmptyEngine
        E[Editor]
        H[Host]
    end
    R[なんかランタイム]

    H --> |dotnet watch run|E
    H --> |起動|R
    E --> |WebSocket接続|H
    R --> |WebSocket接続|H

Hostがエントリポイント

上図でのHostがエディタとして起動するエントリポイントです。 実際のエディタ本体はHostがその場でdotnetプロジェクトを生成してwatch runで起動します。ALCとも参照リークとも付き合わないぞ。 ここで、watchの結果再起動になっても問題ないようにundoスタックなどの状態はテンポラリなファイルとして書き出しています。

Hostの役割は次のとおりです。

  • Editorプロジェクトを作って起動
  • ランタイムを起動
  • Editor/ランタイム間のパケットを中継

Editorとランタイムに必要な情報を刻んだマニフェストのパスを引数に取ります。

{
    "runtimeCommand": "dotnet watch --non-interactive --no-hot-reload run",
    "editorProps": "Editor/Editor.targets"
}

ランタイムに関してエンジン側は何も知らないので、起動コマンドだけ教えてもらってます。 再コンパイル/適用などの仕組みはランタイム側の仕事です。 リポジトリ内の例ではdotnet watchやcargo watchに丸投げです。

Editor側の設定はMSBuild向けのtargetsファイルにまとめていて、Hostが生成するエディタプロジェクトがそれを利用します。Editor実装本体はNuGetパッケージとして公開して参照していて、エディタバージョンがプロジェクトに紐づいて管理できるようになっています。

WebSocketで通信

エントリポイントである都合上、Hostだけがエディタ/ランタイムの両方を知っています。 HostからそれぞれにWebSocketのエンドポイントを渡し、接続してもらうことでメッセージの経路を確保しています。 それ以降、Hostはメッセージをただ中継するだけで内容などは知りません。

WebSocketとしているのはランタイムがWebブラウザ実装の場合も利用できるようにするためです。 以降の図ではHostを省略してエディタとランタイムが直接通信しているように描いていますが、全て実際にはHostが挟まって中継しています。

シーン同期

Unityは、言ってみればEditorの中にランタイムを抱えている状態です。 プロセス内でシリアライズ済み表現と実オブジェクトの同期を行っています。 UnityでEditor拡張を書くとSerializedObjectを操作することになると思いますが、それがここで言う「シリアライズ済み表現」です。

graph LR
    subgraph E[Editor]
        direction LR
        UI[Editor UI]
        SO[/SerializedObject/]
        subgraph R[Runtime]
            OBJ[Component]
        end
    end

    UI <--> |操作|SO
    SO <--> |同期|OBJ

一方で、EmptyEngineではエディタとランタイムは完全に別プロセスです。 エディタ特有のシリアライズ済み表現を、ランタイムが読めるバイト列に変換して送信します。
EmptyEngineではシリアライズ済み表現としてAuthoringObjectが存在します。これはスナップショット的なDTOなのでSerializedObjectとはちょっと役割が違いますが…持っている情報は似たようなものです。

graph LR
    subgraph E[Editor]
        UI[Editor UI]
        AO[/AuthoringObject/]
        HBS[IHierarchyBlobSerializer]
    end
    DATA[/バイト列/]
    subgraph R[Runtime]
        OBJ[シーン表現]
    end

    UI <--> |操作|AO
    AO <--> HBS
    HBS <--> DATA
    DATA <--> |ロード|OBJ

エディタの有無にかかわらず、ゲームプログラムであれば大抵「ディスクに保存されたシーンを読み込む仕組み」が存在すると思います。 その保存形式と同じ形でやり取りしてあげればランタイム側の対応は最小限で済みます。
そのために、エディタ拡張側のIHierarchyBlobSerializerがランタイムが読めるバイト列に変換する責任を持ちます。

ここで触れておきたいのは、この仕組みでもランタイムのシーン構造には制約を課していない点です。 IHierarchyBlobSerializerはエディタ側の表現がヒエラルキー構造というだけで、ランタイムがECSであればエディタ側のヒエラルキーはAuthoring dataの役割になります。 このとき、IHierarchyBlobSerializerはUnity ECSで言うBaker相当の役割も内包することになります。 リポジトリ内では、Bevyサンプルが当てはまります。

ランタイムでの実装

ランタイムから見ると、シーンロードの仕組みにそのまま突っ込めるバイト列がやってきます。

ここで適用の挙動を少し考える必要があります。 最低限のシーンロードの仕組みとして既存のオブジェクトを破棄して新規シーンを構築する方法が考えられます。 ただし、エディタ編集でデータが来るたびにこれを行うと非シリアライズ対象の状態がクリアされてしまいます。 例えばPlayerオブジェクトがシリアライズ対象のPositionと非シリアライズ対象のVelocityを持っていた場合、シーン編集時に位置は保持されますが、その時持っていた速度は失ってしまうという具合です。

graph LR
    subgraph P[Player]
        Position[Position: 10, 20, 30]
        Velocity[Velocity: 3, 1, 0]:::vel
    end
    P --> 破棄

    subgraph P2[Player']
        Position2[Position: 20, 20, 30]
        Velocity2[Velocity: 0, 0, 0]:::vel
    end
    シーンデータ[/シーンデータ/] --> |デシリアライズ|P2

    classDef vel fill:#e3f2fd,stroke:#1976d2,color:#0d47a1

これを避けたければ、元のオブジェクトインスタンスを保ったままシリアライズ対象のフィールドだけ上書きする必要があります。

graph LR
    subgraph P[Player]
        Position[Position: 10, 20, 30]
        Velocity[Velocity: 3, 1, 0]:::vel
    end

    subgraph P2[Player']
        Position2[Position: 20, 20, 30]
        Velocity2[Velocity: 3, 1, 0]:::vel
    end

    P --> |同一インスタンス|P2

    Position --> |編集結果で更新|Position2
    Velocity --> |そのまま|Velocity2

    classDef vel fill:#e3f2fd,stroke:#1976d2,color:#0d47a1

また、ランタイムからシーン状態を送り返す仕組みも用意しています。 シーン内の状態はゲームの進行に伴って変化するので、それをEditor側にも反映する仕組みです。 例えばPositionの変更後に別のフィールドを編集した場合、その時点でEditorが知っているPositionが送信されます。ランタイム側でのPosition更新がEditorに反映されていない場合、その編集によって位置が巻き戻ってしまいます。

sequenceDiagram
    participant E as Editor
    participant R as Runtime

    Note over E: Position: 0, 0, 0
    E -->> R:同期
    Note over R: Position: 0, 0, 0
    R -->> R:ロジック更新
    Note over R: Position: 10, 0, 0
    E -->> R:同期
    Note over R: Position: 0, 0, 0<br>巻き戻ってしまった!

そこで、ランタイムの状態をエディタに送り返すようにすれば巻き戻りは最小限で済みます。また、これによってエディタUIでリアルタイムにランタイムの状態を監視できるようになります。 ランタイムからの見え方としては、エディタから定期的に状態要求のメッセージが来るので、それに応じて返信するだけです。

というわけで、ランタイム側には努力目標が2つあります。

やること やらなかった代償
シリアライズ対象の値だけを更新する エディタでの編集のたびに非シリアライズ状態がクリアされる
シーン状態の要求に返信する エディタでリアルタイム状態が見えない
PlayModeでの編集時に巻き戻る

別に、代償があるだけで、どっちもやらなくても動きます。

アセットのインポート/書き出し

アセットロードは次のような感じです。 素直な流れだと思いますが、シーン同期と同様にランタイムにとって都合のいい形でアーティファクトを書き出しておく想定です。

graph LR
    SF[(Source File)]

    subgraph E[Editor]
        I[IAssetImporter]
        AO[/AuthoringObject/]
        AAS[IAssetArtifactStore]
    end

    A[(Artifact)]

    subgraph R[Runtime]
        AS[アセットローダー]
    end

    SF --> I
    I --> AO
    AO --> AAS
    AAS --> A
    A --> AS

アーティファクトの形式や保存場所はランタイムのアセットロードの仕組みにとって都合のいい形で決めればよいです。 エディタ拡張側のIAssetArtifactStoreが、それに合わせた場所と形式でアーティファクトを書き出す責任を持ちます。

例えば、リポジトリ内の実装例では次の2パターンがあります。

IAssetArtifactStore ランタイム実装
特定のディレクトリに書き出し 実行ファイルからの相対パスで読み込み
書き出したディレクトリをHTTPで配信 GETリクエストで読み込み

TypeCatalog

多くのゲームエンジンは型リフレクションの仕組みを持っています。 その結果を使って、シリアライザやインスペクタUIが動いているわけです。

EmptyEngineではランタイムの型システムなんぞ知らないので、代わりにTypeCatalog.jsonを介して同等の情報をエディタに読ませています。 シーン同期では「ランタイムと同じ形式でシリアライズしといてね」をエディタ拡張に押し付けました。 一方で「Bevyの型リフレクションの結果を返してね」をdotnetで実装するのは辛いです。 そこで、抽象レイヤーではなくファイルでのやり取りとしています。

graph LR
    R[Runtime]
    T[/TypeCatalog.json/]
    E[Editor]

    R --> |ビルド時に生成|T
    T --> |読み込み|E

TypeCatalogはJSON Schemaをベースとして、エディタ側で必要になる情報をいくつか追加したものです。 つまり、エディタのInspectorは実質「JSON Schemaに従ってフィールドを編集できるedit form」です。

ランタイム側はTypeCatalogとして辻褄が合うようなjsonを出力する必要があります。 ビルド成果物のゲームには要らないので、実ランタイムの外での生成を想定してます。 (用意したサンプルではMSBuildやCargoのビルドフック)

Webサーバーとして起動

その他の特徴として、エディタがWebサーバーである点が挙げられます。 Webブラウザを介してEditor UIを触ることになります。

  • 適切なトンネリングによってリモート作業できる
  • コンテナ内での起動が容易

実際に、Tailscaleを利用して次のような形でリモート開発ができるのを確認しています。

graph LR
    subgraph TN[tailnet]
        E[EmptyEngine Editor/Host]
        R[WasmAppHost]
    end

    subgraph B[ブラウザ]
        EU[Editor UI]
        RP[Runtime Page]
    end

    E <--> |操作|EU
    R --> |読み込み|RP
    E <--> |接続|RP

また、Web UIである恩恵として、WebViewベースで他のアプリ内に同居できます。 VS Code内で開く拡張も用意しました。

人間以外の存在がエンジンを操作する口としてHTTPエンドポイントも用意しています。 リモートで指示したAIエージェントが編集した結果をリモートで確認できます。

おしまい

今回作ったエディタを使い、昔作ったゲームをオレオレエンジンに移植しました。 ひとまずこのくらいのゲーム内容を表現する能力はあります。

unityroom.com

上記ゲームではランタイム実装例として用意した.NET製エンジンを使ってます。 結局ランタイム含む1つのゲームエンジンがリポジトリに含まれちゃいました。 これについては別の機会に紹介できればと思います。

荒削りですが、気軽に小さいゲームを作れるツールとして使っていきたいです。 気になった方はぜひ触ってみてください。

Opusコーデックをバラす

音声コーデックのOpusがあります。 GitHub - xiph/opus at v1.6.1 · GitHub

CE-LTというものにバラしました。 github.com

CELTだけにバラす

Opusは基本的に何にでも使えるコーデックですが、その汎用性を実現するために2種類のコーデックを中に抱えています。

  • SILK: 線形予測符号化
  • CELT: MDCTベース

この2つを諸々の条件に応じて切り替えたりフェードしたりするわけですが… ネットワークを介さない再生であればCELTだけでも問題ありません。

ここで、CELT単体のデコーダを利用すれば余計な処理を減らせるかも…ですが、 現在のエコシステムでデータを作りやすいのは「Opus向けのパケット」であり、CELT単体で扱えるようになっていません。

そこで、CELT_ONLYモードでエンコードされたパケットをCELT_ONLYモード前提で解析するデコーダを用意しました。 最低限のOpusパケット解析だけ行い、あとはCELTデコードを行います。SILKのことは知りません。

Benchmark Time (ns) CPU (ns) Iterations
bench_OPUS_Decode 1894542 1885054 373
bench_CE_LT_Decode 1785334 1727580 407

微妙…

非線形処理と線形処理にバラす

CELTはMDCTベースのコーデックなので、デコード時は最終的にIMDCT含む線形処理を行います。 複数の音声を再生する場合、通常はデコード後の波形をミックスしますが… 残っている処理が線形変換だけであれば事前にミックスしても出音が変わらないことが期待できます。 つまり、いくら音を増やしてもIMDCTは一回だけで済むんじゃないかという魂胆です。

そこで、celtのデコード処理を途中で分割してCEとLTと呼ぶ2ステージにしました。

  • CE: Linear Transformを取り除いたら残ったもの
  • LT: Linear Transform
Benchmark Time (ns) CPU (ns) Iterations
bench_CE_LT_Decode 1785334 1727580 407
bench_CE_Decode 1194927 1199777 560

CEステージがだいたい67%でした。 実際はLTステージをこの後に一回だけ行うので… 音数が多ければ多いほど効率化されて理論上は最大33%程度負荷を削れる、はずです。

周波数空間での処理

今回で言うCEとLTの間の中間表現はまさに周波数空間のスペクトルです。 この表現を途中で抜きだせる利点として、周波数空間でのエフェクト適用の効率化が期待できます。

通常は一度PCMにデコードしたバス信号に対してわざわざ周波数空間に変換してフィルタ処理などを掛けて戻すわけですが、なんかもったいない感じしますよね…

最初から係数列の中間表現が見えていればやりやすくなるエフェクトもあり… このクソ雑memsetがハイパスフィルタに相当します。

memset(X, 0, sizeof(celt_norm) * wav.channels * 20);

ただし、この結果のスペクトルは通常のEQだと見られない異様な立ち上がりを見せ、出音も当然ガビってしまいました。そんな上手く行かないよね…

Audacityでの「イコライザ」適用結果と、上記処理での結果

まとめ

Benchmark Time (ns) CPU (ns) Iterations
bench_OPUS_Decode 1894542 1885054 373
bench_CE_LT_Decode 1785334 1727580 407
bench_CE_Decode 1194927 1199777 560

まだ実験しただけ…という感じですが、バラしても劇的には有利にはならないみたいです。

音数が増えた際に有利になるかも…というのは周波数空間ミックスも含めて実際に試したいところですが、まだできてません。

UnityをOpusコーデックに対応させる

あけおめ! Unity6.3での新機能として、Scriptable Audio Pipelineが提供されました。IAudioGenaratorを実装することで、任意の生成波形をAudioClipに代わって利用できます。

docs.unity3d.com

任意の波形を渡せるということは、任意のコーデックの音声データをデコードして渡すことも可能です。 やってみよう。

Opus

ここでいうOpusは音声コーデックのOpusのことです。

opus-codec.org

リアルタイム処理に自信…みたいな紹介ですが実のところ何にでも使えるコーデックです。

できたもの

パッケージとしてまとめました。

github.com

.wav をプロジェクト内に置くと、インポート設定の上部にインポータ選択のドロップダウンが現れるはずです。OpusClipとしてインポートすると、Opusエンコードされたデータを抱えるアセットになります。AudioSource で再生可能です。

こうして

こう

実装内容

Scriptable Audio Pipeline自体の説明は他の記事やマニュアルに譲るとして、今回の実装特有の部分だけ説明します。

エンコード/デコード

今回はlibopus をそのまま利用してエンコード/デコードを行っています。 そのまま、というのはDLLとしてビルドした成果物がそのままパッケージに入っています。

アセット化

今回、Opus fileやその他コンテナは利用していません。 長さやサンプルレートなどのメタデータはアセットにシリアライズしていて、バイナリ部分は純粋なopusエンコード済みデータとそのパケットサイズの列です。 バイナリデータはTextAssetとして抱えています。

デコーダインスタンス

libopusでは「デコーダ」を作成して、それを使ってデコードを行う仕組みです。

デコーダハンドル作成してくれる関数がありますが、これを使うとこの前まとめた通りSafeHandleの利用が必須です。

friendsea.hateblo.jp

一方で、Scripted Audio Pipeline が要求するIRealtimeの実装はunmanagedな型の必要があるため、SafeHandleなどの参照型は使えません。 (おそらく、IRealtimeの実体はネイティブメモリ領域に載せているためにこのような制約があるのだと思います)

ありがたいことに、libopus ではopus_decoder_createだけでなく、事前に確保されたメモリをデコーダインスタンスとしてセットアップするopus_decoder_initも提供されています。そこで、IRealtimeの中に直接デコーダインスタンスを埋め込む形で対応しました。Destroyを呼ぶ必要もありません。 IRealtime実装のメモリ上でのサイズが巨大になりますが…値コピーをできるだけ避けるようにしています。

デコード単位

Opusのエンコード/デコード単位の選択肢はサンプルレートによって変わりまずが…48kHzの場合は次のうちのどれかです。好きなサイズを選んで使います。

  • 120, 240, 480, 960

一方で、Scripted Audio Pipeline が波形生成時に供給するサンプル数は引数のバッファ長に従う必要があります。私の試した環境だと1024サンプルずつでした。Process の返り値としてサンプル数を返せますが、特に賢いハンドリングなどは見受けられませんでした…

今回フレームサイズは480ですが、必要なサンプル数に達するまでデコードし、余った分は次回のプロセスで使うようにバッファに詰めて対応しています。

波形終了後

インターフェース実装のisFiniteをtrueとしてlengthを返すようにしましたが、平気でその長さを超えてProcessが呼ばれます。止まりません。 また、渡されるバッファの内容は何も保証されておらず、無音出力には0を詰め直す必要があります。

有限長の実装例が見当たらないのでよくわからないのですが…正しく止める方法があれば教えてほしいです…

やっていないこと

実用するなら以下の対応も必要だと思いますが、今回はやっていません。

リサンプリング

インポート元のwavのサンプルレートと実行時の出力サンプルレートが違うと再生スピードがズレます。 冒頭にリンクを張ったExample内の”Prefer matching the host's format when possible to avoid conversion.”という文言は「逆にホストとフォーマットが違う場合は変換するよ」という意味かと思っていたのですが…別にリサンプリングはしてくれませんでした。

リサンプラ自体は絶対UnityAudio内部に存在しているはずなんですが…今回は様子見です。

マルチプラットフォーム対応

これは試していないだけです。 libopusのビルド成果物を配置してあげれば基本的にどのプラットフォームでも動くはずです。

ネイティブAPIは?

「Scriptedなんちゃら」シリーズはUnity方言で「ネイティブ側にインターフェイスがあるのを、マネージドコードへのコールバック経由でC#実装できるようにしたもの」と言う意味で捉えています。 今回のケースだと、わざわざマネージドに処理を渡したうえでDllImportでネイティブを呼び出しています。もやる…

グラフィック系のネイティブプラグインと同様にインターフェイスを公開してくれたらそっちでも実装できて嬉しいのですが…ひとまず次の認識です。

  • コード実行の速度の差は大した問題ではなくて、GCによる停止がオーディオスレッド的にはまずい
  • マネージドランタイムによるコード実行をしてしまうと「マネージドスレッド」になっちゃうのでGCで停止してしまう
  • IRealtimeをBurstコンパイルしている場合はマネージドランタイムでは実行されないので問題なし

(本当はIPlayableとかもネイティブAPIが欲しい)

おしまい

今回はScripted Audio Pipelineのお試しでした。 UnityAudioは基本Vorbisなので、他のコーデックを使えるのはメリットになりますが、実用にはもうちょっと整備が必要そうです。

C#/dotnet: 結局SafeHandleって必要なの?

これだけじゃよくわからないよね… learn.microsoft.com

SafeHandleでの包み方の説明は多くありますが、次の事項の解釈を具体的に記しておきます。

  • 何をやってるのか
  • どういう場合に使わない選択がありうるか

結論 : リソースを使うネイティブ関数に対してリソース破棄関数がスレッドセーフじゃない限り必要

SafeHandleとは

冒頭のリンク記事での説明の通り、ネイティブハンドルを握ってP/Invokeする場合に使います。 オペレーティングシステムが~とか書いてありますが、サードパーティ製のライブラリだろうがネイティブハンドルを握ったら使うべきものです。 ざっくり2つの機能を持ちます

  1. Interlockedな参照カウントによる、リソース破棄のスレッドセーフ担保
  2. CriticalFinalizerObjectを継承している

CriticalFinalizerObjectはランタイムが特殊処理を行うマーカーになっています。
.NET FrameworkではファイナライザをConstrained Execution Regionsで実行する特殊処理が入っていたとのこと。 dotnetではCER自体が非対応らしいですが…他のファイナライザより後に実行されるという部分は引き継がれてるかも。

今回の説明はスレッドセーフについての話です。 公式な説明はどこにあるかというと…ソースコードでした… runtime/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/SafeHandle.cs at 1a9df9161a4c2eda46613580923c36ba5f660ccc · dotnet/runtime · GitHub

どうやってスレッドセーフを保証してるのか

解決したい問題は「ハンドルを使ってネイティブ関数を呼ぶタイミングで、別スレッドからDisposeが呼ばれてるかもしれない」です。 これに対して次のようになります。

  • ハンドル割当直後は参照カウントを1とする
  • 破棄以外のネイティブAPIは必ず参照カウントのインクリメントとデクリメントで挟まれる
  • 参照カウントが 1 → 0 になったタイミングでネイティブハンドルの破棄が呼ばれる
  • SafeHandle.Dispose()は参照カウントのデクリメントを行う(その結果0になれば本当に破棄)
sequenceDiagram
    participant Thread1
    participant SafeHandle
    participant Thread2
    Thread1 ->> SafeHandle : DangerousAddRef : 参照カウント 1 → 2
    Thread2 ->> SafeHandle : Dispose呼び出し : 参照カウント 2 → 1
    Thread1 ->> SafeHandle : ネイティブ関数呼び出し
    Thread1 ->> SafeHandle : DangerousRelease : 参照カウント 1 → 0 (本当の破棄)

例えば、このシーケンスの例だとThread1からのネイティブ呼び出しに伴って、前後にインクリメントとデクリメントが呼ばれています。 同時に別スレッドからDisposeが呼ばれてしまいました。この場合ネイティブリソースの破棄はDisposeでは呼ばれず、Thread1からの参照カウントのデクリメントで呼ばれます。 その結果、Thread1でのネイティブAPI呼び出しの完了までネイティブリソースの破棄を遅延するような動きとなります。

参照カウントの操作はどこで呼ばれてるの?

SafeHandleをP/Invokeメソッドに渡した際にDangerousAddRefとDangerousReleaseが自動的に呼ばれます。

  • DllImportの場合はランタイムの生成するILに挿入
  • LibraryImportの場合SourceGeneratorの生成結果時点で挿入

適当なLibraryImportを書いて生成結果を見れば確認できますが…dotnet内の(多分)手書きのP/Invoke部分でも同様の対応が確認できます。 例えば次の例はDllImportにSafeHandleではなく生のファイルディスクリプタを渡しているので、try~finallyの形でDangerousAddRefとDangerousReleaseの呼び出しが必要です。 github.com

注意点

  • 「ネイティブハンドルの破棄」と「その他のネイティブ関数呼び出し」の間はスレッドセーフが保たれていますが、 「その他のネイティブ関数呼び出し」同士では普通に同時に呼べてしまいそうです。 そのようなユースケースではネイティブ実装時点でスレッドセーフが必要かと思います。

  • シングルスレッド前提ならSafeHandle必要ないんじゃない…?残念…ファイナライザがGCスレッドから呼ばれます。 特にAppDomainの強制アンロードもある環境が怖いです(Unityとか)。そういう経緯があって破棄処理だけやたら気をかけてるんじゃないかな…

使わないケース

ここからは私見強めですが、以下ではSafeHandleを使わない選択肢がありうると考えてます。

  • ネイティブがカンペキなスレッドセーフを担保している
  • 使っても意味が薄い

使っても意味が薄いケースというのは例えば… ネイティブに次の両者がある場合

  • hoge_destroy(handle)
  • hoge_destroyAll()

所有権の法則が乱れる!destroyAllを無視して済めばそれでいいですが、「全破棄」と「別の副作用」を同時に持つ無視できないAPIがあると詰んでしまいます。 こういうケースではネイティブレイヤー含めてお手製のメモリ管理の枠組みを用意すると思うので…SafeHandle相当の仕事を自前でやるしか無いと考えてます。

ヒープ確保しないで済まないか…?

2つの理由から、ネイティブハンドルを値型で包むのは難しいと考えてます。

  1. ファイナライザを持てない
  2. コピーされることで参照カウントが壊れる(イミュータブルに作る必要がある)

Unity.Collections.NativeArrayなんかは値コピーするとIsCreatedが壊れてます
ネイティブがカンペキなスレッドセーフとハンドルのステータス取得を提供していればイミュータブルに作れそうです。 ただし、ドメインアンロードに伴うリークの可能性は残ります。

InstancedDanmaku: Unity向け弾幕実装

モリヤリズムで使ったUnity向け弾幕実装がここにあります。

github.com

以前も似たようなものを作っていてブログでも紹介してましたが …ゲームを作る中でいくつか改善点を入れて再構成したのが上記リポジトリです。

これでどれだけ多彩な弾幕が作れるかは…ゲームを買ってご確認ください!(宣伝)

store.steampowered.com

つかいかた

弾源の配置

GameObjectにBulletSourceコンポーネントを付けてBulletModelを指定します。

BulletModelは弾の種類を定義するアセットで、見た目や当たり判定の定義を含みます。 パッケージ内にいくつかアセットが入ってますが、独自に作っても良いです。

  • Assets/Create/FriendSea/BulletModel

弾幕の設定

当たり判定のマスクなどを変更したい場合はProjectSettings/InstancedDanmakuのセクションで設定アセットを作って指定することになります。 InputSystemやURPと同様のUnity仕草ですね。

弾幕のアップデートタイミングをここで指定できます。 ↓が活きてる。 friendsea.hateblo.jp

弾が当たるもの

次の2点がついたGameObjectに衝突したときに…

  • Collider
  • InstancedDanmaku.IBulletColliderを実装したBehaviour

InstancedDanmaku.IBulletCollider.Collideが呼ばれます。

FlexibleValue

「改善点を入れて再構成」って書いてますが、焼き直しポイントその1がFlexibleValueです。 github.com

これは時間とともに変化する実数値を一般化し、インスペクタから指定できるようにしたものです。 AnimcationCurveで良くない…?と言われれば実際そういう場面もありますが、それじゃ足りない部分があります。

そこで、いくつかのモディファイアーの定義を追加したりしなかったりしてパラメータの時間変化を制御できるようにしました。

特にAddRandomとAddDeltaは多用しました。弾幕においてランダムは言わずもがなですが、わざわざカーブを書かずともその傾きだけを指定したいケースが非常に多いです。 また、傾きをカーブとして描きたい場合もありました。

今回の弾幕システムでは弾源で設定する諸々のパラメータをFlexibleValueとしたことで、コードを増やさずに弾幕を定義できました。 Prefab等にしておくとエディタ実行中の編集が快適。

IBulletBehaviour

焼き直しポイントその2
以前の実装だとBulletModelの一部として弾の挙動を定義する抽象を持っていました。 ただし、これだと同じ見た目の弾に別の動きをさせたい場合に、別のアセットを作る必要が出てしまい…面倒でした。

今回は弾源の方で弾の挙動を定義するようにしています。 こんな感じのクラスがあると…

public class SetVelocity : IBulletBehaviour
{
    public void UpdateBullet(ref Bullet bullet)
    {
        //update a bullet
    }
}

弾源での設定からBehaviourとして選べる。 実際には、複数のBulletBehaviourを抱えるBulletBehaviourを使って合体させて制御することも多かったです。

これもコード増やさずに色々な弾幕を表現するのに役立ちました。

描画

GPUインスタンシングを利用し、BulletModel毎に1ドローコールとしています。 (同じ種類の弾数が1024に達した場合は2以上に増えます) https://github.com/FriendSea/InstancedDanmaku/blob/main/InstancedDanmaku/Runtime/Scripts/Core/Danmaku.cs#L169

Shaderについては、以前は真心こめてGPUインスタンシングに対応したものを書いていましたが… 今はShaderGraphで作ると勝手に対応してくれます。めっちゃ楽。

ただしキモい点として、めちゃくちゃShaderLab書いてる扱いされてしまいます…

衝突判定

SpherecastCommandを使ってworkerスレッドに投げています。 弾幕だとどうしても弾の移動後に即座に衝突結果が欲しいので、Jobの発行と同時にCompleteを待っています… これでも愚直にSphereCastやCollider増やすよりだいぶマシです。

おしまい

「モリヤリズム」のインタラクティブサウンド

前回記事でも紹介した「モリヤリズム」についてです。

friendsea.hateblo.jp store.steampowered.com

曲や効果音の作成は素人レベルだし…得意な友人がいるわけでもないのになんで音ゲー作ったんでしょう…? それはそれとして、サウンド自体じゃなくて「鳴らし方」の話をします。

ボスの体力と曲の進行

前回記事でもちょっと触れましたが、STGとリズムゲームを融合させたいと思った結果「曲の進行」と「敵の体力」が紐づいていて欲しかったわけです。 モリヤリズムでは、できてたりできてなかったりします。

ボスの体力に応じて譜面(弾幕パターン)が進行する

つまり、通常のSTGと同じです。殆どのステージがこれです。 当然、これはカンペキな対応ではなくて、モタモタしていると曲ばかり進んでいってしまいます。 ただし、拍子やbpmが変化しない限りゲームとしては破綻しません。

一応尺は調整していて、上手にプレイして想定したタイム前後でクリアしてくれれば、サビが流れて終わり際に決着がつきます。

「フランドール」

拍子が途中で変わっているような演出ですが… これも曲のBPMや拍子は変化していません。 22拍子の弾幕パターンを繰り返しています。

ボスの体力に応じて曲が進行する

八坂神奈子のステージだけはいわゆる「横の遷移」をしています。

「インタラクティブミュージック」という、ゲームと音楽の関係性。連載開始。|じーくどらむす

弾幕は体力に応じて3段階あるのですが、それとともに曲も変化して拍子が変わるという具合です。 2段階目は三拍子でも四拍子でも破綻しないパターンにして両方やらせる。 3段階目は四拍子のサビに進行するとともに「マウンテンオブフェイス」を展開します。

ここで、2段階目での拍子チェンジは少し気を遣う必要があります。
(普通、音ゲーはノーツを展開しながら横の遷移とかしない)
ここでの弾幕パターンは8小節で1ループ、横遷移のポイントは2小節毎にやってきます。 つまり、拍子チェンジ時の弾幕パターンは4通りあり…全て破綻していないことを確認すればOKです。

余談

横遷移に対応した曲を作るのはある程度音楽性とのトレードオフがあると考えていますが、原曲のあるアレンジともなると難易度高そうですよね。 偶然にも「神さびた古戦場 ~ Suwa Foughten Field」という曲、非常に横の遷移しやすい。
東方の曲はそもそもコード進行が単純なものが多いですが、その中でもコード感の薄いパートが多い曲です。

効果音のクオンタイズ

よくあるやつです。今回のゲームでは効果音全体の仕組みとして、鳴らす場面ごとに8分や16分などのクオンタイズ幅を選べるようにしています。 あと、同時発音数の制御とかも一緒に入れてます。(クオンタイズの結果同じタイミングに予約される効果音が複数あった場合に1つだけ鳴ります)

特に、弾が出る音は全部クオンタイズ対象にしてます。弾の発射間隔はSTGとしての難易度で調整するので、BPMに合わせて調整とかやってられません。 そこで、音だけをクオンタイズして曲に合わせています。弾数と効果音の数がズレてるけど…まあええじゃろ…

ナイフが12本出てる。射出音は7音

実装はサンプル数とかは見ておらず、フレーム単位でクオンタイズされます。 ゲームロジックは120fpsで動くようにしているので8msのゆらぎがあるのですが…まあ聞けるって感じ…

発音レイテンシ

音ゲーでは発音にラグがあると遊びづらくなってしまうのですが… Unityの標準オーディオでの発音だと結構なラグを感じます。 今回はCRI ADXを利用していますが、発音までのレイテンシはUnityオーディオの半分くらいになってくれました。

本当はもっと速くなってほしいんですが…簡単な方法はなかなかないようです… (アーケード音ゲーやリズム天国など、ハードウェアが固定されてるゲームはレイテンシが短いことが多い印象です)

おしまい

こういうサウンド演出、気づかれないくらいでちょうどいいという風潮があります。 ので、わざわざ言いふらす場を用意しました。お付き合いありがとうございます。