4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

STYLYAdvent Calendar 2024

Day 11

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

Last updated at Posted at 2024-12-10

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

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

AIR RACE Xとは

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

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

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

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

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

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

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

Editor拡張
Unity Editor拡張のGUI。この図はあくまで説明のためのものであり、実際の飛行データに基づくものではありません。

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

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

モジュール分割

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

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

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

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

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

HeatRoot
モジュールによってプレハブが組み立てられている様子

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

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

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

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

AirplaneModule
選手2名分の AirplaneModule によってプレハブが構成されている様子

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

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

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

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

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

GateModule
試合で通過するゲート数に応じて GateModule が配置されている様子(スタートゲートとゴールゲートを除く)

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

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

GatePassageEffect
先に通過した選手を上段に、次に通過した選手を下段に表示します。この図はあくまで説明のためのものであり、実際の飛行データに基づくものではありません。

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

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

TestRunnerによるテストの自動化

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

TestRunner
86個のテストすべてに合格している様子

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

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

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

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));
        }
    }
}
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拡張ツールを作ったというお話でした!

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

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

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

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

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?