AIR RACE X 2024のAR演出システムの裏側

# AIR RACE X 2024のAR演出システムの裏側

こんにちは！今年は「AIR RACE X 2024」にARコンテンツ制作のエンジニアとして携わりました。
本記事では、ARコンテンツ制作の効率化を目的として作ったUnityEditor拡張ツールについてご紹介します。

#  AIR RACE Xとは

AIR RACE Xは、最高時速400km、最大12Gの重力加速度の中、リモート形式で記録されたデータをもとにタイムを競う世界最高峰のモータースポーツです。

実際に飛んだ飛行データをもとにARでその軌跡を再現し、観客に臨場感のある映像を提供します。

以下の映像をみれば、どういうものなのかわかると思います！

https://x.com/airrace_x/status/1847205425097347430

飛行機のアニメーションや、ゲートを通過したときのタイム表示等を、ARで描画しています。

このARコンテンツの制作をSTYLYが担っています。
私はSTYLY社内のエンジニアとして、ARコンテンツ自動生成システムの設計・開発を担いました。


# Unity Editor拡張でSTYLYシーン生成を自動化

ARコンテンツ制作フローの効率化のため、以下のようなUnity Editor拡張ツールを作りました。

| ![Editor拡張](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/d82bed14-357e-e8b2-0411-019697ef0338.png) |
|---|
| Unity Editor拡張のGUI。この図はあくまで説明のためのものであり、実際の飛行データに基づくものではありません。 |

誰と誰が競争するのか？
それぞれの選手の飛行ログファイルはどれか？
といったことを順番に指定していき、最後の `Generate Heat Scene` ボタンをクリックすると、ARコンテンツを内包したプレハブが生成されるという仕組みです。
そのプレハブをSTYLYプラットフォームへアップロードすれば、STYLYシーンとして利用可能になる仕組みです。

社内ツールのためGUIデザインは簡素ですが、業務効率化には十分役立っています。


# モジュール分割

まず、機能ごとにプレハブを分割し、それを **モジュール** と名付けました。
本記事では一部を抜粋してご紹介します。

| モジュール名 | 役割 | 開発担当者 |
|---|---|---|
| `AirplaneModule` | 飛行機のモデルとそのアニメーション | Aさん |
| `GateModule` | ゲート通過演出 | Bさん |
| `PenaltyModule` | ペナルティ発生時の演出 | Aさん |
| `GoalModule` | ゴール演出 | Bさん |

上記のようにモジュール毎に担当プログラマーをアサインすることで、並行開発を実現しました。

これらのモジュール（プレハブ）は、手作業で作るのではなく、C#コードで自動生成するものです。

完成したモジュールは、以下の図のように、より上位のプレハブを構成する部品になります。

| ![HeatRoot](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/cbc0eeb1-36f8-c3b1-7632-2d45cc759ff7.png) |
|---|
| モジュールによってプレハブが組み立てられている様子 | 

最終的にSTYLYシーンと一対一で対応するプレハブまで、自動で組み立てられるという仕組みです。


# 飛行アニメーション生成の流れ

飛行機のアニメーションは、 `AirplaneModule` が担います。

AIR RACE Xでは、2機の飛行機が競争する `Heat` という形式を取っているため、以下の図のように `AirplaneModule` は、一つの試合につき2つ配置されます。

| ![AirplaneModule](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/a09a3dfa-e73f-5659-4d25-05010c0f8f24.png) |
|---|
| 選手2名分の AirplaneModule によってプレハブが構成されている様子 | 

まず、飛行機に取り付けられたセンサーから位置や姿勢の情報がファイル出力されます。
本記事では、このファイルを `ログファイル` と呼ぶことにします。
このログファイルをもとに、各種変換を行い、最終的にAnimationClipファイルを生成しています。
このファイルを参照する形でAirplaneModule（プレハブ）も生成します。

