🕊

BevyでUIアニメーションハンズオン

2023/02/12に公開

まえがき

当記事では、bevyというゲームエンジンにおけるUIアニメーションについて、ハンズオン形式で実装方法を論じます。
Rustの基礎文法等については解説しませんのでご注意ください。並びに環境へCargo等がインストール済みであることがハンズオンを進める前提です。
また、当ハンズオンではWindowsでの開発をメインに進めていきます。
bevyでゲームを作ったとして、動作環境はWindowsであることが多いと思われるのが理由です。

要約

https://github.com/NULL-header/ui_anim_handson

bevy_uiについて

まずもってbevyとは、Rustで開発され、Rustで用いることのできるゲームエンジンです。完全にコードベースかつGUIエディタが提供されない状態での利用が可能、ECS構造の導入など独自の特徴を豊富に持ちます。
そのbevyにおいてはUIにしても、その他のゲームエンジンと比較してかなり独特です。
Flexboxと呼ばれる機能によって複数の要素間で動的に配置を制御するため、オブジェクトとしての位置情報は完全に無視されます。
そのためUIをアニメーションさせるのは少々手間なのですが、今回はそれでもやりたいという状況になった人のためのハンズオンを、以下に記載します。

UIアニメーション制作ハンズオン

これから作るUIアニメーションは、テキストが画面の左から右へ移動するものとなります。
やけにシンプルな、と思われる方もいらっしゃるでしょうが、Bevyはまだバージョンが1にも満たないので、当然ながら落とし穴が多々あります。
かつ日本語でそれらを体系立って解説している記事はほとんどありませんでしたので、まずはということで、小さ目の目標です。
なお、全てのステップでコードを用意していますので、詰まった場合はそちらも参考にしてください。
注意事項として、Bevyのバージョンは0.9.1です。

0.bevyのインストール

以下をCargo.tomlに記載してください。

[dependencies]
bevy = "0.9"

[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

上はbevyのインストールで、下二つは開発の際にコンパイルをある程度高速化しつつ、動作もキビキビさせるための設定です。
詳しくは、以下を参考にしてください。

https://bevyengine.org/learn/book/getting-started/setup/#compile-with-performance-optimizations

なお、bevy/dynamicは使いません。
上記リンク先のページにもありますが、

Note that right now, this doesn't work on Windows.

とのように、Windowsでは動作しないためです。

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/install-bevy-0

1.TextのSpawn

ではまず、アニメーションさせるUIを画面に表示させます。
そのためのセットアップを進めて行きましょう。
Bevyではアプリケーションをビルダーパターンで構成します。そのため基本構成はこうなります。

main.rs
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

ここで、Bevyにはsystemと呼ばれるイベントのようなものがあり、これに対するhookのようにしてコールバックを設定していきます。
以下はアプリケーションの起動時に一度だけコールされるsystemを登録するコードです。

main.rs
fn setup() {}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .run();
}

またコールバックにはBevyのAPIを操作できるCommands型を受け取ることができ、これを操作することによってオブジェクトのスポーンを果たします。
まずはUIを表示させるためのカメラをスポーンさせましょう。

main.rs
fn setup(mut commands: Commands) {
    commands.spawn(Camera3dBundle::default());
}

ここで、スポーンするオブジェクトの元となるものをBevyではBundleと呼びます。
上記コードではカメラBundleをスポーンメソッドに渡すことでオブジェクトをワールド空間に出現させています。
Unityを扱ったことのある方には、PrefabをBundleと呼ぶと記載したほうが分かりやすいかもしれません。
そして次に、テキストを表示させるためにテキストBundleもスポーンさせます。

main.rs
fn setup(mut commands: Commands) {
    commands.spawn(Camera3dBundle::default());
    commands.spawn(TextBundle::from_section("Text", TextStyle::default()));
}

これで画面上に文字を出力する意味のコードを記述できました。できましたが、この状態でcargo runコマンドを実行してもグレーの画面がただ出るだけでしょう。
始めからBevyの厳しいところが表出しますが、Bevyにはフォールバックフォントが存在しません。
そのためテキストを表示したい場合、フォントのファイルを用意する必要があります。何かしら、好きなフォントをダウンロードしておいてください。
もし迷う場合は、以下のRobotoを使うとよいでしょう。

https://fonts.google.com/specimen/Roboto

その後、otfないしttfファイルをプロジェクトディレクトリのassets配下に配置しましょう。
Bevyにはアセットローダーが標準で組み込まれていますが、ローダーが読み込みに行く先のデフォルトがassetsディレクトリなのです。このためassetsという名前は変えないほうがよいでしょう。
次はフォントのロードです。先ほどのsetup関数でアセットローダーAPIを操作するAssetLoader型の変数を引数として受け取り、これを操作します。

