ろうでんのブログ

ただのブログ

【Unity】PLATEAUの都市モデルとGPSログの軌跡を重ねてみる

この記事はAkatsuki Games Advent Calendar 2023の17日目の記事です。

昨日の記事はHajime Sannoさんの「Shader の GUI を実装する」でした。 普通のInspector拡張を触ることが多い自分にとっても、ShaderGUIの話はかなり新鮮でした。 今後Shaderを実装した際にShaderGUIも書いてみたいなと思える記事でした!

他にもクライアントサイド、サーバサイドだけでなくゲーム以外の技術等々、たくさんの記事があります。よければ見ていってください〜

はじめに

去年あたりからドライブにドハマりしていて、その過程で溜まってきたGPSログの軌跡データを使って何かをしたかったので、今回はUnity空間上に描画してみました。 都合よく日本の都市の3DモデルがUnity向けに開発・提供されているので、今回はそれも使ってみました。 軽めの記事のつもりです。

本記事の内容を3行にまとめると

です。

目次

Unity空間上に日本の都市を召喚

GPSログの軌跡を見るだけなら、それこそ (エクスポートせずとも) Googleマップのタイムライン機能で見たり、(エクスポートするにしても) Google Earthで見ればいい話です。

Googleマップのタイムライン機能
が、Unityでの開発を生業にしている身なのでUnityの勉強がてらにUnityを選択しました。

Unityのシーン上に何も配置せずにGPSログの軌跡を描画しても味気ないので、PLATEAUの3Dモデルを配置して、そこに表示を重ねることにしました。

① Unityインストール

PLATEAU SDK for Unityのマニュアルによると、執筆時点で最新リリースのv2.2.1-alphaの推奨環境はUnity 2021.3.30f1以上とのことなので、執筆時点で最新バージョンの2021.3.33f1をインストールします。 今回はEditor上で動けば十分なので、追加のモジュールは入れません。

Unity 2021.3.33f1インストール後のUnity Hubの様子

② 空のUnityプロジェクト作成

特にひねりを入れずに、通常の「3D」テンプレートから作成します。

空のプロジェクトを作成した様子

③ PLATEAU SDK導入

入手方法は3つありますが、今回は手間が少なそうな2を選びます。

  1. GitHubのReleasesから入手 (マニュアル)
  2. UnityのPackage Managerでgit URLを指定 (マニュアル)
  3. Asset Storeから入手

今回はv2.2.1-alphaを使うので、UnityのPackage ManagerのAdd package from git URL...にて

https://github.com/Project-PLATEAU/PLATEAU-SDK-for-Unity.git#v2.2.1-alpha

を指定します。

PLATEAU SDK導入後のPackage ManagerとProjectの様子

④ 都市3Dモデルのインポート & 配置

SDKにはモデルデータそのもののアセットは同梱されてないので、配置に先立ってモデルデータをインポートする必要があります。 まずメニュー/PLATEAU/PLATEAU SDKからPLATEAU SDKビューを開きます。

モデルデータをインポートする際の参照元としてローカル(ダウンロード済みのデータを使う) とサーバー(新たにサーバーからダウンロードする) を選べますが、今回は何もダウンロードしてない状態なのでサーバーを選びます。

データセットの選択では「どの都市のモデルデータをインポートするか」を指定できますが、今回はアカツキゲームスがある東京都23区を選びます。

基準座標系では浮動小数点数由来の誤差を1小さくするために選択した都市に近い基準地を指定します。 今回は東京都23区を選んだので、基準地には09: 東京(本州), 福島, 栃木, 茨城, 埼玉, 千葉, 群馬, 神奈川を選びます。

マップ範囲選択では、Sceneビュー上2で「データセットに含まれる範囲のうち、どの範囲をインポートするか」を選択します。 今回はアカツキゲームスのある目黒駅周辺の9マス分3を選びます。 地物別設定では、モデルデータの他に都市計画決定情報等のデータをインポートするかを選べます。 今回はモデルデータ以外を使わないため、インポートするの☑を外します。

ここまでやったらいざインポートを実行します。

PLATEAU SDKビューでのモデルインポート直前の様子

今回は3分足らずでダウンロードからインポートまで完了しました。 インポート完了時に現在開いているシーンの原点とインポートしたモデルデータの中心が重なるよう自動的に配置されるので注意しましょう (KMLからインポートした点群をどれだけオフセットさせればよいかに関わる) 。 なおモデルデータは原寸大なのでスケールは調整不要です。

インポート完了後の様子

GPSログの準備 & パーサ実装

KMLファイルへのエクスポート

まずはGPSログのデータを準備します。 前述の通り、Googleマップのタイムライン機能からKMLファイルをエクスポートします4。 今回は目黒駅周辺のモデルデータのみをインポートしたので、目黒駅周辺を通った2023-04-09のデータを用います。 タイムライン画面上で⚙ボタンをクリックすると出てくるこの日のデータを KML 形式でエクスポートをすると、KMLファイルがダウンロードされます (今までの全期間の一括ダウンロードはできないように見える) 。

2023-04-09の移動軌跡をタイムライン機能で表示した様子

KMLファイルのインポート

次にUnityプロジェクトにKMLファイルをインポートします。 拡張子が*.kmlのままだとUnityにTextAssetとして認識されずにDefaultAssetとして扱われてしまって不都合が多いため、*.xmlにリネームします。 そしてAssets/Resources/フォルダを作り、リネームしたファイルをその配下にインポートします5

KMLファイルのパーサ実装

次にKMLファイルから緯度・経度のタプル列を抽出するパーサを実装します。 KMLファイルはXMLのフォーマットに従っていて、スキーマ公式リファレンスに記載されている通りです。 今回は移動軌跡を取れれば十分なので、KML全体のパースは試みず、移動軌跡にあたる部分のみをパースできれば十分です。 KML上のkml/Documentタグで囲われた部分は配列になっていて、様々な種類の子要素が入っています。 それらの子要素のうち、今回必要な座標の点群データはPlacemarkタグで囲われた部分にあります。 Placemarkには以下の2パターンがあるので、両方に対応します。

  • 1点のデータのみを含むもの (訪問先を表すデータ): Placemark/Point/coordinates内に記述されている
  • 複数点のデータを含むもの (移動中の座標の履歴を表すデータ): Placemark/LineString/coordinates内にスペース区切りで記述されている

手抜き気味ですが、以下のコードで最低限動きます。

public static IEnumerable<(double latitude, double longitude)> ExtractGeodeticCoordinates(string kml)
{
    // エラーハンドリング等は手抜き
    var xDoc = XDocument.Parse(kml);
    XNamespace xmlns = "http://www.opengis.net/kml/2.2";
    var kmlElement = xDoc.Element(xmlns + "kml");
    var documentElement = kmlElement!.Element(xmlns + "Document");

    foreach (var placemarkElement in documentElement!.Elements(xmlns + "Placemark"))
    {
        var coordinatesElement = placemarkElement.Element(xmlns + "Point")?.Element(xmlns + "coordinates") // 1点のみ
            ?? placemarkElement.Element(xmlns + "LineString")?.Element(xmlns + "coordinates"); // 複数点

        foreach (var coordinate in coordinatesElement!.Value.Split(' '))
        {
            // 例: 139.7161781,35.633174499999996,0
            if (coordinate.Split(',') is { Length: 3 } coordinates)
            {
                // KMLファイル中は経度,緯度,高度の順番になっているので逆にする。高度には0しか入ってないので省略する。
                yield return (double.Parse(coordinates[1]), double.Parse(coordinates[0]));
            }
        }
    }
}