```mermaid
graph TD
    subgraph InputFiles[入力ファイル]
        XMLFiles[/ログファイル<br>飛行機の位置・姿勢/]
    end

    subgraph Processing[処理]
        Convert[座標変換]
        Offset[handicapによる時間合わせ]
        FileOutput[ファイル出力]
    end

    subgraph Output[出力ファイル]
        AnimationClip[/AnimationClip<br>飛行機のアニメーション/]
        AnimatorController[/AnimatorController/]
        AirplaneModule[/AirplaneModule<br>飛行機のモデルとアニメーションに関するプレハブ/]
    end

    XMLFiles --> Convert --> Offset --> FileOutput

    FileOutput --> AnimationClip
    FileOutput --> AnimatorController
    FileOutput --> AirplaneModule

    AirplaneModule -.->|参照| AnimatorController
    AnimatorController -.->|参照| AnimationClip
```

`handicapによる時間合わせ` という処理がありますが、これは、実際の飛行機が飛んだ場所の天気や風向き等によって有利な選手と不利な選手がどうしても生じてしまうため、それを考慮してアニメーション開始タイミングを調整します。
例えば、ある飛行機が強風の中で飛行した場合、その時間を補正して公平にするため、アニメーション開始タイミングを調整します。


# ゲート通過演出用プレハブ生成の流れ

ゲート通過演出は `GateModule` が担います。

試合中のゲート数に応じて生成されるため、 `GateModule` は以下の図のように多数配置されます。
（スタートゲートとゴールゲートは演出が異なるため、 `GateModule` ではなく、別のモジュールで対応します。）

| ![GateModule](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/42a8f483-5e2b-9cdf-597a-65c881c45b1b.png) |
|---|
| 試合で通過するゲート数に応じて GateModule が配置されている様子（スタートゲートとゴールゲートを除く） | 

このモジュールの厄介な点は、1人の選手のデータだけでは完成せず、2人の選手のデータを比較する必要があることです。

なぜかというと、 **先に通過した選手の名前とタイムを上段に表示し、次に通過した選手の名前を下段に表示する。** という仕様があるからです。

| ![GatePassageEffect](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/3230e763-8e10-b6ac-abe2-d6e64e1a955d.png) |
|---|
| 先に通過した選手を上段に、次に通過した選手を下段に表示します。この図はあくまで説明のためのものであり、実際の飛行データに基づくものではありません。 |

この仕様を実現するため、以下のように2つの入力ファイルから1つの `GateModule` が生成されます。

```mermaid
graph TD
    subgraph InputFilesA[選手Aの入力ファイル]
        JsonFileA[/選手Aのログファイル<br>ゲート通過タイム情報/]
    end

    subgraph InputFilesB[選手Bの入力ファイル]
        JsonFileB[/選手Bのログファイル<br>ゲート通過タイム情報/]
    end

    subgraph Comparing[分岐]
        ValidateDNF{棄権判定}
        CompareTime{タイム比較}
    end

    subgraph Processing[表示処理]
        DisplayDNF[棄権と判定されていない<br>選手のみを表示]
        DisplayA[選手Aを上段に表示<br>選手Bを下段に表示]
        DisplayB[選手Bを上段に表示<br>選手Aを下段に表示]
    end

    subgraph Output[出力ファイル]
        GateModule[/GateModule<br>ゲート通過演出用プレハブ/]
    end

    JsonFileA --> ValidateDNF
    JsonFileB --> ValidateDNF

    ValidateDNF -.->|どちらかの選手が棄権と<br>判定されている| DisplayDNF
    ValidateDNF -.->|どちらの選手も棄権と<br>判定されていない| CompareTime

    CompareTime -.->|選手Aが先に通過| DisplayA
    CompareTime -.->|選手Bが先に通過| DisplayB

    DisplayDNF --> GateModule
    DisplayA --> GateModule
    DisplayB --> GateModule
```

急に `棄権判定` [^1] という言葉が登場しました。
これは、オーバーGや速度超過等によるルール違反や機体トラブルにより、選手がゴールできなかった場合を指します。
ゲートのタイム表示演出にも影響するため、このタイミングで判定処理をしています。

[^1]: 正確には **棄権** ではなく **DNF** (Do Not Finish) です。モータースポーツ関係者からすると、棄権という表現は不適切だと感じられるかもしれませんが、Qiita読者にとってわかりやすい表現をさせていただきました。ご了承ください。