main.rs
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera3dBundle::default());

    let font: Handle<Font> = asset_server.load("Roboto-Regular.ttf");
    commands.spawn(TextBundle::from_section(
        "Text",
        TextStyle { font, ..default() },
    ));
}

これで、画面左上に文字が表示されていた場合はフォントのロードに成功しています。
失敗した場合は、asset_serverにロードさせるフォントのパスが合っているかをよく確認してください。
表示できた場合は、見やすい位置に微調整しましょう。
縦方向のセンタリングを適応し、左側にマージンを10%取ります。またフォントサイズも見やすい大きさに調整します。フォントサイズに関しては各自で値を調整してください。
縦方向のセンタリングに関しては、親要素からAlignItemsを適応します。

main.rs
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera3dBundle::default());

    let font: Handle<Font> = asset_server.load("Roboto-Regular.ttf");
    commands
        .spawn(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.), Val::Percent(100.)),
                align_items: AlignItems::Center,
                ..default()
            },
            ..default()
        })
        .with_children(|parent| {
            parent.spawn(
                TextBundle::from_section(
                    "Text",
                    TextStyle {
                        font,
                        font_size: 50.,
                        ..default()
                    },
                )
                .with_style(Style {
                    position: UiRect {
                        left: Val::Percent(10.),
                        ..default()
                    },
                    ..default()
                }),
            );
        });
}

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/spawn-text-1

2.MarkerComponent

テキストの表示はできましたが、現状の問題として、アニメーション部分のsystemからUI表示しているテキストを特定できません。
Bevyにはこのためのパターンとしてマーカーコンポーネントがあります。

You can use empty structs to help you identify specific entities. These are known as "marker components". Useful with query filters.
出典:https://bevy-cheatbook.github.io/programming/ec.html

コンポーネントはBundleの最小単位だと考えるのがよいです。
その上で上述の通り、何も定義されていない特定のComponentをテキストとセットでBundleとしてスポーンさせ、アニメーションsystemからは特定のComponentでワールド空間に対してクエリを掛けることで、意図した通りの動作を期待することができます。
それでは実際にやってみましょう。

まずマーカーコンポーネントを定義します。

main.rs
#[derive(Component)]
struct Marker;

そしてこれをTextBundleに付与すればオーケーです。

main.rs
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // ~~~ 省略 ~~~
        .with_children(|parent| {
            parent
                .spawn(
                    TextBundle::from_section(
                        "Text",
                        TextStyle {
                            font,
                            font_size: 50.,
                            ..default()
                        },
                    )
                    .with_style(Style {
                        position: UiRect {
                            left: Val::Percent(10.),
                            ..default()
                        },
                        ..default()
                    }),
                )
                .insert(Marker);
        });
}

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/marker-component-2

3.UIにおけるTransform

テキストの表示及び、マーカーの設定も終わりましたので、アニメーションをさせるシステムを導入します。
ここで、Bevyにおける位置情報の管理はTransform.translationにて行われます。

main.rs
fn animate(mut query: Query<&mut Transform, With<Marker>>) {
    let mut transform = query.single_mut();
    let x = transform.translation.x;
    let x = x + 1.;
    transform.translation.x = x;
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_system(animate)
        .run();
}

しかし、このコードは動きません。コンパイルは通りますが、実行時にアニメーションが行われないのです。
ここで記事冒頭のbevy_uiに関する記述を思い出しますと、BevyはUIにおいて位置を動的に制御するFlexBoxという仕組みを導入している、とのことでした。
通常のオブジェクトであればTranslationのミューテーションで問題ないのですが、ことUIにおいてtranslationは無視されます。
ところで、UIにはStyleという修飾関係を保持するBundleが付与されています。
故に、もしかすると感づかれているかもしれませんが、Styleのミューテーション、つまりAbsoluteにおけるPositionや、Marginの大きさによってUIは移動アニメーションをさせる必要があります。
通常のオブジェクトのアニメーション手法ではUIを操作できないことに注意しましょう。当然ですが、transform.translate_aroundのような直線移動でない特殊な動作をさせる関数もUIに用いることはできません。どうしても単純な直線移動以外の位置変更を行いたい場合は、自力で実装する必要があります。
なお、translation以外の、たとえばrotationなどはTransformで制御する必要があります。回転するアニメーションを実装する場合はTransformをミューテーションする必要がありますし、回転しながら動く場合はこの二つを別々に制御しなければいけません。
またアニメーションを自力で実装するときに気を付けたいのは、他ゲームエンジンによくあるAnchorと呼ばれる原点を変更するシステムが組み込みでは存在しないことです。
当ハンズオンではそこまで深入りしませんので問題ありませんが、複雑なアニメーションを実装したい場合は空間と行列に関する数学の知識を身につけておくのが無難です。
ちなみに、こういったUIに関する問題を解決するライブラリとして、bevy_eguiが存在します。ここでは紹介に留めますので、気になる方は各自調べてください。

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/ui-transform-3

