Unity のセットアップ
Unity で FlowTask を動かす仕組みと、その設定を説明します。既定の World が PlayerLoop でどう動くか、時間の進み方、Play Mode の終了やドメインリロードでどうなるか、未処理の例外がコンソールにどう出るかが分かります。
パッケージの導入(Packages/manifest.json の書き方、任意のパッケージ)は インストール にあります。このページは導入の後の話です。
最初のフロー
Section titled “最初のフロー”導入すると、起動時に既定の FlowWorld が作られ、PlayerLoop で毎フレーム動きます。フローは FlowTaskUnity.World.Run で始めます。
using Katout.FlowTask;using Katout.FlowTask.Unity;using UnityEngine;
public sealed class Boot : MonoBehaviour{ void Start() => FlowTaskUnity.World.Run(Game());
static async FlowTask Game() { await FlowTask.WaitForSeconds(1.0); // DefaultClock advances like Time.deltaTime // ... }}World は RuntimeInitializeLoadType.SubsystemRegistration で作られるので、どの Awake よりも前からあります。World がないとき(Edit Mode、Shutdown の後、再生中の再コンパイルの後)に FlowTaskUnity.World を読むと、原因を書いた InvalidOperationException になります。あるかどうかは FlowTaskUnity.IsInstalled で調べます。このページのほかの例も、同じ 3 つの using(Katout.FlowTask、Katout.FlowTask.Unity、UnityEngine)を前提にします(Math や ArgumentOutOfRangeException を使う例は using System; も)。
GameObject が無効になったらフローを止めたいときは、World.Run ではなく gameObject.RunWhileActive で始めます(GameObject の寿命)。
パッケージとアセンブリ
Section titled “パッケージとアセンブリ”対応するのは Unity 2023.1 以降です。動作を確かめているのは、Unity 6(6000.3)の Editor(EditMode、PlayMode)、Windows の Mono と IL2CPP のプレイヤー、Android の IL2CPP のビルド(端末での実行はしていない)です。2023.1 と 2023.2 そのものでは確かめていません。
パッケージは次のアセンブリ(asmdef)に分かれています。コードを asmdef に置いているなら、使うアセンブリを参照に足します。
| asmdef | 参照 | コンパイルされる条件 |
|---|---|---|
FlowTask |
なし(noEngineReferences) |
常に |
FlowTask.Unity |
FlowTask | 常に。PlayerLoop 統合、ブリッジ、GameObject の寿命 |
FlowTask.Unity.UI |
FlowTask、FlowTask.Unity、UnityEngine.UI | com.unity.ugui があるとき(FLOWTASK_UGUI) |
FlowTask.Unity.Editor |
FlowTask、FlowTask.Unity | エディタだけ。Scope Tree ウィンドウ |
FlowTask.Testing |
FlowTask | エディタとテストプレイヤーだけ(UNITY_INCLUDE_TESTS)。autoReferenced: false |
FlowTask.Testing.NUnit |
FlowTask、FlowTask.Testing、nunit.framework.dll |
同上 |
FlowTask.UniTask |
FlowTask、UniTask | com.cysharp.unitask 2.0.0 以上があるとき(FLOWTASK_UNITASK) |
FlowTask.Unity.UIとFlowTask.UniTaskは、依存するパッケージがなければ黙ってコンパイルされません(エラーにならない)。- UniTask を
.unitypackageでAssets/Plugins/UniTaskに入れた場合は、パッケージとして見えないのでFLOWTASK_UNITASKが定義されず、FlowUniTaskを使うコードがCS0246になります。Player Settings > Other Settings > Scripting Define Symbols にFLOWTASK_UNITASKをプラットフォームごとに足してください。 - 2 つのテスト用アセンブリは、manifest の
testablesに書いたパッケージでしかコンパイルされません(インストール)。
C# 10 と csc.rsp
Section titled “C# 10 と csc.rsp”パッケージのソースは C# 10 で書かれています。Unity の既定は C# 9 なので、各 asmdef の隣に -langversion:10.0 の 1 行だけの csc.rsp があり、そのアセンブリだけの言語バージョンを上げています。あなたのコードの言語バージョンは既定のまま変わりません。
IDE によっては、パッケージのソースの C# 10 の構文が誤りとして表示されます。Unity が IDE 用に生成する csproj に csc.rsp の言語バージョンを反映するのは、Rider のパッケージと、2.0.24 以降の Visual Studio のパッケージです。反映しない IDE で誤りが表示されても、Unity のコンパイルには影響しません。
.meta ファイル
Section titled “.meta ファイル”git から入れたパッケージは読み取り専用なので、パッケージのすべてのファイルとフォルダの .meta がリポジトリに入っています。パッケージを Packages/ に埋め込んで手を入れるときは、ファイルの名前や場所を変えるなら .meta も一緒に動かしてください(GUID がアセットの同一性です)。
パッケージのフォルダには .NET 用の csproj もありますが、Unity では単なるアセットとして取り込まれ、コンパイルには関係しません。
Tick と Flush
Section titled “Tick と Flush”既定の World は、1 フレームに 1 回、Update フェーズの先頭(Update.ScriptRunBehaviourUpdate の直前)で Tick します。加えて、Update フェーズの末尾と PreLateUpdate フェーズの末尾で FlowWorld.Flush() を呼びます。Flush は時間を進めずに、その時点までに予約された再開(Emit、FlowProperty.Set、UnityEvent、完了したブリッジ)を処理します。
EarlyUpdate inputFixedUpdate physicsUpdate [FlowTask Tick] -> MonoBehaviour.Update, uGUI clicks, coroutines -> [Flush]PreLateUpdate MonoBehaviour.LateUpdate, animation events -> [Flush]PostLateUpdate renderingこの順序から、次のことが言えます。
- Tick は入力と物理の後なので、フローはそのフレームの状態を見て動けます。フローが変えた状態は、同じフレームの
MonoBehaviour.Updateから見えます。 MonoBehaviour.Update(uGUI のクリックを含む)と、Update フェーズで走るコルーチンと Awaitable の続き(yield return null、NextFrameAsyncなど)で起きた Emit は、Update の末尾の Flush で、描画の前の同じフレームのうちに受け手を再開します。LateUpdateとアニメーションイベントで起きたものは、PreLateUpdate の末尾の Flush が処理します。- FixedUpdate で起きたもの(
WaitForFixedUpdateとFixedUpdateAsyncの続きを含む)は、そのフレームの Tick で処理します。PreLateUpdate より後で起きたもの(WaitForEndOfFrameとEndOfFrameAsyncの続きなど)は、次のフレームの Tick で処理します。早めたいときは、Flush ポイントを足します(下の「設定」)。 - 空の Flush はフィールドを数個読むだけなので、Flush ポイントを増やしても費用はほとんどかかりません。
RunWhileActive で結んだ GameObject を無効にしたときは、この表とは別に、その場で Flush します(GameObject の寿命)。
各システムは FlowTaskUnity.FlowTaskTick などのマーカー型の名前で Profiler に出ます。
Emit が受け手を同期では再開しないことなど、Tick の中の順序の規則は 実行モデル にあります。
既定の World は Time.unscaledDeltaTime(Time.maximumDeltaTime で切り詰めたもの)で Tick し、DefaultClock がそのフレームの Time.deltaTime と同じだけ進むように、World.DefaultClock.TimeScale を毎フレーム決めます。
var world = FlowTaskUnity.World;await FlowTask.WaitForSeconds(2.0); // DefaultClock: follows Time.timeScale, stops at 0await FlowTask.WaitForSeconds(2.0, world.UnscaledClock); // keeps running while Time.timeScale is 0DefaultClockは、起動直後のフレームも含めて、毎フレームTime.deltaTimeと同じだけ進みます。Time.timeScale = 0で止まります。UnscaledClockはTime.timeScale = 0の間も進みます。TimeScale は変えられず、Pause もできません。- 長いフレーム(シーンの読み込み、ブレークポイント)は、Unity と同じく
Time.maximumDeltaTimeで切り詰められます。1 回の Tick で期限を過ぎた時間待ちは、期限の順ではなく、待ち始めた順に完了します(Race なら引数の順)。 WaitForSecondsは、dt を double で足した時刻が目標に届いた最初の Tick で終わります。60 fps で 0.1 秒を待つと、7 回目の Tick で終わります(1/60 を 6 回足しても 0.1 にわずかに届かない)。フレーム数で待つならFlowTask.DelayFramesを使います。
Clock、Pause、TimeScale の一般的な使い方は 時間と Clock にあります。
独自のスローモーションとポーズ
Section titled “独自のスローモーションとポーズ”DefaultClock.TimeScale は統合が毎フレーム上書きするので、自分では変えません。ゲームのスローモーションやポーズは、DefaultClock の子の Clock で行います。
var world = FlowTaskUnity.World;var game = world.CreateClock("Game"); // a child of DefaultClock: follows Time.timeScalevar ui = world.CreateClock("UI", world.UnscaledClock); // keeps running while Game is paused and at Time.timeScale 0game.TimeScale = 0.5; // slow motion on top of Time.timeScale-
ダイアログやメニューは UI の Clock で動かします(
Flow.WithClock(ui, Menu()))。ゲームの Clock をPause()するメニューがその Clock の上で動いていると、自分の再開も止まります(PausedOwnClockの警告が出ます)。 -
敵ごとのスローは、その場かぎりの Clock を
Flow.CreateClockで作ります。作ったスコープが終わると削除されます。async FlowTask SlowedAttack(){var slow = Flow.CreateClock("slow", Flow.CurrentClock); // removed when this method's scope endsslow.TimeScale = 0.5;await Flow.WithClock(slow, Attack());}AI のループの中で直接
Flow.CreateClockを呼ぶと、Clock がループのスコープ(敵の寿命)の終わりまで積み上がるので、上のようにメソッドに分けます。World が終わるまで使う Clock はFlowWorld.CreateClockで作ります。 -
Time.timeScaleと子の Clock の倍率の積が有限でなくなるときは、DefaultClockへの倍率の設定がArgumentOutOfRangeExceptionで拒否されます。統合はその例外をログに出し、そのフレームは前の倍率のまま Tick します。
Clock の Pause が止めるのは、その Clock で動くフローだけです。Animator や物理も一緒に止めるには、Pause の間だけ Time.timeScale を 0 にします(サンプル の PauseLink)。
詳しく:DefaultClock.TimeScale が Time.timeScale と違うフレーム
Section titled “詳しく:DefaultClock.TimeScale が Time.timeScale と違うフレーム”DefaultClock.TimeScale はふつう Time.timeScale と同じ値です。ただし Time.deltaTime が「非スケールの時間 × timeScale」にならないフレームでは、DefaultClock が Time.deltaTime だけ進むように、その比が入ります。次のフレームです。
- 起動直後のフレーム。Unity が仮の
Time.deltaTimeを報告します。 - Tick より前(FixedUpdate、EarlyUpdate、PreUpdate の Input System のコールバック、UniTask の
PlayerLoopTiming.Update以前の続き)にTime.timeScaleを変えたフレーム。新しい値は次のフレームから効きます。 Time.captureDeltaTimeを設定している間(一定のフレームレートで録画するとき)。DefaultClockは録画の時間で進み、UnscaledClockとその子の Clock は実時間(Time.unscaledDeltaTime)で進みます。録画とそろえたい待ちは、DefaultClockかその子の Clock で行います。
設定は FlowTaskSettings で、コードからもアセットからも変えられます。
FlowTaskUnity.Configure(new FlowTaskSettings{ FlushPoints = FlowFlushPoints.Default | FlowFlushPoints.AfterFixedUpdate, OnUnhandledException = info => MyCrashReporter.Report(info.Exception, info.ScopePath), // replaces the default logging});| 設定 | 意味 | 既定 |
|---|---|---|
AutoTick |
Update の先頭で Tick するか。false なら自分で Tick する(Flush ポイントは動く) |
true |
FlushPoints |
Flush する場所。FlowFlushPoints のフラグ:AfterEarlyUpdate、AfterFixedUpdate、AfterUpdate、AfterLateUpdate、EndOfFrame、Default、All、None |
Default(AfterUpdate | AfterLateUpdate) |
AutoInstall |
起動時に World を作り、PlayerLoop に組み込むか。アセットからだけ読まれる | true |
OnUnhandledException |
未処理の例外などの報告の処理を差し替える。コードからだけ | 下の「未処理の例外と警告」 |
Configureはいつでも呼べます。OnUnhandledExceptionはすぐに、Tick と Flush ポイントの変更は次のフレームから効きます。FlowTaskUnity.Configureで入れ直しても、UniTask など他のライブラリが PlayerLoop に入れたシステムは残ります。- アセットで設定するときは、
Assets > Create > FlowTask > SettingsでFlowTaskSettingsAssetを作り、FlowTaskSettingsという名前でResourcesフォルダに置きます。最初のシーンのロードの前に適用されます。 - 起動時の順:
SubsystemRegistrationで既定の設定の World を作り(FLOWTASK_DISABLE_AUTO_INSTALLを定義したときは作らない)、BeforeSceneLoadでアセットを適用します。それより前に(AfterAssembliesLoadedやBeforeSplashScreenのRuntimeInitializeOnLoadMethodから)コードがInitialize、Configure、Shutdownを呼んでいたら、アセットは適用しません。コードの設定(OnUnhandledExceptionを含む)が勝ち、コードが組み込んだ World はアセットのAutoInstallがfalseでも残ります。FLOWTASK_DISABLE_AUTO_INSTALLを定義したときは、アセットを読みません。 - 今の設定は
FlowTaskUnity.Settingsで読めます。返るのは写しで、書き換えてもConfigureに渡すまで効きません。
自動の起動を止める
Section titled “自動の起動を止める”アセットの AutoInstall を false にするか、スクリプティングシンボル FLOWTASK_DISABLE_AUTO_INSTALL を定義すると、自動では World を作りません。自分で FlowTaskUnity.Initialize(settings) を呼んで始めます。自分で作った World を既定の World にするなら Initialize(settings, myWorld) で渡します。渡した World は Shutdown で Dispose されません。渡した World にすでに OnUnhandledException があれば、それを残します(FlowTaskSettings.OnUnhandledException と既定のログは効きません)。なければ統合のハンドラを入れ、Shutdown で外します。
自分で Tick する
Section titled “自分で Tick する”AutoTick = false にしたときは、既定と同じ手順を自分で書きます。
void Update(){ var world = FlowTaskUnity.World; FlowLifetime.PollWatched(); // completes WaitForDestroy of objects that never became active if (world.DefaultClock.TimeScale != Time.timeScale) world.DefaultClock.TimeScale = Time.timeScale; world.Tick(Math.Min(Time.unscaledDeltaTime, Time.maximumDeltaTime));}Time.deltaTime を渡すと、UnscaledClock まで Time.timeScale に従ってしまいます。非スケールの時間を渡し、倍率は DefaultClock.TimeScale で与えてください。
終了とドメインリロード
Section titled “終了とドメインリロード”Play Mode の終了(シーンのオブジェクトが破棄される前)とプレイヤーの終了(Application.quitting)で、FlowTaskUnity.Shutdown() が呼ばれます。PlayerLoop からシステムを外し、World を Dispose するので、すべてのフローが巻き戻ります。ただし Dispose は Tick を回さないので、finally の中の await は最初の await までしか走りません(下の「終了時のセーブ」)。
Enter Play Mode Options でドメインリロードを無効にしても、起動のフックは Play Mode に入るたびに走り、前回の World を片付けてから新しい World を作ります。
Signal などは World ごとに作る
Section titled “Signal などは World ごとに作る”Signal<T>、FlowProperty<T>、Once<T> は、最初に使った World に結び付きます。その World が Dispose された後に別の World から使うと、FlowMisuseException になります。
// Wrong: with domain reload disabled, the second Play Mode session throwsstatic readonly Signal<int> s_damaged = new("Damaged");static のフィールドに置くと、ドメインリロードを無効にしたときの 2 回目の再生で例外になります。World ができた後(Start など)に作り、フローには引数で渡してください。サンプルの GameClocks と BackKeyRouter がこの形です(サンプル、シグナル)。
再生中の再コンパイル
Section titled “再生中の再コンパイル”Preferences > General > Script Changes While Playing が「Recompile And Continue Playing」だと、再生中にスクリプトを変えたとき、Unity は再生を続けたままドメインをリロードします。FlowTask はこの状態から自動では復旧しません(理由は 設計の背景 の「エンジンのオブジェクトの寿命」)。
- 既定の World は消えます。
RunWhileActiveで始めたフローは、Unity がリロードの前に呼ぶOnDisableで巻き戻りますが、ほかのフローは巻き戻らずに消えます(finallyは走らない)。 - 再生を止めるか
FlowTaskUnity.Initialize()を呼ぶまで、FlowTask は止まったままです。リロードの後のFlowTaskUnity.WorldとRunWhileActiveはNo FlowTask World: ...で始まるInvalidOperationExceptionを投げます。エディタはリロードの直前に警告を出し、この例外の文面と Scope Tree ウィンドウも、原因と設定の名前を示します。 Initialize()で動かし直しても、消えたフローは戻りません。AwakeとStartも呼ばれ直さないので、そこで始めるフローも始まりません。
この設定は「Recompile After Finished Playing」か「Stop Playing and Recompile」にしてください。
終了時のセーブ
Section titled “終了時のセーブ”終了の Shutdown で finally の await は最初の await で切られます。Flow.NonCancelable の await も切られ、CleanupCutAtDispose の警告が出ます。終了の前にセーブを最後まで終わらせるには、Application.wantsToQuit で終了をいったん取り消し、セーブが終わってから Application.Quit() を呼び直します。
// on an object that lives for the whole session (DontDestroyOnLoad)public sealed class SaveOnQuit : MonoBehaviour{ public IGameSave Save { get; set; } // your save system: FlowTask Write(), void WriteNow() FlowHandle _saving; bool _quitRequested; bool _saved;
void OnEnable() => Application.wantsToQuit += WantsToQuit; void OnDisable() => Application.wantsToQuit -= WantsToQuit;
bool WantsToQuit() { if (_saved || Application.isEditor) return true; // the Editor ignores Application.Quit() if (!_quitRequested) { _quitRequested = true; // Runs to the end, or gives up after 5 s; on the UnscaledClock, so also while Time.timeScale is 0. var world = FlowTaskUnity.World; _saving = world.Run(FlowTask.Race(Save.Write(), FlowTask.WaitForSeconds(5.0)).WithoutResult(), world.UnscaledClock); } return false; // not yet: the World keeps running while it saves }
void Update() { if (!_quitRequested || _saved || !_saving.IsCompleted) return; _saved = true; Application.Quit(); // outside flow code: quitting disposes the World }
// Android and iOS: the OS can end an app in the background without wantsToQuit. void OnApplicationPause(bool paused) { if (paused) Save.WriteNow(); // the PlayerLoop stops in the background: save synchronously }}World の Dispose と後始末の関係は 失敗の扱い にあります。
未処理の例外と警告
Section titled “未処理の例外と警告”既定の World では、ルートに届いた未処理の例外は Debug.LogException(new FlowTaskUnhandledException(info)) でコンソールに出ます。元の例外とスタックトレースの後に、スコープのパスが出ます。
InvalidOperationException: ... (stack trace)Rethrow as FlowTaskUnhandledException: [Unhandled] at 'Game > InGame > Battle': InvalidOperationException: ...-
FlowExceptionKind.Undelivered(受け手がもういなかった例外。キャンセル済みのフロー、決着済みの Race、2 個目以降の例外など)だけはDebug.LogWarningで出します。普通のプレイでも起き、エラーにすると本物のエラーが埋もれるためです。 -
FlowTaskSettings.OnUnhandledExceptionを設定すると、上の出力はすべて置き換わります。種類で分けるならFlowExceptionInfo.Kindを見ます。OnUnhandledException = info =>{if (info.Kind == FlowExceptionKind.Undelivered) Debug.LogWarning($"[FlowTask] {info.Kind} at '{info.ScopePath}': {info.Exception}");else MyCrashReporter.Report(info.Exception, info.ScopePath);}, -
World の警告(
FlushLimit、PausedOwnClock、LongCleanup、CleanupCutAtDispose)は、種類ごとに World で 1 回、Debug.LogWarning("[FlowTask] ...")で出ます。止める設定はありません。
報告の種類と読み方は 失敗の扱い と デバッグと診断 にあります。
自分で作る World(物理フレームなど)
Section titled “自分で作る World(物理フレームなど)”物理フレームを 1 回待つだけなら、World を分けずに await Awaitable.FixedUpdateAsync().AsFlow(); と書けます。続きは FixedUpdate の中ではなく、その後の Tick か Flush で走ります。
物理フレームごとに動かし続けたいフローは、自分の FixedUpdate から Tick する World で動かします。駆動源ごとに World を分けます。PlayerLoop の統合はこの World に何もしないので、未処理の例外と警告の出し先、Tick、Dispose を自分で書きます。
[DefaultExecutionOrder(-10000)] // before the FixedUpdate of the other scriptspublic sealed class PhysicsFlows : MonoBehaviour{ public FlowWorld World { get; private set; }
void Awake() { World = new FlowWorld("UnityPhysics"); // Without OnUnhandledException, the outermost Tick, Flush, Run or Dispose throws the reports (FlowUnhandledException). World.OnUnhandledException = info => { if (info.Kind == FlowExceptionKind.Undelivered) Debug.LogWarning($"[FlowTask] {info.Kind} at '{info.ScopePath}': {info.Exception}"); else Debug.LogException(new FlowTaskUnhandledException(info)); }; World.OnWarning += w => Debug.LogWarning("[FlowTask] " + w); FlowWorldRegistry.Register(World); // shown in the Scope Tree window }
void FixedUpdate() { try { if (World.DefaultClock.TimeScale != Time.timeScale) World.DefaultClock.TimeScale = Time.timeScale; } catch (ArgumentOutOfRangeException ex) { Debug.LogException(ex); // rejected: keep the previous scale and tick anyway } World.Tick(Time.fixedUnscaledDeltaTime); }
void OnDestroy() { FlowWorldRegistry.Unregister(World); World.Dispose(); // unwinds the physics flows }}OnUnhandledExceptionを設定しないと、報告はいちばん外側のTick、Flush、Run、DisposeからFlowUnhandledExceptionで投げられます。- この World では、
NextFrame()とDelayFrames(n)は物理フレームで数えます。 Time.timeScale = 0の間はFixedUpdateが呼ばれないので、この World のUnscaledClockも止まります。ポーズ中にも進む必要のある待ちは、既定の World で動かします。- World の間にスコープの親子はありません。別の World で始めたフローをこちらの寿命に結び付けるときは、ハンドルを持って
Flow.AddCleanup(handle.Cancel)と書きます。 - World の間で
Signal<T>をやり取りするときは、送る側が終わるときにClose()し、受ける側はNextOrClosed()で待ちます。相手の World が Dispose されても、待っているNext()は終わらないためです。Signal は長く生きる World で先に使います(上の「Signal などは World ごとに作る」)。 RunWhileActiveに第 2 引数でこの World の Clock(World.DefaultClockなど)を渡すと、この World のフローも GameObject の寿命に結び付けられます(GameObject の寿命)。フローの中のWhileActiveは、await したフローの World で動きます。
Tick の手順とスレッドの規則は フローと World と スレッド にあります。
コアのパッケージにはアナライザ(Analyzers/FlowTask.Analyzers.dll)が入っており、FlowTask を参照するすべてのアセンブリのコンパイルで働きます。既定でエラーのルール(FLOW001、FLOW002、FLOW005)は、Unity のコンパイルも止めます。ルールの一覧と、Unity で重大度を変える方法は アナライザ にあります。
IL2CPP とビルド
Section titled “IL2CPP とビルド”- コアは実行時のコード生成(
Reflection.Emit)、リフレクション、ジェネリック仮想メソッドを使いません。IL2CPP と Managed Stripping でそのまま動きます(非同期メソッドビルダも AOT で具象化されます)。 - 割り当てがないのは、最適化したビルド(Release のプレイヤー、エディタの Code Optimization が Release)です。Development Build はスクリプトを最適化なしでコンパイルし、async のステートマシンがクラスになるので、
async FlowTaskメソッドは呼び出しごとに割り当てます。Development Build で割り当てを測るなら、asmdef の隣のcsc.rspに-optimize+を書きます。 GC.GetAllocatedBytesForCurrentThread()は Unity では使えません(Mono では常に 0、IL2CPP では未実装)。割り当ては Profiler の GC Alloc 列か、ProfilerRecorder("GC Allocated In Frame")で確かめます。
割り当ての費用については 性能とメモリ にあります。