軌跡を描画

最後に、緯度・経度のタプル列をワールド座標に変換してLineRendererで描画します。

緯度・経度 (測地座標系) から平面直角座標系に変換するコードは以下の記事を参考にしました (長いので省略) 。 yubeshicat.hatenablog.com

変換の際に必要となる基準地点は、インポートしたモデルの中心座標35.634375, 139.7171875とします。 この値は、モデルの親GameObjectについているPLATEAUInstancedCityModelコンポーネントのLatitudeプロパティ・Longitudeプロパティで簡単に得られます。

また、モデルをシーン上で動かしたい場合は、変換により得られたワールド座標をさらにオフセットさせる必要があります。

LatLon2Coordinate()で得られる平面直角座標はZ-upの右手座標系なので、最後にUnity標準のY-upの左手座標系に変換します。

これまた手抜き気味ですが、以下のコンポーネントをシーン上に配置し、PLATEAUInstancedCityModelコンポーネントとLineRendererコンポーネントをアタッチすることで最低限動きます。

 public class KmlRenderer : MonoBehaviour
 {
     [SerializeField] private PLATEAUInstancedCityModel cityModel;
     [SerializeField] private LineRenderer lineRenderer;
 
    private void Start()
     {
         var kml = Resources.Load<TextAsset>("history-2023-04-09");
         var geodeticCoordinates = KmlParser.ExtractGeodeticCoordinates(kml.text);
         var worldPositions = geodeticCoordinates.Select(LatLon2WorldPosition).ToArray();
         lineRenderer.positionCount = worldPositions.Length;
         lineRenderer.SetPositions(worldPositions);
     }
 
    private Vector3 LatLon2WorldPosition((double latitude, double longitude) geodeticCoordinates)
     {
         var cityModelPosition = cityModel.transform.position;
         var coordinate = GeoCoordinateConverter.LatLon2Coordinate(geodeticCoordinates.latitude, geodeticCoordinates.longitude, cityModel.Latitude, cityModel.Longitude);
         return new Vector3((float)(coordinate.X - cityModelPosition.x), (float)(50.0 - cityModelPosition.y), (float)(coordinate.Z - cityModelPosition.z));
     }
}

先程エクスポートした2023-04-09の移動経路を描画した結果はこちらです。 一応予想通り(?)の結果が得られました。

目黒駅周辺。ちょっとだけおかしいような……?
アカツキゲームスが入居しているoak meguro周辺
引きの視点

今後の発展

ひとまず技術検証はできたので、自分にとっての理想形を得るために続きをやるとしたらここらへんかな〜と思ってます。

  • 目黒駅周辺の9マスだけでなく、23区全体、東京全体、日本全体をインポートして表示してみる
    • Unityがどこまで耐えられるか次第
  • 手作業で1日ずつエクスポートするのは大変なので、Googleマップのタイムライン機能から何らかの方法で過去のすべてのデータをKMLファイルにエクスポートしてみる
  • KMLファイルには経度・緯度情報しか無いので代わりに決め打ちで標高50mとしていたが、固定値ではないそれっぽい値をでっち上げてPLATEAU空間上に表示してみる
    • 地表のメッシュにRaycastしたときの交点のY座標を使う?
  • 「どこで渋滞によくハマるかの可視化」等、面白そうなことに繋げられそうなので、軌跡上の通過速度を何らかの手法で取ってみる
    • KMLファイル中にはPlacemark/TimeSpanに出発時刻begin・到着時刻endが含まれているが、これでは表定速度しか得られない
    • Googleマップのタイムライン機能の「元データ」には座標ごとの計測時刻が記録されてるので、それを何らかの方法で取得する?
    • 他のGPSロガーのデータを使う?

おわりに

ひとまずできることは分かったので、ぼちぼちいじってみて何か面白いことが分かったらまた記事にしようと思います! (エクスポート面倒問題がネックですね……)

明日のAkatsuki Games Advent Calendar 2023の記事はぐんそうさんの何かだそうです! お楽しみに〜


  1. 日本のサイズは1000kmオーダーなので、Unityの単精度floatを用いた座標系ではcmオーダーの誤差が出る。今回は気にするほどのレベルではない (GPSの計測誤差の方がよっぽど大きい) が、わざわざ誤差が大きくなる基準地を選ぶ理由も無いので素直に09を選択。
  2. Sceneビュー上に地理院地図っぽい地図が出てきて、GUI操作で範囲選択できる。リアルタイムに地理院地図のデータをダウンロードしてきているのか、拡大縮小したりすると重たい。
    マップ範囲選択
  3. 1km弱四方。今回は動くようになるところまでを試したかったので、時間とストレージ容量の節約のためにかなり狭めの範囲とした。
  4. ロケーション履歴を有効にしたAndroid端末を普段から使っていて、運転時にGoogleマップのナビを使っているため、運転時のGPSログが (明示的に計測開始/終了せずとも) 自動的にここに集約されており、データソースとしてちょうどよかったためこれを選んだ。ちなみに、Android Autoでのナビ中はGPSを受信できないトンネル中でも最後の地点から速度・加速度・地磁気等のセンシングデータを基に推定した地点を記録してくれる (車側とスマホ側のどっちが推定しているかは分からない) 。
    首都高4号新宿線からC2中央環状線に入ったときの軌跡。西新宿JCTで山手トンネルに入ってから徐々に北東方向にズレが拡大している
  5. Unityのベストプラクティス的にはResourceフォルダを使った実装方法は推奨されていないが、今回は「動けばいい」のでResourcesフォルダを使った

マツダ車のAndroid Auto機能でナビ画面にUnityを映したかったが数々の壁に阻まれた件

この記事はAkatsuki Games Advent Calendar 2022 18日目の記事です。

昨日はYuji Sugiyamaさんの画像から音楽を生成する「img2music」を手探ってみるでした。 最近はStable Diffusion等の登場によりAIでの画像生成や自然言語生成の成長が目覚ましいですが、音楽生成についても数年前からだいぶ進んでいるんですね〜。 AIの活用に関する動向が気になる方におすすめの記事です!

他にもアカツキゲームスの皆さんによる色々な記事がある(ゲーム会社ですがまるでゲーム以外の技術もかなり多いですよ)のでどうぞ!

はじめに

本記事の内容を3行にまとめると

  • Android Autoという機能により、スマホにインストールしたAndroid Auto用アプリを車のナビ画面上に表示できる
  • Android Auto用アプリは数あれど、Unityを使ってみた例が見当たらなかったので作ってみたかった
  • しかし2つの大きな壁にぶち当たって挫折した

です。

目次

背景

最近車を買いました。

で、この車(マツダ車)にはマツコネマツダコネクトの略)というAndroid Auto対応の多機能なナビ(?)が搭載されています1

マツコネの画面にGoogleマップを表示してルート案内している様子

せっかくなのでAndroid Autoアプリを自分で作ってみたいなと思い立ちました。

Android Autoというのは、(誤解を恐れずに言えば)Android搭載スマホ上で動作するアプリの画面を車の画面に表示して操作できるようにする技術の一つです2

マツコネを含むAndroid Auto対応ナビはあくまでもインタフェースとして動作するだけで、Android Auto用アプリはあくまでも接続されたスマホ上で動作します。