4.Styleの取得と変更

それでは注意事項が長々と続きましたので、サクッと動かしてしまいましょう。
Transformをクエリしたときの要領で、UIの修飾を司るStyleオブジェクトをワールド空間からフィルタして取得します。

main.rs
fn animate(mut query: Query<&mut Style, With<Marker>>) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = match position.left {
        Val::Percent(percent) => Val::Percent(percent + 0.5),
        _ => {
            return;
        }
    };
    position.left = left;
}

初期値はVal::Percentでセットしていますので、その他の型に関してはアーリーリターンして構いません。
なお当ハンズオンでは行いませんが、パーセントで設定しているものに対してピクセルで移動を制御しようとする場合、パーセンテージを画面幅からピクセルに直す解決部分も含めて自前で実装する必要があります。

これで動くことは確認できました。しかしこの状態だとフレームレートによって動作が変わってしまいます。
そこでよく採られる解決方策としては、座標の計算部にフレーム間の時間を係数として追加するパターンが挙げられます。
今回もそれを採用します。Bevyにはデフォルトでフレーム間の時間、deltaと言ったりしますが、これを係数に導入します。今回の場合は、1秒で画面幅のうち25%移動するようにしましょう。

main.rs
fn animate(mut query: Query<&mut Style, With<Marker>>, time: Res<Time>) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = match position.left {
        Val::Percent(percent) => Val::Percent(percent + 25. * time.delta_seconds()),
        _ => {
            return;
        }
    };
    position.left = left;
}

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/style-mutation-4

5.停止機構

ここまでで動かす部分はできましたので、次は止める仕組みを実装しましょう。
今回は画面幅50%まで進んだとき停止することにします。さっくりアーリーリターンで実装できますが、少し汚いコードになってしまいました。

main.rs
fn animate(mut query: Query<&mut Style, With<Marker>>, time: Res<Time>) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = match position.left {
        Val::Percent(percent) => {
            if percent > 50. {
                return;
            }
            Val::Percent(percent + 25. * time.delta_seconds())
        }
        _ => {
            return;
        }
    };
    position.left = left;
}

現状のコードには、問題が大まかに三つあります。
一つ目は、値の計算と関数の実行条件の制御部(return文やif文など)が混在していること。多少紛れ込んでしまうものの仕方がない場合もありますが、なるべくは取り除きたいものです。
二つ目は、この関数そのものはアニメーションが終了したとしてもフレーム毎にコールされ続けることです。すぐアーリーリターンしますので負荷は少ないですが、ずっとシステムに残り続けるのは開発側として気分が良くないですし、塵も積もれば多大なオーバーヘッドとなる可能性もあります。
三つ目に、アニメーションの発火を制御できないことです。アプリケーションの起動と同時にアニメーションが発火されてしまっていて、遅延や待ち合わせの一切ができていない状況です。これでは演出としてまともに使えたものではありません。

二つ目、三つ目は、実のところBevyの機能であるStateによって解決することができます。ですが、停止機構からかなり外れますので次の項目で行うことにします。
では当項の最後に一つ目の問題を解決しましょう。一つ目はBevyというよりRustらしい手法で解決できますので、そちらを用いることにします。
一つ目の問題を解決する前提として、まず値を計算する部分を外部に抽出します。

main.rs
fn next_percent(current: Val, delta: f32) -> Val {
    todo!();
}

fn animate(mut query: Query<&mut Style, With<Marker>>, time: Res<Time>) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = next_percent(position.left, time.delta_seconds());
    position.left = left;
}

ここで、next_percentには値の計算に失敗して欲しいパターンが以下のようにあります。

  • パーセンテージの値が50%を超えているとき
  • 値がパーセンテージでないとき

