1フレームが SteamVR に届くまで
1フレームを担う2つのスレッド
Section titled “1フレームを担う2つのスレッド”1フレームの描画は、2つのスレッドの仕事です。メインスレッドが、ウィジェットのビルドから Layer ツリーの複製までを行い、描画スレッドが、複製を GL で描いて SteamVR のオーバーレイに提出します。変更したい処理がどの層のどの段にあるかは、この流れで見つけます。
| スレッド | 名前 | 仕事 |
|---|---|---|
| メインスレッド | FloatSodaMainThread(Windows では STA) |
OpenVR のイベント、入力、ビルド、レイアウト、描画命令の記録(Layer ツリーの更新)、Layer の複製 |
| 描画スレッド | RenderThread |
GL のコンテキストを持ち、Layer ツリーをテクスチャに描いてオーバーレイに渡す |
ほかに I/O スレッド(IOTaskRunner)があります。利用者のコード向けに、処理を投入順に1件ずつ実行するスレッドで、フレームの流れには入りません。
メインスレッド 描画スレッド タスクの実行 OpenVR のイベント、アクション入力 BeginFrame 入力の処理 ビルド → レイアウト → 描画命令の記録 Layer の複製 ── PostRender ──────────────▶ PreUpdate: 投入されたタスクの実行 待機 (Present: Renderer.Render → OpenVR へ提出) Update: GLFW のイベント処理 待機層は、プロジェクトごとに分かれ、参照の向きは csproj が決めます。
FloatSoda.OVR: ほかの FloatSoda のプロジェクトを参照しないFloatSoda.Rendering: ほかの FloatSoda のプロジェクトを参照しないFloatSoda.Abstractions:FloatSoda.Renderingを参照するFloatSoda.Engine: Abstractions、OVR、Rendering を参照するFloatSoda(フレームワーク): Abstractions、Engine、Rendering を参照するFloatSoda.UI:FloatSodaを参照するFloatSoda.UI.Cream、FloatSoda.UI.FizzyPop:FloatSoda.UIを参照する
メインループと描画ループの周期は、どちらも FloatSodaOptions.TargetFrameRate(既定は 60)から作った FramePacer が決めます。
メインスレッド: ビルドから Layer の複製まで
Section titled “メインスレッド: ビルドから Layer の複製まで”メインループは、1周ごとに次の順で動きます。
- 保留中のタスクを実行する(
CreateWindowや、ホットリロードの再ビルドの予約) - OpenVR のシステムイベントをポーリングし、アクション入力を更新する
- ウィンドウごとの
WidgetBinding.BeginFrameを呼ぶ FramePacer.WaitForNextFrameで次の周期まで待つ
BeginFrame は、ポインターイベントの処理、登録済みのフレームコールバックの実行、DrawFrame の順に動きます。DrawFrame の中の順序は、次のとおりです。各段の詳細は フレームワーク層 にあります。
| 段 | 内容 |
|---|---|
| ビルド | BuildOwner.BuildScope が、dirty な Element を再構築し、RenderObject のプロパティを更新する |
| レイアウト | RenderPipeline.FlushLayout が、dirty なノードを Depth の昇順で再レイアウトする。続けて、ホバー状態を更新し、サイズが変わっていればリサイズを描画スレッドに投入する |
| 描画命令の記録 | RenderPipeline.FlushPaint が、dirty なノードを Depth の昇順で再描画し、描画命令を記録して Layer ツリーを更新する。最終画像を描くのは描画スレッド |
| 複製と投入 | RenderView.Layer を Clone() し、RenderPostTaskRunner.PostRender で描画スレッドに渡す |
すべてのフレームで、レイアウトと描画が走るわけではありません。ビルドは毎フレーム確かめますが、レイアウトからあとは、NeedsVisualUpdate が立っているフレームだけです。ウィンドウがまだ作られていないフレームも、レイアウトからあとは飛ばします。
描画スレッド: Layer を描いて SteamVR に渡す
Section titled “描画スレッド: Layer を描いて SteamVR に渡す”描画スレッドは PostTaskRunner のループで動き、1周ごとに、投入されたタスクの実行、GLFW.PollEvents、待機の順に進みます。PostRender が投入するタスクは、window.Present(layer) を呼びます。
OverlayWindow.Present は、Renderer.Render(layer) で Layer ツリーを GL テクスチャに描きます。Render は、GLView.Clear()、LayerRenderer.Render(Layout のあとに Paint)、GLView.Flush() の順に動きます。そのあと、GL テクスチャのハンドルを Texture_t に包み、Overlay.Texture.FromTexture_t で OpenVR に渡します。GL テクスチャが OpenVR に渡るのは、この呼び出しです。デスクトップウィンドウ(DesktopWindow)は、同じ Present の流れを、テクスチャを既定のフレームバッファに転送して、SwapBuffers で終えます。
ウィンドウの作成にも、2つのスレッドが関わります。FloatSodaApp.CreateWindow は、作成の処理をメインスレッドのタスクとして積みます。WidgetBinding.EnsureInitialized は、GLView と Renderer と、エンジン層のウィンドウの作成を、描画スレッドのタスクとして投入します。そのため、ウィンドウは描画スレッドで非同期に作られ、メインスレッドから見えるのは、作成が終わったフレームからです。
エンジン層の詳細は エンジン層、Layer の描画は 描画層、OpenVR の型は OVR 層 にあります。
なぜ Layer を複製して渡すのか
Section titled “なぜ Layer を複製して渡すのか”複製は、描画中の Layer を、次のフレームの変更から守るためにあります。メインスレッドは、再描画のたびに、同じ Layer のインスタンスの子を消して作り直します。描画スレッドが原本を読んでいる最中に、次のフレームの描画が始まると、読んでいる Layer が書き換わる可能性があります。これはコードの構造から読んだ推定で、複製を省いて実際に描画が崩れるかは再現していません。そこで、描画スレッドには、複製したツリーを渡します。AGENTS.md も、描画スレッドへ渡す前に複製して、データ競合を避けると説明しています。
複製が共有するのは、SKPicture のような描画リソースの参照です。ツリーの構造は共有しません。共有の範囲は 描画層 の「Clone は何を共有し、何を共有しないか」にあります。複製を省く変更は、メインスレッドの次のフレームと、描画スレッドの Present が、同じ Layer を同時に読み書きしないかを確かめます。
リサイズは同じ流れの別の枝
Section titled “リサイズは同じ流れの別の枝”ウィンドウの大きさは、既定ではウィジェットのレイアウト結果が決めます。DrawFrame は、レイアウトのあとに、サイズが前回投入した値と違うかを調べます。違えば、PostTask で window.Resize(newSize) を描画スレッドに投げます。この投入は、描画の投入より先なので、同じキューの順で、リサイズのあとに描画が走ります。
OverlayWindow.Resize は、GLView の描画先 Surface と GL テクスチャを作り直して、オーバーレイの幅(ピクセル幅 ÷ Dpm)を更新します。入力を受けるオーバーレイでは、マウスのスケールもピクセルサイズに合わせます。サイズを変える処理は、描画スレッドで走ります。
入力は逆向きに流れる
Section titled “入力は逆向きに流れる”ダッシュボードのオーバーレイでは、コントローラーのレーザーが、次の順でボタンに届きます。
- OpenVR のマウスイベントを、
OverlayWindow.PollInputEventsがポーリングする OverlayMouseRawPointerSourceが、RawPointerEventに変換して発火するPointerControllerが、キューに積む。Flushで取り出し、PointerEventに変換するWidgetBinding.HandlePointerEventが、ヒットテストを行う- ヒットパスの末尾で、ジェスチャ認識器にイベントが届く
1 の PollInputEvents も、3 の Flush も、メインスレッドで BeginFrame から呼ばれます。つまり、これらの段は、すべてメインスレッドで動きます。デスクトップウィンドウでは、GLFW のコールバックが描画スレッドで RawPointerEvent を発火しますが、キューに積むだけで、変換とそれ以降はメインスレッドの Flush が行います。
フレームワークの標準のポインター入力が接続されるのは、オーバーレイのうち、ダッシュボードだけです。ほかのオーバーレイでは、PointerSource が null です。コントローラーのレイで当たりを判定する ControllerPointerSystem は、どこからも呼ばれていません(追跡: #182)。
コントローラーのボタンを読むアクション入力は、別の流れです。メインループが、1周ごとに VRInputUpdater.Update で更新します。
ウィジェット側の入力は 入力が届くまでを追う、OpenVR 側は OVR 層 にあります。
各層の解説へ
Section titled “各層の解説へ”変更したい層の解説に進みます。
- フレームワーク層: Widget、Element、RenderObject と、差分更新
- 入力が届くまでを追う: ヒットテスト、ジェスチャ、ポインター
- 描画層: Layer ツリーと、
Cloneの共有範囲 - エンジン層: 描画スレッド、GL の描画先、ウィンドウ
- OVR 層: OpenVR の包み方と、つまずきやすい点
- UI 層: UI を3層に分けた理由
手を動かして確かめるときは、描画が届くまで(4段) と、各層の実験(フレームワーク層、描画層、OVR 層)を使います。