TL;DR
- Omochaya Story は Unity の標準コルーチン(
IEnumerator)を完全置き換えするゼロアロケーション非同期タスクエンジンです。- Ver 1.3.0 でタスクシステムに完全統合された ゼロアロケーションの Tween 機能 が追加されました。
- 専用マネージャーを介さず、1回の仮想関数呼び出しで動作する軽量構造体設計。
async/awaitで直感的に演出シーケンスが組めます。
1. はじめに
Omochaya Story は、Unity標準のコルーチン(IEnumerator)を完全に置き換えるために設計された、ゲームロジックおよびシーケンス制御特化型のゼロアロケーション非同期タスクエンジンです。RPGのイベントやUIシーケンスなど、メインスレッドで完結するフレームベースのゲームロジックにおいて、最高のパフォーマンスを発揮します。
ライブラリ開発の背景や、「なぜ UniTask ではなく独自タスクエンジンを作ったのか」という基本思想・使い方については、初回リリース時の以下の記事をご覧ください。
最新のバージョン1.3.0では、このタスクシステムに完全統合された「ゼロアロケーションのTween(アニメーション)機能」が追加されました。本記事では Omochaya Story ならではの設計思想と実用的なアピールポイントをご紹介します。
2. 構造体ステートマシンによるゼロアロケーション
Storyの最大の特徴は、独自のステートマシンプールと世代管理付きIDを採用することで、実行中のGCアロケートを完全に排除している点です(キャンセル例外時などを除く)。
Tween機能もこの恩恵を直接受けています。裏側で専用のTweenマネージャーを回すのではなく、Tween自体がタスク(Story.Task)として生成されており、専用のインスタンスを生成・管理するコストは存在しません 。Story本体と同様にTween機能自体もジェネリック構造体パターンで作成されており、Tween機能自体はデリゲートや仮想関数呼び出しは一切行いません。「 transform.localScale.y を1秒間で0から1まで減速変化させる」といった動きを非同期メソッドのステートマシンを処理する ただ1回の仮想関数呼び出しで実現しています。
また、ライフサイクル管理もタスクエンジンに依存しているため、紐づけたオーナー(デフォルトで対象コンポーネントが紐づきます)の破棄に連動した安全なキャンセルや、オーナーの非アクティブ化に連動した一時停止もそのまま機能します。
3. async/await に最適化された直感的なAPI設計
StoryのTweenはコンポーネントのプロパティに対応した拡張メソッドが起点となり、目標値を To()(絶対値)または By()(相対値)で指定し、動きを Interval()(時間)または Speed()(速度)で指定することでタスクを生成します。
生成したタスクは通常の Story タスクと同様に Start() や await はもちろん、 MoveNext() でのステップ実行やコンビネータでの実行も可能です。
// 指定した絶対座標へ指定した時間(秒)をかけて移動
await this.transform.TweenLocalPosition().To(new Vector3(100f, 50f, 0f)).Interval(1.0f);
// 現在位置から見た移動先へ指定した速度で移動
await this.transform.TweenLocalPosition().By(new Vector3(0f, 50f, 0f)).Speed(200f);
成分ごとの独立制御と並行実行
さらに、引数名を利用して特定の成分のみを変化させることが可能です。Task.With()コンビネータを利用すれば、他のタスクを道連れキャンセルすることなく、安全かつ直感的に並行アニメーションを記述できます。
// X軸は加速移動、Y軸は減速移動。(Z軸はノータッチなのでタスク外で操作が可能)
var task1 = this.transform.TweenLocalPosition().To(x: 100f).Interval(1.0f, Story.Ease.QuadAcc);
var task2 = this.transform.TweenLocalPosition().To(y: 50f).Interval(1.0f, Story.Ease.QuadDec);
// 独立したタスクを束ねて待機
await task1.With(task2);
4. 必要十分な対応コンポーネントとイージング
Omochaya Story 1.3 では主要なUnityコンポーネントに対してTweenの起点となる拡張メソッドを用意しています。
また Interval() および Speed() の第2引数に指定することで変化方法を指定できるイージングを用意しています。イージング同士の結合や合成も可能です。
操作可能なコンポーネントとプロパティ一覧
-
Transform
- 座標:
TweenLocalPosition(),TweenPosition() - 回転:
TweenLocalRotation(),TweenRotation(),TweenLocalEulerAngles(),TweenEulerAngles() - スケール:
TweenLocalScale()
- 座標:
-
RectTransform
- UI座標・サイズ:
TweenAnchoredPosition(),TweenSizeDelta(),TweenPivot()
- UI座標・サイズ:
-
CanvasGroup
- 透明度:
TweenAlpha()
- 透明度:
-
TMP_Text (TextMeshPro)
- テキスト制御:
TweenAlpha(),TweenFontSize(),TweenMaxVisibleCharacters()
- テキスト制御:
-
Image / Slider
- UIパラメータ:
TweenFillAmount()(Image),TweenValue()(Slider)
- UIパラメータ:
-
Graphic / SpriteRenderer
- 色:
TweenColor()
- 色:
-
Camera
- カメラ制御:
TweenFieldOfView(),TweenOrthographicSize(),TweenBackgroundColor()
- カメラ制御:
-
AudioSource
- 音声制御:
TweenVolume(),TweenPitch()
- 音声制御:
主要なイージング (Easing) 一覧
Story.Ease.~ と記述してください。なお当ライブラリでは、一般的な In / Out 表記ではなく Acc / Dec 表記を採用しています。
-
加速 (Acc)
-
SineAcc,QuadAcc,CubicAcc,QuartAcc,SqrtAcc,ExpoAcc,CircAcc,BackAcc,ElasticAcc,BounceAcc
-
-
減速 (Dec)
-
SineDec,QuadDec,CubicDec,QuartDec,SqrtDec,ExpoDec,CircDec,BackDec,ElasticDec,BounceDec
-
-
その他
-
None: デフォルト値。線形補間(リニア) -
PowAcc(pow),PowDec(pow): 任意の累乗値を使用 -
Curve(curve):AnimationCurveを直接使用
-
-
イージングの結合 (Stitch)
-
ease.Stitch(ease)で2つのイージングを組み合わせて InOut や OutIn カーブを作成できます。 - 加速と減速、あるいは減速と加速の結合が可能で、変化の種類に制限はなく、カクつくことなく滑らかに繋がります。
-
Story.Ease.QuadAcc.Stitch(Story.Ease.SineDec)のように、前後で異なる形状のカーブを繋ぎ合わせる表現も可能で、滑らかに繋がります。
-
-
イージングの合成
- メソッドチェーンでイージング同士を合成(重ね合わせる)することが可能です。また
Reverse()をつなげると逆再生することも可能です。(Story.Ease.None.SineAcc().QuadAcc().Reverse()等) - 構造体のジェネリクスで表現していますので、IL2CPPの「フル・ジェネリック共有(Full Generic Sharing)」へのフォールバックによるパフォーマンス低下に注意してください。
- メソッドチェーンでイージング同士を合成(重ね合わせる)することが可能です。また
5. フレーム遅延を完全に排除した「チェイン再生」
フレーム管理のタスクシステムで DOTween 等をラップして Tween を直列に繋ぐ場合、完了検知から次の実行までに最大1フレームの遅延が発生し、微小なカクつきが生じることがあります。
Storyでは ref start を引数に渡すことでこれを解決します。Tween生成時に所要時間が自動計算・加算されるため、フレーム単位の実行遅延に影響されることなく、前のタスクの終了時刻を次のタスクの開始時刻として正確に指定できます。
this.transform.localPosition = Vector3.zero;
// 現在の基準時刻を取得
var start = Story.GetStart();
// 1つ目のアニメーション(生成時に start に所要時間が加算される)
await this.transform.TweenLocalPosition().To(x: 50f).Speed(100f, Story.Ease.SineAcc, ref start);
// 2つ目のアニメーション(task1の終了時刻が正確な開始時刻となるため滑らかにつながる)
await this.transform.TweenLocalPosition().By(x: 50f).Speed(100f, Story.Ease.SineDec, ref start);
6. 【実践編】構造体ベースならではの制約とハック
StoryのTweenは一度走り出すと、外部から進行度をシークしたり逆再生したりするAPIは存在しません。これは構造体ステートマシンというアーキテクチャ上の制約です。
しかし、タスクの生成コストがゼロであるため 「現在実行中のタスクを Stop() でキャンセルし、現在値から新しいTweenを即座に再生成して上書きする」 という割り込みアプローチが躊躇なく行えます。
また、専用のパス移動(Path)機能はありませんが、汎用メソッド Story.Tween() とデリゲートを利用することで、アロケーションなしで AnimationCurve や独自の計算に基づく高度な移動処理を組み込むことが可能です。
// 0.0 から 1.0 へ変化する汎用Tweenを利用し、自前でパス計算を行う
var start = Story.GetStart();
var seconds = 2f;
await Story.Tween(seconds, this, static (self, now) =>
{
self.transform.position = self.CalculatePathPosition(now);
}, ref start);
7. パフォーマンスとバイナリサイズのトレードオフ(コードブロートへの対策)
StoryのTweenは、高い実行速度を達成するためにC#の構造体ジェネリクスによる「コードブロート(バイナリサイズの増加)」を一定量受け入れるアーキテクチャとなっています。
この課題に対し、開発者がプロジェクトの要件に合わせて処理コストとバイナリサイズのバランスをチューニングできるよう、2つの Scripting Define Symbols を用意しています。これらは実のところ、展開される非同期メソッド内に「不要な分岐(switch文)を受け入れるかどうか」の選択肢として機能します。
-
STORY_EASE_COMPACT: イージング処理を共通化し、コンパイル時間とバイナリサイズを節約します。トレードオフとして実行速度が低下します。 -
STORY_MOVER_FAST: Tweenの実行タスクを細分化し、実行速度を向上させます。トレードオフとしてコンパイル時間とバイナリサイズが増加します。
これら「イージング」と「マッパー」の2軸のON/OFFにより、計4パターンの最適化戦略を選択可能です。「STORY_EASE_COMPACTなし + STORY_MOVER_FASTあり」の設定では「イージング」と「マッパー」の両方に switch 文を含まない最小サイズの非同期メソッドが生成されますが、それは個別に細分化された非同期メソッドが必要になるということでもあります。
この最小サイズのTween 60 種類 で Omochaya Story 1.3 で生成した APK サイズは実測で 150 KB ほど増加しました。極限のパフォーマンスのためにこういったバイナリサイズの増加を許容できるかどうかが判断基準になります。
構造体が展開された非同期タスクのイメージ
最も細分化される設定(STORY_EASE_COMPACTなし + STORY_MOVER_FASTあり)で以下のTweenを記述した場合を例にします。
this.transform.TweenLocalPosition().To(x: 100f, y: 100f).Interval(2f, Story.Ease.CubicDec);
このメソッドチェーンによって生成される非同期タスクは、内部的に以下のようなコードとして最適化・展開されることが期待できます。
// 型は特定できますが記述をシンプルにするためにジェネリクス表記のままにしています
static async Story.Task Task<TS, C, M, T, E>(Creator<TS, C, M, T> creator, E ease)
where TS : struct, Story.ITimeSource
where C : struct, Mover.ICarrier<T>
where M : struct, Mover.IMapper<T>
where E : struct, Story.IEase
{
// ...前(初回のみ処理される判定と初期化処理)略...
while (true)
{
updater.stepper.Proceed();
if (0f <= updater.stepper.seek)
{
if (updater.stepper.interval <= updater.stepper.seek)
{
var current = updater.carrier.self.localPosition;
updater.carrier.self.localPosition = new Vector3(updater.to.x, updater.to.y, current.z);
break;
}
{
var now = updater.stepper.seek / updater.stepper.interval;
// STORY_EASE_COMPACT が「有効」な場合、ここがイージング種別の switch 文になる
// case Story.EaseCompact.Type.QuadDec:
now = 1f - now;
now = now * now * now;
now = 1f - now;
var rt = 1f - now;
var current = updater.carrier.self.localPosition;
// STORY_MOVER_FAST が「無効」な場合、ここが操作対象別の switch 文になる
// case StoryVector3.Comb.XY_:
updater.carrier.self.localPosition = new Vector3(updater.to.x + updater.diff.x * rt, updater.to.y + updater.diff.y * rt, current.z);
}
}
await Story.Yield;
}
}
8. まとめ
Omochaya Story 1.3のTweenは、「コルーチンのメンタルモデルで扱える非同期タスクでありながらTween専用ライブラリに匹敵するパフォーマンスを発揮」 することを目指して作成しました。
UIアニメーションやバトルの演出など、高いパフォーマンスと堅牢なライフサイクル管理が求められる場面で、ぜひ導入を検討してみてください。
バグ報告やフィードバック、GitHubへのStarもお待ちしております!