Unityを用いたゲーム開発で飯を食ってる身なのでUnityでAndroid Auto用アプリを作れないかネット上で軽く調べてみたところ、Unity Forumに「作れなかった(細かい情報はナシ)」という古〜いスレッドが2つ見つかった (1, 2 だけだったので、やりようがあるのではないかと思い、トライしてみました(そして前人と同じ末路を辿りました)。

実験環境

Windowsでの実験環境 (環境1)

Macでの実験環境 (環境2)

サンプルのAndroid Auto用アプリをビルドしてみる

Android Auto用アプリのサンプルプロジェクトがGoogleによって複数公開されており、少なくともAndroid for Cars App Library samplesUniversal Android Music Player sampleMedia Controller Test sampleというものがありました。

今回はこの1つ目のAndroid for Cars App Library samplesを題材にします。

  1. GitHubから公式サンプルのリポジトリをclone
  2. car_app_libraryフォルダをAndroid Studioで開く
  3. 【トラップ1】helloworldautomotivecommonmobileのそれぞれに対応するbuild.gradle内のdependenciesのimplementationにあるapp-automotiveappapp-projectedのバージョン1.2.0-rc011.3.0-alpha01に変更する(参考
    • これをやらないとビルドできませんでした
  4. それぞれのproject (helloworld, navigation, places, showcase) をビルド

サンプルのAndroid Auto用アプリをスマホで動かしてエミュレータで表示してみる

Android DeveloperのAndroid Auto 用のアプリをテストするを参考にしました。

ありがたいことに、Googleが提供するDHU(Desktop Head Unit)というツールを使うことでPC上でAndroid Auto対応ナビをエミュレートでき、スマホ上で動作するAndroid Auto用アプリをテストすることができます。

試していませんが、実機のスマホだけでなくAndroid Emulatorでもテストできるはずです。

  1. DHU バージョン 2.0 をインストールするを参考にしてインストール
  2. スマホをPCに接続3
  3. DHU を実行するを参考にして実行
  4. Android Studiohelloworld.mobileスマホにインストール
  5. DHU上の◉ボタン(多くの場合、画面下部の角にある)をタップ
  6. DHU上でランチャーが表示されるので、その中の「カスタマイズ」をタップ
  7. スマホ上でAndroid Autoアプリの設定のランチャーのカスタマイズが開かれるので、Hello Worldの横のチェックボックスにチェックを入れる

  8. 7.だけでは起動済みのDHUに反映されないため、DHUを再起動する

  9. DHU上で再度ランチャーを開くとHello Worldアプリが追加されているので、タップして開く
  10. 以下の画面が表示される

【立ちはだかる壁1】サンプルのAndroid Auto用アプリをスマホで動かしてマツコネで表示してみたかったができなかった(悲)

DHUではサンプルアプリが動いたので、次は実車で表示してみよう!と思いましたがダメでした。

下記のスクショの通り、サンプルアプリをインストールしたスマホを開発用PCから外す(DHUから切断する)と、ランチャーのカスタマイズ画面からサンプルアプリが消えてしまいます。

左: DHU接続中, 右: DHU切断後

なので、愛車に接続してもランチャーにサンプルアプリが出現せず、立ち上げることができません。

自動車向け Android アプリをテストするを見てもDHUを用いたテスト方法しか書かれておらず、嫌な予感がしました。

そこで色々公式ドキュメントを漁ってみると、自動車向け Android アプリの品質

アプリは、Play ストアからインストールされない限り、Android Auto で動作しません。

という記載がありました……4

なので、次は自動車向け Android アプリを配布するを参考にしてPlay Storeでの公開に挑戦してみます。 (追記: 障壁が多すぎて頓挫中……)

しかしながらAndroid Auto用アプリの要件はかなり厳しく、所定の用途のアプリしか許されていない他、自動車向け Android アプリの品質ビジュアル デザインとユーザー操作機能に示された要件を満たさければならず、更にAndroid Automotive OSへの両対応も必要なようで、前途多難です。

【立ちはだかる壁2】せめてDHU上だけでもいいからUnityの画面を表示したかったがスマホ側の画面にしか出せなかった(悲)

サンプルのhelloworld.mobileが最もコンパクトな実装になっているので、これを参考にしつつUnityのAndroid用ビルドを改造していくことにしました。

UnityをAndroid向けにビルドする際、Android Studio用プロジェクトをエクスポートできるので、空っぽのUnityプロジェクトで空っぽのSceneだけをエクスポートしました。

Unityからエクスポートしたものとhelloworld.mobileを見比べると、Unityではandroid.app.Activityを継承したUnityPlayerActivityクラスで画面を表示するのに対して、androidx.car.app.Screenを継承したHelloWorldScreenクラスで画面を表示する実装になっています。

後者は具体的には、

という風になっています。

公式ドキュメントを見ても、そのようにすべきと書かれています。

フォアグラウンドサービスとしてアプリを動作させるため、このような実装になっているのだと思います。

なので、なんとかしてScreenを継承したクラスでTemplateにUnity画面をねじ込まなければなりませんでしたが、アドベントカレンダーには間に合いませんでした(泣)

なお、ナビ画面上にScreenを表示しつつ、同時にスマホ画面上にUnityPlayerActivityを表示することには成功したため、それについては後日記事にします!

今後の展望

UnityPlayerActivityと同等の処理をScreen+Templateで実装できるか頑張ってみます。

また、Unity as a Libraryならばうまくできるのではないか、というのも試してみようかと思います。

うまくいったら、Play Storeの審査に通るようにアプリを作り込んで審査に出してみます。

どうしてもActivityでしか(スマホ側の画面にしか)表示できないようであれば、Android Automotive OS5用アプリ専用のandroidx.car.app.activity.CarAppActivityを用いて作れないか試してみます(こちらは残念ながらマツコネに表示できませんが)。

おわりに

さて、本来の目的は果たせなかったものの、明確な課題点が見つかったので引き続き挑戦してみます!

明日のAkatsuki Games Advent Calendar 2022の記事はYusuke Nakajimaさんの記事です。 お楽しみに!


  1. マツダコネクト(通称、マツコネ)のv1を搭載しているグレード(ロードスターRF VS Terracotta Selection)のAndroid Auto対応の年式(2022年式)のものを買いました。Androidスマホの場合はAndroid Auto機能を使えますが、iPhoneの場合はApple Car Play機能を使えます。なお、他のグレードではマツコネ非搭載だったりします。またマツコネ搭載グレードでも、古めの年式だとAndroid Auto非対応(Apple Car Playのみ対応の場合もある)なこともあります。2022年現在はUSB接続のみに対応しています。また、Android Autoの操作方法には「タッチ操作のみ(touch)」「タッチ操作+コントローラ操作(hybrid)」「コントローラ操作のみ(rotary)」の3種類があります。少なくとも私のロードスターRFの場合はジョグダイヤル等をrotaryのみに対応しているようです。2018年マツダ技報2. Apple CarPlay及びAndroid Auto2.4 開発注力ポイント(1) 安全性対応(P.57) によると、マツコネのためにマツダGoogleが協業してrotaryの対応が行われたそうです(Apple Car Playも同様とのこと)。
  2. 2022年現在のAndroid 12以降の話に限ります。また、似たような名前にAndroid Automotive OSというものもありますが、別モノの技術です。
  3. スマホ上で開発者モードの有効化やUSBデバッグの有効化等、事前に必要な作業がいくつかありますが、今回のAndroid Auto用アプリ実行に限った話ではないため、ここでは割愛します。
  4. ランチャーのカスタマイズ画面から消えていることに気がつくまでは、愛車のランチャーに表示されない他の理由も考えました。愛車が対応しているAndroid Auto APIが古すぎるから説については、AndroidManifestのmeta-detaによりAPI Level 1でも動作するようビルドした(今回はandroidx.car.appの1.3.0-alpha01を使ったため最新のAPI Level 5に対応している)ため違いました。また、愛車がコントローラ操作のみしか受け付けていない(DHU起動時のコマンドラインオプションに--input=rotaryをつけたときと同じ動作)から説については、DHUでrotaryとしたときはランチャーからGameSnacks6が消えるのに対してHello World等のサンプルアプリは消えないので、これも違いました。
  5. Android Automotive OSは、車のインフォテインメントシステム用組み込みOSの一種です。Android Autoとは異なり、車載のコンピュータ上でアプリを動作させます(スマホ不要)。採用されている車は非常に少ないながらも、Polestar 2等のボルボ車に採用されているようです。Android Automotive OS向けであれば、Unity Technologiesが提供しているAutomotive HMI テンプレートを用いて楽に実装できるかもしれません。Android Automotive OS用アプリの場合はAndroid Automotive OS 用のアプリをテストするを参考にして実験できそうです。
  6. (おそらく)Android Autoにプリインストールされているミニゲーム集。ミニゲーム一覧画面ではコントローラ操作も受け付けますが、ミニゲーム自体はタッチ操作しか受け付けません。なお、ミニゲームの中の2048 Giantという2048ライクゲームが面白かったです。

UnityでC#をスクリプト言語のように実行してみる

この記事は Akatsuki Advent Calendar 2021 の11日目の記事です。 adventar.org 10日目の記事はこちら takanakahiko.hatenablog.com


どうも、1年ぶりの投稿です。

今日は Microsoft.CodeAnalysis.CSharp.Scripting を使って*1、Unity上でC#ソースコードスクリプト言語かのように実行してみようと思います。 www.nuget.org

(以降、本記事ではコンパイル対象のC#ソースコードを「ソースコード」、インタプリタに渡されるC#ソースコードを「スクリプト」と呼びます。紛らわしいので)

「UnityでC#を実行?当たり前じゃん」となるかもしれませんが、あくまで「スクリプト言語かのように」実行してみます。 通常、UnityではC#コンパイラ言語として扱うので、ソースコードをMonoやIL2CPPとして実行可能な形式 (ILや機械語) にコンパイルしてEXE等に事前に格納します。 今回やろうとしていることは「C#スクリプト言語として扱い、実行時にスクリプトを与えてインタプリタで解釈・実行する」ということです*2。 具体的には、UnityのuGUI上に配置したInputFieldにスクリプトを書いて、Buttonをクリックしたらそのスクリプトが実行されるようにしてみます。

なお、今回紹介する技術を応用して「スクリプトをAssetBundleでDLできるようにしようぜ!」みたいなことをしてしまうと脆弱性の塊になる*3ので、配布用のプログラムに活用する際にはセキュリティの観点に注意してくださいね (責任は負いかねます) 。


さて、今回は以下の手順でやっていきます。


Unityエディタの用意

後々使う Microsoft.CodeAnalysis.CSharp.Scripting が .NET Standard 2.0 に依存しているので、API Compatibility Level が .NET Standard 2.0 以上に対応しているUnityバージョンを使う必要があります。 具体的には、Unity 2018.1以上であればOKそうです*4。 今回は、特に理由はありませんがUnity 2022.1.0b1 (ベータ) を使ってみようと思います。

Unityエディタのインストールについての詳細な手順については触れません。 Unity公式のドキュメントを参考にしてください。 unity.com

ざっくり言うと、

  1. Unity Hubのインストール
  2. Unity Hubを使ってUnity 2022.1.0b1 (ベータ) をインストール
  3. Unity 2022.1.0b1 (ベータ) 用の空のプロジェクトを作成 (プロジェクトのテンプレートは何でも良いので、とりあえず3Dを選択)

です。

Code Analysys の用意 & DLL参照を追加

今回使用する Microsoft.CodeAnalysis.CSharp.Scripting は、UPMで入手できる Code Analysis というパッケージに付属しています。 これをプロジェクトに追加します。 また、これだけでは追加したパッケージを使用できないので、パッケージのDLLを参照に追加します。

詳細な手順は以下のQiita記事が参考になります。 qiita.com

ざっくり言うと、

  1. プロジェクトを開く
  2. Package Managerを開く
  3. Add package from git URL... から com.unity.code-analysis を指定してインストール*5
  4. ソースコードを入れる予定のフォルダにAssembly Definitionを作成 (今回は ScriptingTest フォルダを作成し、その中に ScriptingTest.asmdef を作成しました)
  5. 作成したAssembly Definitionを以下のように編集
    1. Override References にチェックを入れる
    2. Assembly References に以下の3つを追加

です。

文字列リテラルに格納されたスクリプトを実行

本記事では最終的にInputFieldに入力したスクリプトを動的に実行できる状態を目指しますが、その第一歩として静的なスクリプト (文字列リテラルにハードコードしたもの) を実行できるようにしてみます。

コンソールにログを残すだけの単純なスクリプトを実行できるようにしてみます。 以下のソースコードを作成し、シーン上の適当なオブジェクトにアタッチします。

// ScriptingComponent.cs

using System.Reflection;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using UnityEngine;

namespace ScriptingTest
{
    internal class ScriptingComponent : MonoBehaviour
    {
        private void Start()
        {
            const string scriptText =
@"using UnityEngine;
Debug.Log(""Start() が実行されました。"");
";

            try
            {
                var scriptOptions = ScriptOptions.Default.WithReferences(
                    Assembly.Load("netstandard"),
                    typeof(Debug).Assembly
                );

                CSharpScript.RunAsync(scriptText, scriptOptions).Wait();
            }
            catch (CompilationErrorException)
            {
                Debug.LogError("コンパイルエラー");
                throw;
            }
            catch
            {
                Debug.LogError("その他のエラー");
                throw;
            }
        }
    }
}

この状態でエディタを実行してみると、コンソール上に Start() が実行されました。 というログが出るはずです。

なお、スクリプトでのC#の文法はやや特殊です。 感覚的にはC# 9.0以降のtop-level statementsに近いです。 docs.microsoft.com

UIの配置

前節で静的なスクリプトを実行できるようにしたので、今度は動的にスクリプトを編集して実行できる状態を目指します。 具体的には、uGUIのInputFieldに書いたスクリプトを実行できる状態を目指します。 そのために必要となるUIをシーンに配置していきます。 今回はTextMesh Proを使ったので、途中でTextMesh Proのインポートを行いました。 大体こんな感じの配置にすればOKです。

f:id:cllightz:20211211234707p:plain
uGUIの配置

ボタンをクリックしたら実行できるようにする

前節ではUIを配置しただけなので、InputFieldにソースコードを入力しようが、Buttonをクリックしようが、スクリプトは実行されません。 なので、UIと先述のScriptingComponentを紐付けます。

まず、先程作成したAssembly DefinitionにTextMesh Proの参照を追加しなければなりません。 Assembly Definition Referencesの Unity.TextMeshPro を追加してください。

次に、ScriptingComponent.csを以下のように変更します。

// ScriptingComponent.cs

using System.Reflection;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace ScriptingTest
{
    internal class ScriptingComponent : MonoBehaviour
    {
        [SerializeField] private TMP_InputField scriptInputField;
        [SerializeField] private Button executeButton;

        private void Start()
        {
            executeButton.onClick.AddListener(OnExecute);
        }

        private void OnExecute()
        {
            try
            {
                var scriptOptions = ScriptOptions.Default.WithReferences(
                    Assembly.Load("netstandard"),
                    typeof(Debug).Assembly
                );

                CSharpScript.RunAsync(scriptInputField.text, scriptOptions).Wait();
            }
            catch (CompilationErrorException)
            {
                Debug.LogError("コンパイルエラー");
                throw;
            }
            catch
            {
                Debug.LogError("その他のエラー");
                throw;
            }
        }
    }
}

次にScriptingComponentの各フィールドに前節で配置したInputFieldとButtonをアタッチします。

f:id:cllightz:20211211234948p:plain
UIをアタッチ

これによりすべての準備が整いました。 Unityエディタを再生し、InputField内にスクリプトを書いてExecuteボタンを押すとスクリプトを実行できます。 例えばプリミティブなカプセルを生成するスクリプトを書いて実行するとこうなります。

f:id:cllightz:20211211224249p:plain
カプセルを召喚

まとめ

以上で実装は終わりです。 今回の実装例はGitHub上に公開してあります。 良ければどうぞ。 https://github.com/cllightz/ScriptingTestgithub.com

このままでは特に使い道がありませんが、応用すればゲームのデバッグツールとして使えるかもしれませんね (RiderやVisual Studioのイミディエイトウィンドウみたいに) 。

ただし、前述の通りかなりセキュリティに気をつけないと脆弱性の塊になってしまうので、慎重に実装しましょう。

*1:別のアプローチとして、dotnet scriptコマンドを外部プロセスとして実行する方法があると思います。 そちらは事前にdotnetコマンドとdotnet-scriptをインストールする必要があります。 docs.microsoft.comgithub.com

*2:もちろん、スクリプトを実行するために用意する他の部分は、通常通りコンパイル対象とします

*3:スクリプトから参照できるアセンブリ内にあるコードにある限りの任意コードを実行可能になってしまう

*4:こちらのドキュメントを参考にしましたdocs.microsoft.com

*5:今回は執筆時点で最新の0.1.2をインストールしました

C# 9.0でワンライナーしてみよう

この記事はAkatsuki Advent Calendar 2020 8日目の記事です。 ジョーク記事です。 adventar.org

はじめに

この記事では、AtCoderの練習問題を題材にして、Top-level statementsを活用してワンライナーするためのテクニックについて触れます。 ワンライナーとは、「1行で何でもかんでも処理するソースコード」のことです(ざっくり)。 実用的なワンライナー*1もありますが、ここではパズル的な娯楽のためのワンライナーを扱います。 この記事は「C# 6.0~8.0で普通のコーディングをしたことはあるが、過剰なワンライナーをしたことはない」といった人向けの内容になっていると思います。この記事を読み終えた後は、以下のような状態になっているかもしれません。

  • C# 9.0新構文のTop-level statementsについて知っている
  • LINQ等を悪用したメソッドチェーンによって何でもかんでも1行で書きたくなる

ワンライナー」の定義は色々あると思いますが、この記事では

①1つの文から成り
②ブロックを持たず
セミコロンを1つしか含まない

もののみをワンライナーとして認めることにします。 なお、この記事ではショートコーディングは目指しません。 そして、実行速度も度外視しています。 あしからず。 また、コード例すらも本当に1行で書いてしまうと非常に読みづらくなるため、適宜改行したものを例示します(ワンライナーとは……?)。

本記事で示すコードは、Visual Studio Community 2019 (16.8.2) を用いた.NET 5.0向けコンソールアプリケーションのプロジェクトでの実行を想定しています。 AtCoderのジャッジシステムは用いていません(ジャッジシステムがC# 9.0未対応のため)。


流れとしては、最初にTop-level statementsについて軽く触れた後、実演的な形で非ワンライナーのコードからワンライナーのコードに変形させていくテクニックについて書き連ねます。

目次

C# 9.0とは?

C# 9.0は、プログラミング言語C#の最新のメジャーバージョンです。2020年11月10日にリリースされた.NET 5.0 *2でサポートされるようになりました。 C# 9.0では、新構文が色々追加されました。 今回用いるTop-level statementsもその内の1つです。 それぞれの機能の詳細については岩永さんの記事が詳しいです。

Top-level statementとは?

C# 9.0で追加された新構文(糖衣構文のような感じ)です。

C# 8.0まででは、C言語で言うところのmain関数内に記述するような処理を、メインメソッド内に記述する必要がありました。 また、メインメソッドを持つクラスも定義する必要がありました。 これでは素の状態からコーディングするのは億劫ですね。

// C# 8.0までのHello World
using System;

// メインメソッドを入れるためのクラス
class Program
{
    // メインメソッド
    static void Main()
    {
        // 処理はメインメソッド内に書く必要がある。
        Console.WriteLine("Hello World!");
    }
}

// 出力:
// Hello World!

ところで、Python等の言語では、トップレベルにいきなり書くことができますね? これをC#でできるようにするものがTop-level statementsです。 ちょっとしたコードを書く際に嬉しい構文ですね。

// C# 9.0からのHello World
using System;

// トップレベル(クラス外・メインメソッド外)に処理を書くことができる。
Console.WriteLine("Hello World!");

// 出力:
// Hello World!

もちろん、このように複数行書くこともできます。

// C# 9.0
using System;

Console.WriteLine("Hello World 1");
Console.WriteLine("Hello World 2");

// 出力:
// Hello World 1
// Hello World 2

本題:AtCoderの練習問題をワンライナーで解いてみる

さて本題です。 ここからは、AtCoder練習用コンテストA問題(要ログイン)を解くためのプログラムをワンライナーしていきます。

問題は次のようなものです。

整数a, b, cと、文字列sが与えられます。a+b+cの計算結果と、文字列sを並べて表示しなさい。

制約:

  • 1≤a, b, c ≤ 1,000
  • 1≤|s|≤100

入力:

入力は以下の形式で与えられる。

a
b c
s

出力:
a+b+cとsを空白区切りで1行に出力せよ。

まずはあまり糖衣構文を用いない古典的かつ最も冗長的なスタイルでコーディングしてから、ちょっとずつワンライナーに仕上げていきます。

古典的・冗長的なスタイル

using System;

class Program
{
    static void Main()
    {
        string line1 = Console.ReadLine(); // 1行目の入力(aが文字列として入る)
        string line2 = Console.ReadLine(); // 2行目の入力(bとcがスペース区切りで文字列として入る)
        string s = Console.ReadLine(); // 3行目の入力(sが入る)

        int a = int.Parse(line1); // 文字列→整数 の変換

        string[] bc = line2.Split(' '); // スペース区切りで2つの文字列に分解
        int b = int.Parse(bc[0]); // 文字列→整数 の変換
        int c = int.Parse(bc[1]); // 文字列→整数 の変換

        int sum = a + b + c; // 3つの整数の和
        string sumStr = sum.ToString(); // 整数→文字列 の変換
        string result = sumStr + " " + s; // a+b+cの文字列とsをスペース区切りで結合
        Console.WriteLine(result); // 結果を1行に出力
    }
}

ローカル変数への導入を省略 その1

さっきのコードはローカル変数への導入を過剰に行っていました。 これを省略して、文を減らしていきます。 ついでにvarを用いた型推論も活用しています。

using System;

class Program
{
    static void Main()
    {
        var a = int.Parse(Console.ReadLine()); // 1行目の入力を整数に変換
        var bc = Console.ReadLine().Split(' '); // 2行目の入力をスペース区切りで分解
        var b = int.Parse(bc[0]); // 文字列→整数 の変換
        var c = int.Parse(bc[1]); // 文字列→整数 の変換
        var s = Console.ReadLine(); // 3行目の入力
        Console.WriteLine((a + b + c).ToString() + " " + s); // 結果を1行に出力
    }
}

ローカル変数への導入を省略 その2

もっと詰め込んでいきましょう。 このあたりから非推奨な領域に入っていきます。 ついでに文字列補間 (string interpolation) を活用してイマドキの書き方に直します。 文字列補間自体は推奨です*3

using System;

class Program
{
    static void Main()
    {
        var a = int.Parse(Console.ReadLine()); // 1行目の入力を整数に変換
        var bc = Console.ReadLine().Split(' '); // 2行目の入力をスペース区切りで分解

        // 文字列補間式内には式をガッツリ書けるので、ここで int.Parse() したり
        // Console.ReadLine() しちゃってもOK!
        Console.WriteLine(
            $"{a + int.Parse(bc[0]) + int.Parse(bc[1])} {Console.ReadLine()}"
        );
    }
}

LINQを活用

Console.WriteLine()内の文字列補間式内に色々詰め込めるようになってきましたね。

さて、ここで困ったことに、「2行目の入力を受け取りつつ、b, cの2つの整数にパース」を上手いこと文字列補間式に詰め込めない状態になってしまいました。 例えば、a + b + cの部分を

// ローカル変数に導入せず、その場で Console.ReadLine() しちゃえ!
a + int.Parse(Console.ReadLine().Split(' ')[0]) + int.Parse(Console.ReadLine().Split(' ')[1])

としてしまうと、結果が不正なものになってしまいます。 bにあたる箇所で2行目を、cにあたる箇所で3行目を読み込んでしまうからです*4

いかにローカル変数に導入せずに「2行目の入力を受け取りつつ、b, cの2つの整数にパース」できるか。 こういうときLINQが便利なんです。 Enumerable.Sum()を使って以下のように書き換えてみます。 Enumerable.Sum() *5によって、string.Split() *6して得られたstring[] *7に対して、str => int.Parse(str) *8で各要素をintに変換しつつ、その総和を計算しています。

using System;

// Sum() が System.Linq.Enumerable クラスにある拡張メソッド(後述)であるため、
// Sum() を拡張メソッドとして使うためにはこのusingディレクティブが必要。
using System.Linq;

class Program
{
    static void Main()
    {
        var a = int.Parse(Console.ReadLine());

        // LINQの Sum() を使って、パース後の整数b, cの和を求めている
        Console.WriteLine(
            $"{a + Console.ReadLine().Split(' ').Sum(str => int.Parse(str))} {Console.ReadLine()}"
        );
    }
}

ローカル変数への導入を省略 その3

b, cを1つの式に詰め込むことができたため、aも詰め込めるようになりました*9

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        Console.WriteLine(
            $"{int.Parse(Console.ReadLine()) + Console.ReadLine().Split(' ').Sum(str => int.Parse(str))} {Console.ReadLine()}"
        );
    }
}

(C# 6.0) 式形式のメンバメソッドに変形

晴れて、メインメソッドの中身が Console.WriteLine() のみの1文となりました。

ここで、この記事でのレギュレーションを振り返ってみましょう。

②ブロックを持たず

これは、もちろんメソッドブロックにも適用されるでしょう *10。 そのため、C# 6.0からある式形式のメンバメソッドという糖衣構文を活用して、メインメソッドのメソッドブロックを省略しましょう。 (後々この工程は意味をなさなくなりますが……)

using System;
using System.Linq;

class Program
{
    static void Main() =>
        Console.WriteLine(
            $"{int.Parse(Console.ReadLine()) + Console.ReadLine().Split(' ').Sum(str => int.Parse(str))} {Console.ReadLine()}"
        );
}

usingディレクティブの省略 その1

もう1回、この記事でのレギュレーションを振り返ってみましょう。

セミコロンを1つしか含まない

先程のコードではまだセミコロンが3つありますね? 3つの内、最初の2つはusingディレクティブ *11と呼ばれるプリプロセス命令の1つです。 usingディレクティブは、名前空間名を省略したいとき等に用います。 例えば、Console.WriteLine()でお馴染みのConsoleクラスは、System名前空間内にあります。 なので、名前空間名を省略せずに呼び出す場合には

System.Console.WriteLine()

となります。 いちいちSystem.と書くのは邪魔くさいですね。 そこでusing System;のようにusingディレクティブを書くことで、

Console.WriteLine()

だけでOKになります。

これを逆手に取って、練習問題のコードのusing System;を排除してみましょう。 これにより、セミコロンが2つに減ります。

using System.Linq;

class Program
{
    static void Main() =>
        System.Console.WriteLine(
            $"{int.Parse(System.Console.ReadLine()) + System.Console.ReadLine().Split(' ').Sum(str => int.Parse(str))} {System.Console.ReadLine()}"
        );
}

usingディレクティブの省略 その2

まだusing System.Linq;が残っていますね? このusingディレクティブは、Enumerable.Sum()を使うために書いたものでした*12。 使っている箇所は~~~.Split(' ').Sum(~~~)です。

Sum()Enumerableという静的クラスの静的メソッドですが、Split()の返り値string[]のメンバメソッドかのように書かれています。 このような書き方を拡張メソッドと呼びます。 拡張メソッドはあくまでも静的メソッドなので、普通の静的メソッドと同様に書くこともできます(ほとんど出番はありませんが)。 例えば、

new[] { 2, 3 }.Sum()

であれば

Enumerable.Sum(new[] { 2, 3 })

と書けます。 また、

new[] { "2", "3" }.Sum(str => int.Parse(str))

であれば

Enumerable.Sum(new[] { "2", "3" }, str => int.Parse(str))

と書けます。

これを逆手に取って、練習問題のコードの.Sum()を普通の静的メソッドの形式に変形しつつ、using System.Linq;を排除してみましょう これにより、セミコロンが1つに減ります。

class Program
{
    static void Main() =>
        System.Console.WriteLine(
            $"{int.Parse(System.Console.ReadLine()) + System.Linq.Enumerable.Sum(System.Console.ReadLine().Split(' '), str => int.Parse(str))} {System.Console.ReadLine()}"
        );
}

(C# 9.0) Top-level statements導入

さて、ここまではC# 8.0までの機能でワンライナーに取り組んでみました。 C# 8.0までは、ワンライナーするにもここが限界でした。

改めてこの記事でのレギュレーションを振り返ってみましょう。

①1つの文から成り
②ブロックを持たず
セミコロンを1つしか含まない

①は達成できてそうですね。 クラス定義はではありませんし、メインメソッドに連ねて式形式で書いた部分のみが文となっています。 なので1文で構成されていると言えますね。

一つ飛ばして、③についても一目瞭然、達成できてますね。

さて②についてはどうでしょう? クラス定義の波括弧ってブロックな気がしませんか……?(もしかすると違うかもしれません。ツッコミください) どちらにせよ、排除したくなったので排除しましょう。 ここでTop-level statementsの登場です。 晴れて、この記事でのレギュレーションを満たすことができましたね。 お疲れ様でした。

System.Console.WriteLine(
    $"{int.Parse(System.Console.ReadLine()) + System.Linq.Enumerable.Sum(System.Console.ReadLine().Split(' '), str => int.Parse(str))} {System.Console.ReadLine()}"
);

オマケ:さらなる高みへ

ワンライナーにすることはできました。 が、潔癖を働かせて、より高度なコード(抱腹絶倒ギャグ)にしてみましょう。

さて、System.Console.ReadLine()が行の数だけ3回出てきてまどろっこしいですよね? LINQをこねくり回して合体させましょう。 Enumerable.Range()Enumerable.Aggregate()がミソです。 ついにずっと使ってきた文字列補間式が崩れました。 それにより、適宜改行できるようになりました。 どうだ読みやすくなったろう。

System.Console.WriteLine(

    // Aggregate() は複雑な集計方法も叶えられるスゴいヤツ
    System.Linq.Enumerable.Aggregate(

        // 1行ごとの文字列と行番号を要素とした IEnumerable<(string line, int index)> を生成
        System.Linq.Enumerable.Select(

            // 3行分のループを生み出す
            // IEnumerable<int> を返す
            System.Linq.Enumerable.Range(0, 3),

            // 行の文字列と行番号のタプル (string line, int index) に変換するラムダ式 Func<int, (string line, int index)>
            // ここのindexは、後の条件演算子のところで使う
            index => (line: System.Console.ReadLine(), index)
        ),

        // Aggregate() で必要な「アキュムレータ (int sum, string str) の初期状態」を渡している
        // 整数と文字列を別々に扱った方が簡素になりそうなのでこうした
        (sum: default(int), str: default(string)),

        // 要素を1つずつ受け取り、アキュムレータ (int sum, string str) に集計していくラムダ式 Func<(int sum, string str), (string line, int index), (int sum, string str)>
        (accum, item) =>
            item.index == 2

                // 3行目の文字列をアキュムレータの str に格納
                ? (accum.sum, item.line)

                // 1・2行目の文字列を整数にして、行ごとに Sum() で和を取り、更にアキュムレータの sum を使って1行目での和と2行目での和を合算している
                : (accum.sum + System.Linq.Enumerable.Sum(item.line.Split(' '), str => int.Parse(str)), accum.str),

        // アキュムレータで別々に集計した整数と文字列を合体している
        accum => $"{accum.sum} {accum.str}"
    )
);

おわりに

イカカデシタカ? これからはあなたもレッツエンジョイワンライナーライフ*13

気が向いたらB問題の方もやってみます。 OrderBy()で綺麗に書ける気がします。

最後にオマケのコードを短くして締めくくります。

System.Console.WriteLine(System.Linq.Enumerable.Aggregate(System.Linq.Enumerable.Select(System.Linq.Enumerable.Range(0,3),i=>(l:System.Console.ReadLine(),i)),(n:0,s:""),(a,v)=>v.i>1?(a.n,v.l):(a.n+System.Linq.Enumerable.Sum(v.l.Split(' '),s=>int.Parse(s)),a.s),a=>$"{a.n} {a.s}"));

謝辞

岩永さんの記事にはいつもお世話になっております。 ありがとうございます。

*1:デバッガのウォッチ式を1行で書く裏技としては便利っちゃ便利です。あと、式木にするときに嬉しいかも?

*2:C#VB.NET、F#、C++/CLIなどが動く開発環境&実行環境のこと。今まではWindows専用の.NET Framework系列(4.8が最後)と、マルチプラットフォームだがWPFなどの機能を持たない.NET Core系列(3.1が最後)がありました。それらの正統後継者として.NET 5が生まれました。名前とバージョニングがややこしいですね。 https://docs.microsoft.com/ja-jp/dotnet/core/dotnet-five

*3:文字列補間式はただの糖衣構文で、実行時にはstring.Format()と等価です。文字列補間式はコンパイル時に構文解析されるため、ミスりづらくてありがたいです。文字列の結合よりは高速だし使わない手はありません。ただし、ボックス化 (boxing) には注意。

*4:入力によって動作は異なりますが、ArgumentOutOfRangeExceptionや、無限の入力待機が起きうる状態です

*5:これは拡張メソッド

*6:これはstringクラスのメンバメソッド

*7:string[] は IEnumerable を第1引数に取る拡張メソッドを使える

*8:stringを受け取ってintを返すラムダ式。型で表すと Func<string, int> となる。

*9:これまでの状態では、Console.ReadLine()の実行タイミングがズレてしまわないようにわざわざローカル変数に導入していました。

*10:Microsoftによると、メソッドはコードブロックとのことです。

*11:using文とは別物です

*12:拡張メソッドを使うためには、その拡張メソッドが定義されている静的クラスのある名前空間名をusingディレクティブに書かなければならない

*13:業務では使わないようにしましょう

アカツキのインターンに行ってきた話

久しぶりの投稿です。
株式会社アカツキの就業型インターンシップで10営業日働いてきたので、そこでやったこと・学んだこと・感じたことについて書きます。
某配信中のスマートフォンゲームのプロダクトチームに所属して、クライアントサイドのプログラム(Unity)を触りました。

目次

お前のスペック

理系の国立大学の大学院の修士課程1年生で、2020年新卒の就職活動の最中です。
Unity 歴は半年くらいなので Unity 独自の技術についてはペーペーですが、C# 歴は7年くらいで、かつ業務(アルバイト等)にも使っているのでそれなりにできると自負しています。

与えられたタスクとやったこと

  • 「Profiler を使ってパフォーマンスを改善できそうなところ見つけて!」
    • 一瞬だけ重くなる箇所を見つけて原因を調べた
      • Unity のログ出力が瞬間的に大量に呼ばれていただけだった(なのでリリース版では生じない問題)
      • UnityEngine.Debug.Log() について改めて学んだ →後述
  • コード自動整形ツールの導入 →後述
  • プロダクトで実際に使われている Shader ファイルを見ながら Shader の勉強 →後述(メインの話)

UnityEngine.Debug.Log() について改めて学んだ

Unity でデバッグ出力をしたいとき、UnityEngine.Debug.Log() を使います。
この UnityEngine.Debug.Log() によるデバッグ出力は、リリースビルド時には実行されません。
しかし、IL 上でのメソッドの呼び出し自体は消えないため、引数の評価コストとメソッドの呼び出しコストは掛かってしまいます。
そのため、下記のページでも紹介されている手順で新しいメソッドを作ってあげます。 qiita.com noracle.jp

簡単にまとめると、

  1. Conditional 属性 [Conditional("UNITY_EDITOR")] をつけたメソッドを作り、UnityEngine.Debug.Log() をラップする
public static class Logger
{
    [Conditional("UNITY_EDITOR")]
    public static Log(string arg)
    {
        Debug.Log(arg);
    }
}
  1. UnityEngine.Debug.Log() を使用している箇所を、上記メソッドに置き換える
public class Hoge : MonoBehaviour
{
    public Update()
    {
        // Time.deltaTime の評価や、string.Format() の処理が入る
        // UnityEngine.Debug.Log($"Delta Time: {Time.deltaTime}");

        Logger.Log($"Delta Time: {Time.deltaTime}");
    }
}

コード自動整形ツールの導入

Artistic Style というツールがあるので、プロダクトチームで共有して使うためのバッチを組んでほしい!」という要望があったので、Artistic Style を呼ぶためのシェルスクリプトと、コーディング規約の設定ファイルと、利用者向けのドキュメントを書きました。
(こういう、明確な期限がないものってインターン生の課題に向いてますよね)

Artistic Style とは、C, C++, C++/CLI, Objective-C, Java, C#ソースコードインデントや改行、スペースの挿入位置などのコーディング規約を揃えたり、タブ文字→スペースの置換や改行文字の統一を自動でやってくれるすごいやつです。
「どのルールに統一するか?」を設定ファイルに記述することができるので、チームでのコーディング規約統一にも使えます。
個人的には、コーディング規約を自然言語で記述されるよりも、こういう設定ファイルの方が分かりやすいんじゃないかと思ってます。

astyle.sourceforge.net

トゥーンレンダリングの勉強

プロダクトで実際に使われている Shader ファイルを見ながら、Shader とトゥーンレンダリングを学びました。
主に トゥーンレンダリング(3D グラフィックをアニメ塗りっぽく出す技法)について学びました。

トゥーンレンダリングでは、主に下記の2つのことをやります。

  1. アウトライン(輪郭線)を出す
  2. シェードの計算をいじって、シェードラインがパキッっとなるようにする(アニメ塗り)

詳しく説明していきます。

1. アウトライン(輪郭線)を出す

アウトラインを出すには下記のようなやり方があり、それぞれ特性が異なります。

参考↓

light11.hatenadiary.com

  1. 少し大きくしたモデルをアウトライン色で描画してから、元のモデルで上書き
    • 最も単純なやり方
    • 1つのモデル内の凹な部分にもアウトラインが出る
    • 描画処理は2回走る
  2. 少し大きくしたモデルのステンシルバッファの値と、元のモデルのステンシルバッファの値を別々にして、前者の部分をアウトライン色で描画し直す
    • ステンシルバッファが同じ値のモデル同士(凹な部分や、体と腕の重なりなど)にはアウトラインが出ない
    • 描画処理は3回走る
  3. ポストエフェクトで、隣接する箇所との深度が大きく異なるところにアウトラインを描画
    • 深度情報を付与してあげる必要がある
    • 凹な部分にアウトラインを出すかどうかを決められる
  4. ポストエフェクトで、隣接する箇所との法線ベクトルの方向が大きく異なるところにアウトラインを描画
    • 法線情報を付与してあげる必要がある
    • 凹な部分にアウトラインを出すかどうかを決められる
    • シワや折り目などの部分にもアウトラインを出すことができる
  5. シェーダを使わずに、モデルやテクスチャ自体にアウトラインをつける
    • モデリング担当者の思い通りの箇所にアウトラインをつけられる
    • 後からアウトラインの太さや色を変更することは困難

1について詳しく調べてみたので下記にまとめます。

最も単純なアウトラインシェーダ

Pass を2つ使い、下記の流れでレンダリングします。

  1. 1つ目の Pass で、少し大きくしたモデルをアウトライン色で描画
    • 「少し大きくする」を実現するために、頂点シェーダ内で各頂点を法線方向に移動させる
    • このときの移動させる量がアウトラインの太さになる
    • このままだと 2. のモデルを覆い隠してしまうので、Cull Front を指定する
  2. 2つ目の Pass で、元のサイズのモデルを元の色で通常通りに描画

以上の処理でアウトラインを出せますが、もうちょっと手を加えることでより良くなります。

  • 近くのアウトラインが太く、遠くのアウトラインが細くなるのをなんとかしたい!

    • 法線方向への移動量にカメラとの距離に応じた係数を掛けてあげれば、ある程度太さが一定になる
    • 「スクリーン座標系で 2px の太さにしたい」といった場合には、より重たい処理が必要になる
  • テクスチャの色に応じてアウトラインの色を変えたい!(2D CG イラストでいうところの「色トレース」という技法)

    • テクスチャの色を暗くした色で描画してあげればOK
    • 厳格にアウトライン色を指定したい場合は、モデルの頂点カラーなどにメタ情報としてアウトライン色を埋め込む必要がある

2. シェードの計算をいじって、シェードラインがパキッっとなるようにする(アニメ塗り)

通常のシェーダ(Unity でいうところの Standard シェーダ)では、シェードにはグラデーションがかかったようになります。
ですが、アニメ塗りのノーマル色/1号影のようにシェードライン(シェードのある部分とない部分の境界線)でパキッと塗り分けたい場合があると思います。
そのような場合、下記のようなやり方があります。

  1. ディフューズの計算時に、変化を大きく(係数をかける)して clamp(範囲内に収める)する
    • clamp して1になったところをノーマル色、0になったところを1号影にする
    • 1号影の色を厳密に指定したい場合、テクスチャにメタ情報をつける
  2. テクスチャに全部描いて、シェーダは Unlit(シェードをつけない)にする
    • モデルの向きに応じて陰が変わらない(逆さまになっても)

マップオブジェクトなど、向きが変わらないモデルに対しては 2. でも問題ありませんが、プレイヤキャラクタのように向きが変わるモデルに対しては 1. が望ましいです。

さて、1. の処理でアニメ塗りを再現できるわけですが、もうちょっと手を加えることでより良くなります。

  • 2号影をつける
    • 上記 1. の処理をもう一度やってあげる
  • ハイライトをつける
    • Specular の計算時に、上記 1. と似たように clamp してあげる
  • リムライトをつける
    • モデルの奥のやや横に光源があると仮定して Diffuse を計算

インターンシップを通して感じたこと

私は他社でのアルバイトや就業型インターンの経験があります。
それと比較して最も感じたことは、社員と同様の流れで仕事をできたことだと思います。
他社のインターンでは、インターン専用のカリキュラム(?)に沿って以下のような流れで仕事を進めることがしばしばありました。

  1. 「〇〇を実装する」や「〇〇のバグを直す」といったタスクが与えられる
  2. 問題の対処をする
  3. 問題が解決できたか確認する
  4. Pull Request を出す
  5. 「社員が忙しいのでレビューできない!ごめんね!」
  6. 退職後マージ

一方でアカツキでは、以下のような流れで仕事を進めることができました。

  1. 現状のプロダクトを触って自分で問題点を見つける(自分でタスクを決める)
  2. 問題の原因を調べる
  3. 問題の対処をする
  4. 問題が解決できたか確認する
  5. Pull Request を出す
  6. (レビューされる)
  7. レビューに応じて直す
  8. 6-7 を繰り返す
  9. マージおめでとう!

こうして社員と同じ流れをたどることができ、ただ技術力をつけるだけでなく、仕事を進める力がつけられたのではないかなと思います。

総評

アカツキでのインターンを通して、

  • Unity 力
  • Shader 力(トゥーンレンダリング 完 全 に 理 解 し た
  • コーディング規約治安部隊力
  • タスクを見つけて実行する仕事力

をつけられたと思います!

今回のインターンでは、メンターの方だけでなく、プロダクトチームのいろんな方々にお世話になりました!
この場を借りて感謝申し上げます🙏

aktsk.jp