メインコンテンツまでスキップ

Unity PlayerLoop統合

ValkarnTasksはasyncメソッドを再開するためにスレッドやバックグラウンドスケジューラーを使用しません。すべてはUnityのメインスレッド上で、UnityのネイティブのPlayerLoopシステムによって実行されます。この仕組みを理解することで、ユースケースに適したタイミングを選択し、await後にコードが正確いつ再開されるかを把握できます。

Unity PlayerLoopとは

UnityのPlayerLoopはすべてのフレームを駆動する内部エンジンループです。単一のUpdate()呼び出しではありません — フレームごとに定義された順序で実行されるフェーズの階層的なシーケンスです:

Initialization
EarlyUpdate
FixedUpdate (物理がステップする場合は繰り返し)
PreUpdate
Update
PreLateUpdate
PostLateUpdate
TimeUpdate

これらの各トップレベルフェーズには、Unity(およびサードパーティパッケージ)が特定のポイントでロジックを実行するために挿入するサブシステムが含まれています。例えば、MonoBehaviour.Update()Updateフェーズ内で実行されます。MonoBehaviour.LateUpdate()PreLateUpdate内で実行されます。

ValkarnTasksはこのループに直接フックするため、await ValkarnTask.Yield()はスレッドをパークしません — 選択されたフェーズの次のティックでUnityが呼び出すコールバックを登録して即座に戻ります。

16のPlayerLoopタイミング

ValkarnTasksは16のポイントで注入します:8つのUnity PlayerLoopフェーズそれぞれの開始終了に1つずつ。Lastバリアントは親フェーズのサブシステムリストの末尾に追加され、プレーンバリアントは先頭に追加されます。

列挙型整数親フェーズ親内の位置
Initialization0UnityEngine.PlayerLoop.Initialization最初
LastInitialization1UnityEngine.PlayerLoop.Initialization最後
EarlyUpdate2UnityEngine.PlayerLoop.EarlyUpdate最初
LastEarlyUpdate3UnityEngine.PlayerLoop.EarlyUpdate最後
FixedUpdate4UnityEngine.PlayerLoop.FixedUpdate最初
LastFixedUpdate5UnityEngine.PlayerLoop.FixedUpdate最後
PreUpdate6UnityEngine.PlayerLoop.PreUpdate最初
LastPreUpdate7UnityEngine.PlayerLoop.PreUpdate最後
Update8UnityEngine.PlayerLoop.Update最初
LastUpdate9UnityEngine.PlayerLoop.Update最後
PreLateUpdate10UnityEngine.PlayerLoop.PreLateUpdate最初
LastPreLateUpdate11UnityEngine.PlayerLoop.PreLateUpdate最後
PostLateUpdate12UnityEngine.PlayerLoop.PostLateUpdate最初
LastPostLateUpdate13UnityEngine.PlayerLoop.PostLateUpdate最後
TimeUpdate14UnityEngine.PlayerLoop.TimeUpdate最初
LastTimeUpdate15UnityEngine.PlayerLoop.TimeUpdate最後

Update(値8)はすべてのValkarnTasks操作 — Yield()Delay()WaitUntil()WaitWhile()NextFrame()DelayFrame() — のデフォルトです。

マーカー構造体とべき等な注入

UnityのPlayerLoopに注入するため、ValkarnTasksはタイミングごとに1つのPlayerLoopSystemを作成します。各システムはPlayerLoopHelper内で定義された固有のマーカー構造体によって識別されます:

struct ValkarnTaskInitialization     { }
struct ValkarnTaskLastInitialization { }
struct ValkarnTaskEarlyUpdate { }
struct ValkarnTaskLastEarlyUpdate { }
struct ValkarnTaskFixedUpdate { }
struct ValkarnTaskLastFixedUpdate { }
struct ValkarnTaskPreUpdate { }
struct ValkarnTaskLastPreUpdate { }
struct ValkarnTaskUpdate { }
struct ValkarnTaskLastUpdate { }
struct ValkarnTaskPreLateUpdate { }
struct ValkarnTaskLastPreLateUpdate { }
struct ValkarnTaskPostLateUpdate { }
struct ValkarnTaskLastPostLateUpdate { }
struct ValkarnTaskTimeUpdate { }
struct ValkarnTaskLastTimeUpdate { }