ですがパニックは起こして欲しくありません。Rustにおいてほとんどの状況ではパニック即アプリケーションのクラッシュですので。
ですのでResult型を返すようにしまして、animateでResultから制御できるようにしましょう。
ここで気を付けたいのはRustにおけるエラーハンドリングです。完全にバニラの状態で行う場合は、手間を非常にかけるか、dynキーワードを使ってヒープにアロケートする必要があります。
そういった問題を回避するためのクレートも、勿論存在します。有名なものとしてはanyhowかthiserrorが挙げられるでしょう。
今回はthiserrorを利用します。これは完全に筆者の好みですので、もしanyhowを使いたい場合はそちらでも構いません。

Cargo.toml
[dependencies]
bevy = "0.9"
thiserror = "1.0"

Cargo.tomlへの追記後、もしRust-Analyzer等で自動補完を利用している場合は、一度cargo runを叩くなどしてthiserrorをビルドしてしまいましょう。
そしてuseで名前空間にthiserrorのマクロを取り込み、

main.rs
use thiserror::Error;

その後エラー型を定義します。

main.rs
#[derive(Error, Debug)]
enum NextValError {
    #[error("The animation has finished.")]
    Finished,
    #[error("The current value is not supported. Maybe init is wrong.")]
    NotPercent,
}

このエラー型を利用して、next_percent関数を記述します。

main.rs
fn next_percent(current: Val, delta: f32) -> Result<Val, NextValError> {
    let current = match current {
        Val::Percent(p) => p,
        _ => return Err(NextValError::NotPercent),
    };
    if current >= 50. {
        return Err(NextValError::Finished);
    }
    Ok(Val::Percent(current + 25. * delta))
}

そしてnext_percentの返り値がResult型になったので、これに合うようanimate関数を書き換えます。

main.rs
fn animate(mut query: Query<&mut Style, With<Marker>>, time: Res<Time>) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = next_percent(position.left, time.delta_seconds());
    let left = match left {
        Ok(l) => l,
        _ => {
            return;
        }
    };
    position.left = left;
}

これで停止機構のリファクタは完了です。

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/stop-function-5

6.Stateによるリファクタ

BevyにはStateと呼ばれる機能があります。ある程度端折って説明すると、enumをミュータブルな値としてワールド空間に差し込むものになります。
たったそれだけ、と思われるかもしれませんが、実際にはBevyのsystemは並行で実行されますので、ミュータブルな値を差し込むだけでも同期部分のラッパーが必要ですし、これを自前で用意してデッドロックなんて起こしたときは堪りません。そういった並行性の面倒な部分もBevy側がやってくれるからシンプルに見える、とだけ頭の片隅にでも置いておくとよいでしょう。
それではState用のenumを定義します。

main.rs
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
enum AnimateState {
    NotStarted,
    Animating,
    Finished,
}

そしてStateの初期値を設定します。startup_systemでは行わず、アプリケーションへ直に設定することに留意してください。

main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_state(AnimateState::Animating)
        .add_system(animate)
        .run();
}

これで、もし開始直後にアニメーションを発火したくない場合はAnimateStateの初期値をAnimateState::NotStartedに変えるだけでよいようになりました。
更に、Bevyの機能の一つであるSystemSetを用いましょう。
SystemSetには複数の機能がありますが、今回は特定のStateの特定のイベント時のみ動作するようsystemを登録する機能を使います。

main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_state(AnimateState::Animating)
        .add_system_set(SystemSet::on_update(AnimateState::Animating).with_system(animate))
        .run();
}

そしてアニメーションが終了時、つまり水平位置が50%を超えた時にStateを変更します。

main.rs
fn animate(
    mut query: Query<&mut Style, With<Marker>>,
    time: Res<Time>,
    mut state: ResMut<State<AnimateState>>,
) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = next_percent(position.left, time.delta_seconds());
    let left = match left {
        Ok(l) => l,
        _ => {
            state.set(AnimateState::Finished).unwrap();
            return;
        }
    };
    position.left = left;
}

これで、先項で述べた問題点は全て解決することができました。リファクタによってコードの見通しはよくなり、コールバックは呼ばれ続けることがなくなり、アニメーションは任意に発火できるようになりました。

ここまでのコードは以下になります。

https://github.com/NULL-header/ui_anim_handson/tree/refactor-state-6

7.Plugin化

現状のままでは使いまわしが出来ず、また新たにコードが増えた場合にコードの見通しが悪くなることでしょう。
こんなときのためのBevyの機能がPluginです。このタイミングでファイル分割もしてしまいましょう。

slide_plugin.rs
use bevy::prelude::*;
use thiserror::Error;

#[derive(Component)]
pub struct Marker;

#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum AnimateState {
    NotStarted,
    Animating,
    Finished,
}

#[derive(Error, Debug)]
enum NextValError {
    #[error("The animation has finished.")]
    Finished,
    #[error("The current value is not supported. Maybe init is wrong.")]
    NotPercent,
}