# TestRunnerによるテストの自動化

以上のように、様々なモジュールを開発しましたが、そのほぼすべてに対して、テストコードも作成しました！
具体的には86個のテストコードをTestRunnerで実装しました。

| ![TestRunner](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221845/97545e7a-012e-5a94-5188-d471d0a1bd0c.png) |
|---|
| 86個のテストすべてに合格している様子 |

コードを変更するたびにすべてのテストコードを実行し、バグをすぐに検知できるようにしました。
（なお、このテストコードではカバーできなかった範囲で発生したバグもありましたが、それについては別の課題として取り組みました。）

ここで、テストコードを１つ紹介します。

たとえば、タイム数値をゲートに表示する文字列へ変換する関数のテストコードは以下の通りです。

```cs
using _AirRaceXAnimationConverter.Scripts.ExportScripts;
using NUnit.Framework;

namespace _AirRaceXAnimationConverter.Tests.ExportScripts
{
    public class TimeFormatterTest
    {
        [TestCase(3, "3.000")]
        [TestCase(0, "0.000")]
        [TestCase(3.123456, "3.123")]
        [TestCase(3.456789, "3.456")]
        [TestCase(3.789123456, "3.789")]
        [TestCase(-3.789123456, "-3.789")]
        [TestCase(-3.456789000, "-3.456")]
        [TestCase(60, "60.000")]
        [TestCase(123.456123, "123.456")]
        public void FormatSecondsTest(double input, string expectedOutput)
        {
            var actual = TimeFormatter.FormatSeconds(input);
            Assert.That(actual, Is.EqualTo(expectedOutput));
        }
    }
}
```

```cs
namespace _AirRaceXAnimationConverter.Scripts.ExportScripts
{
    public static class TimeFormatter
    {
        /// <summary>
        /// ミリ秒未満を切り捨てる
        /// </summary>
        /// <param name="seconds">秒</param>
        /// <returns>秒</returns>
        public static float TruncateToMilliseconds(double seconds)
        {
            if (seconds >= 0)
            {
                return (float)Math.Floor(seconds * 1000) / 1000.0f;
            }

            return (float)Math.Ceiling(seconds * 1000) / 1000.0f;
        }

        /// <summary>
        /// 秒数をタイム表示用秒数文字列に整形する
        /// ミリ秒未満を切り捨てる
        /// </summary>
        /// <param name="seconds">秒</param>
        /// <returns>タイム表示用秒数文字列</returns>
        public static string FormatSeconds(double seconds)
        {
            var roundedSeconds = TruncateToMilliseconds(seconds);
            return roundedSeconds.ToString("F3");
        }
    }
}
```

ようするに小数第三位まで表示して、それ以降は切り捨て、小数点以下の桁数が足りない場合は0で補完するという処理です。

「たったそれだけのことであれば、 `ToString("F3");` を使えばいいし、わざわざ関数にする必要はない！ここまで丁寧なテストコードも必要ない！」
という意見もあるかと思います。

しかし、たとえば以下のような細かい仕様は、依頼主の要望次第で変化する可能性があります。（実際に変わったものもあります。）

- 小数第四位以降は切り上げなのか？切り下げなのか？四捨五入なのか？マイナス値のときはどうか？
- 60秒以上の数値のときは、 `mm:ss` 表記にするのか？
- マイナス値のときは、 `-` を表示するのか？あるいは別の表現をするのか？

そういう仕様変更に柔軟に対応するためには、こういう細かい処理であっても関数にしておき、単体テストのコードを作っておくことが非常に重要です。


# さいごに

AIR RACE XのARコンテンツ制作業務を効率化するために、Unity Editor拡張ツールを作ったというお話でした！

一緒に開発に取り組んでくれた同僚たちには、改めて感謝を申し上げます。ありがとうございました！

本記事が皆さんのプロジェクトやツール開発のインスピレーションになれば幸いです。

本記事作成にあたり、以下のページを参考にさせていただきました。ありがとうございました。

- https://www.airracex.com/ja/