注入前に、PlayerLoopHelperValkarnTaskUpdateが現在のPlayerLoopツリーのどこかに既に存在するかを確認します。存在する場合、注入はスキップされます。これにより注入がべき等になります — Init()を複数回呼び出しても(エディターで発生する可能性がある)、重複するシステムが登録されることはありません。

注入は常にPlayerLoop.GetCurrentPlayerLoop()現在のPlayerLoopを読み取り、GetDefaultPlayerLoop()ではありません。これにより、他のパッケージが以前に設置したシステムが保持されます。

ContinuationQueue — ワンショットコールバック

await ValkarnTask.Yield()(または正確に1ティック間サスペンドするもの)をawaitすると、コンパイラーが生成したステートマシンはawaiterのOnCompletedを呼び出します。awaiterはPlayerLoopHelper.AddContinuation(timing, action, state)を呼び出し、コールバックをそのタイミングのContinuationQueueにエンキューします。

ContinuationQueueダブルバッファー設計を使用します:

  1. アクティブバッファーactionList):現在のドレイン前後にキューされたコンティニュエーションを保持します。
  2. 待機バッファーwaitingList):アクティブバッファーがドレインされている最中にエンキューされたコンティニュエーション(つまり、コンティニュエーション自体からの再入エンキュー)をキャッチします。
  3. クロススレッドTreiberスタックcrossThreadHead):バックグラウンドスレッドからポストされたコンティニュエーションは、ロックフリーのcompare-and-swapを通じてここに着地します。各ドレインの開始時に、スタック全体がアトミックにクレームされてアクティブバッファーに移されます。

各PlayerLoopティックで、ContinuationQueue.Run()はこのシーケンスを実行します:

1. crossThreadHeadをactionListにドレイン     (ロックフリーアトミックテイク、次にSpinLock下でコピー)
2. カウントをスナップショット、isDraining = trueに設定 (SpinLock下)
3. すべてのコールバックを実行 (ロック外、GCのためにrefをクリア)
4. actionList ↔ waitingListをスワップ (SpinLock下、isDraining = falseに設定)

約1〜2フレームのウォームアップ後、内部配列は最高水位マークで安定し、キューはゼロアロケーションで実行されます。

クロススレッドノードは、バックグラウンドスレッドのエンキューのたびにアロケーションするのを避けるため、有界のロックフリープール(最大1,024ノード)にプールされます。

PlayerLoopRunner — 繰り返しアイテム

一部の操作は完了するまでティックごとにチェックされる必要があります:Delay()DelayFrame()WaitUntil()WaitWhile()NextFrame()。これらはIPlayerLoopItemインターフェースを実装します:

internal interface IPlayerLoopItem
{
// 実行を継続する場合はtrueを返す;ランナーから削除する場合はfalseを返す。
bool MoveNext();
}

アイテムはPlayerLoopHelper.AddAction(timing, item)を通じてPlayerLoopRunnerに追加されます。各ティックで、PlayerLoopRunner.Run()はすべての登録済みアイテムを反復して各アイテムのMoveNext()を呼び出します。falseを返したアイテムはインプレースコンパクションによって削除されます — 配列は挿入順序を保持しながら一回のパスでコンパクトされます。Run()呼び出し中に追加されたアイテムは安全に追加され、次のティックで処理されます。

ティックN:
ContinuationQueue.Run() → すべてのワンショットawaiterを再開
PlayerLoopRunner.Run() → すべての繰り返しアイテムをティック(Delay、WaitUntil、...)

両方とも16のタイミングすべてに対して、エンジンが呼び出す順序で実行されます。