fn next_percent(current: Val, delta: f32) -> Result<Val, NextValError> {
    let current = match current {
        Val::Percent(p) => p,
        _ => return Err(NextValError::NotPercent),
    };
    if current >= 50. {
        return Err(NextValError::Finished);
    }
    Ok(Val::Percent(current + 25. * delta))
}

fn animate(
    mut query: Query<&mut Style, With<Marker>>,
    time: Res<Time>,
    mut state: ResMut<State<AnimateState>>,
) {
    let mut style = query.single_mut();
    let position = &mut style.position;
    let left = next_percent(position.left, time.delta_seconds());
    let left = match left {
        Ok(l) => l,
        _ => {
            state.set(AnimateState::Finished).unwrap();
            return;
        }
    };
    position.left = left;
}

pub struct SlidePlugin;

impl Plugin for SlidePlugin {
    fn build(&self, app: &mut App) {
        app.add_state(AnimateState::NotStarted)
            .add_system_set(SystemSet::on_update(AnimateState::Animating).with_system(animate));
    }
}

コード後半のように、Pluginトレイトをプラグイン用に作る構造体へ実装することで、プラグインを作ることができます。
このSlidePluginは、ワールド空間に一つだけマーカーが付いたテキストがあった場合、これをスライドさせる、といった機能を持つプラグインです。
逆説的にこのプラグインは、スライドさせたいテキストにマーカーを付け、Stateによってアニメーションを発火させることで動作させることができます。
これをmain.rsにて適応して終了です。

main.rs
mod slide_plugin;
use bevy::prelude::*;

fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut state: ResMut<State<slide_plugin::AnimateState>>,
) {
    // ~~~ 省略 ~~~
        .with_children(|parent| {
            parent
                .spawn(
                    TextBundle::from_section(
                        "Text",
                        TextStyle {
                            font,
                            font_size: 50.,
                            ..default()
                        },
                    )
                    .with_style(Style {
                        position: UiRect {
                            left: Val::Percent(10.),
                            ..default()
                        },
                        ..default()
                    }),
                )
                .insert(slide_plugin::Marker);
        });
    state
        .overwrite_set(slide_plugin::AnimateState::Animating)
        .unwrap();
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugin(slide_plugin::SlidePlugin)
        .add_startup_system(setup)
        .run();
}

なお、setup関数後半にある、state.overwrite_setは変更をキューイングするものです。
Stateは並行的に扱われるため、同時に複数操作があるとエラーを起こします。そしてアプリケーションの立ち上げ時は初期値のセットがあるので、startup_systemでも変更を行うと競合してしまうのです。
これを避けるための手法がキューイングでした。

実装完了

以上でハンズオンは終わりです。お疲れ様でした。
Bevyに置ける勘所である、実装をPluginへ分割する部分、SystemSetとStateで実行順序を制御する部分を拾いつつ、UIアニメーションについて嵌りやすい部分も解説しましたので、これでBevyでゲームを作ることも難しくない、はずです。たぶん。
ところで、宿題も用意してありますので、元気が残っている方はそちらも行うとよいでしょう。やらなくてもよいですが、どうせしっかりゲームを作ろうとすればやる羽目にはなると思います。

宿題

next_percent関数において、二通りの失敗条件がある、とのことでしたが、Result型は実は必要ありません。
もしピクセルで設定されていた場合パーセンテージに変換する処理を用意し、StateにSetup等と盛り込んで操作すれば、片方の失敗条件は存在しなくなります。なおこの処理をstartup_systemで行うと、まだマーカー付きのコンポーネントがスポーンされていない状況が発生し得るので推奨できません。
また、セット前に50%を超えているかどうか確認していますが、計算後に50%を超えていたら50%で座標をミューテーションしつつ、StateをFinishedに変更すれば、これも失敗条件が存在しなくなります。run_criteriaを用いることでも可能ですが、その場合正確には50%でない値で終了するのがネックです。
上記で述べたことをヒントに、next_percentの返り値の型からResultを除いてください。
答えはmainブランチ最新版に載せておきます。

あとがき

実はこの記事はおまけで書きました。
BevyにおけるUIの結合テストをするときにかなり沼ったので、その記事を書くつもりでしたが、前提となるリポジトリを用意するとかなりの文量になってしまうことが目に見えていたので、そのリポジトリを作るところを外部に切り出したのがこの記事です。
UIの結合テストのほうも投稿予定なので、そちらもよろしくお願いします。

GitHubで編集を提案

Discussion