Updateタイミングはグローバルフレームカウントも追跡し、定期的にオブジェクトプールのトリミングをトリガーします(ValkarnTask.TrimCheckIntervalフレームごと)。

初期化 — RuntimeInitializeOnLoadMethod

ValkarnTasksはRuntimeInitializeLoadType.SubsystemRegistration(Unityが提供する最も早いフック、シーンオブジェクトのAwake()より前に起動)で初期化されます:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Init()
{
// 1. スレッド安全チェックのためにメインスレッドIDをキャプチャ
// 2. すべての16タイミングのContinuationQueueとPlayerLoopRunnerを作成
// 3. PlayerLoopに注入(べき等)
// 4. プレイモード状態変更コールバックを登録(エディターのみ)
}

メインスレッドIDはこの時点でキャプチャされ、すべてのプールとキューサブシステムと共有されます。これによりメインスレッドエンキュー(SpinLockパス)とクロススレッドエンキュー(Treiberスタックパス)を区別できます。

ドメインリロード処理(エディター)

Unity Editorでは、プレイモードへの出入りがドメインリロードをトリガーします。前のプレイセッションの静的状態が持続すると、古い参照が残ります。

ValkarnTasksはこれをInit()中に登録されたEditorApplication.playModeStateChangedコールバックで処理します。状態がExitingPlayModeまたはEnteredEditModeに遷移すると、以下のクリーンアップが実行されます:

// すべてのキューとランナーをリセット
for (int i = 0; i < 16; i++)
{
s_continuationQueues[i] = null;
s_playerLoopRunners[i] = null;
}

// 他のサブシステムをリセット
ValkarnTask.ResetStatics();
TimeProvider.ResetToDefault(); // UnityTimeProviderに戻す
PoolRegistry.Clear();
ContinuationQueue.ContinuationNode.ResetPool();

その後プレイモードに再入すると、RuntimeInitializeOnLoadMethodによってInit()が起動し、新しいキューとランナーがアロケーションされます。PlayerLoop注入チェック(HasValkarnTaskSystems)により、ドメインが完全にアンロードされていない場合にマーカーシステムが二度挿入されるのを防ぎます。

適切なタイミングの選択

ほとんどのコードはデフォルトのUpdateタイミングを使用すべきです。他を選ぶのは特定の理由がある場合のみです。

タイミング使用する場面
Initialization / LastInitialization非常に早いフレームセットアップ;ゲームコードではほとんど不要
EarlyUpdate / LastEarlyUpdate入力サンプリング;物理とUpdateより前に実行
FixedUpdate / LastFixedUpdate物理同期ロジック;MonoBehaviour.FixedUpdateのペースに合致
PreUpdate / LastPreUpdateUnityのメイン更新ディスパッチ前;カスタムスケジューラーのプリパスに有用
Update(デフォルト)標準ゲームロジック;MonoBehaviour.Updateに合致
LastUpdateすべてのMonoBehaviour.Update呼び出し後に実行するロジック
PreLateUpdate / LastPreLateUpdateMonoBehaviour.LateUpdateに合致;カメラとトランスフォームのフォロー
PostLateUpdate / LastPostLateUpdateレンダリングサブミット後;UIの最終化、スクリーンショットキャプチャ
TimeUpdate / LastTimeUpdate時間値の同期;非常にまれに必要
// 次のUpdateティックで再開(デフォルト)
await ValkarnTask.Yield();

// 次のFixedUpdateの開始時に再開(物理安全)
await ValkarnTask.Yield(PlayerLoopTiming.FixedUpdate);

// 2秒待機、非スケール時間で進み、LateUpdateでチェック
await ValkarnTask.Delay(
TimeSpan.FromSeconds(2),
DelayType.UnscaledDeltaTime,
PlayerLoopTiming.PreLateUpdate);

// 条件がtrueになるまで待機、すべてのLateUpdate呼び出し後にチェック
await ValkarnTask.WaitUntil(() => _ready, PlayerLoopTiming.LastPreLateUpdate);