tanaka's Programming Memo

プログラミングについてのメモ。

ジェネリッククラスの継承。抽象、インターフェイスも添えて

ジェネリッククラスを継承するための定義の書き方で難儀したのでメモ。

以下、簡易シングルトンをジェネリックで扱いつつ、それを更に解消しつつ、インターフェイスも実装。

    public abstract class ResqueActionBase<T> : SimpleSingleton<T> where T : IResqueAction, new()
    {
        // ...
    }

whereを継承元側に書くのか。未だにジェネリックは書き方を見慣れていないので難儀する。

インターフェイスを親に実装したら、子側にも実装を書く必要があるので注意。改めて考えれば当たり前だけど忘れてたので・・・。

参考URL

docs.microsoft.com

ClusterGAMEJAME 2022 in SPRINGに参加した48時間

天翔ける腕輪

天翔ける腕輪|メタバースプラットフォーム cluster(クラスター)

 

Cluster GAMEJAM 2022 Springの参加記念メダルが届きました!!

参加記念メダル

書かねば書かねばと思いつつなかなか手が付けられず開催から1ヶ月以上過ぎてしまいましたが、テーマ発表から公開までの48時間の記録です。

 

目次

 

一人企画会議(3/18 20:30-25:00)

開会式への参加からスタート!

テーマは「かける」!!翔ける、架ける、掛けるなど色々解釈ができて楽しそうなお題です。ちなみにこの時はデフォルトアバターでの参加でした。

 

テーマが判明した21時ごろから一人企画会議の開始です。Notionに思いついたことを箇条書きしたり、iPadのメモ帳に落書きしながら企画を模索しました。

  • 虹を動かす、あるいは発生させて橋をかけて、ワールドを巡ってスタンプ帳を完成させる
  • クレーンで橋を操作する
  • アイテムを使って橋を出現させる

準備段階で考えていた巨大ロボを動かしたいとか、ステージを大きく動かすところから着想。他のプレイヤーごと、ステージの地形をごっそり動かしたいとか、俯瞰でステージを見たいとか、ざっくりとしたイメージは沸くのですが、今ひとつ形にならず。引き続きアイディア出し。

  •  雨雲を操作して、雨が去ったら虹の橋がかかるので、それを歩いてゴールを目指す
  • クマを操作して、木を倒して橋をかけて移動する
  • クレーンを操作して、クレーンの先に吊り下げられているやかんからステージに置いてあるカップ麺や、他のプレイヤーにお湯をかけるというインパクト系のやつ

最終形までイメージが広がらないし、プレイヤーが移動している状態と何かを操作している状態の2つの状態があるとプレイ感が散漫になりそうです。乗り物に乗っている状態だけで完結することを考えて、靴を乗り物にする 案が出ました。

天翔ける腕輪の最初のイメージ

スタート地点から夜空を見上げると、他のプレイヤーが跳んでいる靴からパーティクルとトレイルが放たれて虹がかかっているように見える、そんなイメージです。操作方法、やること、見せたい画面のイメージがハッキリと思い浮かびました。この案はいけそうです。

 

当初は奇抜で驚けて笑えるものにしたかったのですが、アイディアが降ってこないのでは仕方ありません。それはまたの機会ということで、降りてきてくれたこの案を48時間で形にします!!

 

仮タイトルを「天翔ける靴」として、Notionに全作業を書き出して、大項目をツイート。

18日の21時から25時の4時間で企画と作業リストを作成して、少しUnityのプロジェクトの整理などをして初日を終えました。

制作1日目 3/19

画面作り -9:20

画面作りから制作開始です。予め調査していたPureNatureの岩のプレハブをシーンに配置して足場を作ります。

assetstore.unity.com

パーティクルやトレイルを奇麗に見せるために夜のステージにしたかったので、以下のようなアセットからよさげな星空を探しました。

PureNatureの空はデフォルトのSkyboxに雲のシェーダーで構成されていました。上空の設定なので星空が欲しいので不可です。Clusterのサンプルの星空も良い味わいなのですがディテールが合わなかったので不採用。Space Graphics Planetsは星そのものを作るのがメインのアセットでした。星空もあったのですがスクリプトが必要でClusterと相性が悪そうだったので不採用。ということで、最後はいつものFantasy Skybox | 2D Sky | Unity Asset Storeに落ち着きました。以前見た時より表現が増えていてかっこよくなってました!

assetstore.unity.com

 

いい感じの夜空と舞台ができました!

画面作り完了

プレイヤーのアニメ作成、音探し、BGM再生 9:20-13:50

プレイヤーを動かすのに必要な跳ぶ時のアニメーションを作成します。作業に取り掛かったところで、Clusterの乗り物に乗った時の足の位置は、乗り物に設定したアニメから変えられないことに気づきました。跳ねる時に足が動かないのは寂しいです。制御できるのは座る位置と腕の位置。ということで、飛ぶためのギミックは足ではなく、腕につけることに変更して「天翔ける腕輪」になりました。

 

腕輪をProBuilderで作成して、乗り物としてClusterのギミックをアタッチします。腕輪を拾ったり、置いたり、浮遊、加速、というアニメを腕輪や手の位置のオブジェクトの移動で作っていきます。腕輪の位置の調整は、Unity Editor上だとアバターが表示されないので、ビルドとClusterへのアップロードを繰り返して行いました。

 

昼食を食べながらBGMと効果音探し。今回は音楽の卵さんの曲でぴったりなものが見つかりました。

ontama-m.com

 

効果音はお世話になりまくっているUniversal Sound FXです。 

assetstore.unity.com

 

14時ちょっと前にBGMを鳴らして一段落です。

 

星モデル、スタンプカードの組み込み、クレジット表示 13:50-17:10

本体の開発に入る前に、準備済みのスタンプカードの組み込みとクレジット表示を実装しました。集める星のモデルをVoxelorerBirdから持ってきたり、スタンプ欄を7つに増やしたり、カードのデザインをしたり、クレジットを書いておくUIを設置したりして17:10ぐらいに完了しました。

星とスタンプ実装

夕方になってまだゲーム本体に手がついてません^^;

 

買い出し

リフレッシュを兼ねて買い出しへ。ご飯を作る時間を節約するために弁当やおにぎり、あとは景気づけのおやつをゲット。帰宅してご飯食べて作業再開。

 

ジャンプの実装 -25:24

日没後からようやくギミック作りです。まずはジャンプから。着地している時と空中にいる時で方向キーとスペースキーの操作が変わります。Is Grounded Character Item Triggerを使えば簡単に実装できると見込んでいたのですが、着地判定が甘くて飛べないことが頻発。Clusterのポニーのギミックをカンニングすると、着地判定はタイマーを使ったりして複雑なことをやってました。

まだできたばかりの機能なのでやむなし。今後の改善を願いつつ、ポニーの着地判定を参考にして必要な動きができました。

 

次は方向キーが押された時の前後左右への加速ギミックです。この時点では、左右は旋回ではなく直に移動でした。24:00ぐらいに作業完了です。

初日終わり

Clusterは内部の値が分からないので、左上にデバッグ用に文字列を表示していました。この辺りがInspectorに表示されるようになると、より開発がしやすくなりそうです。

 

移動周りの効果音を組み込んで開発1日目終わり。残りの作業量から睡眠時間を計算して就寝しました。

 

制作2日目(最終日) 3/20

操作の実装の続き 5:00-9:35

5:00頃から作業再開。前日は左右は直移動でしたが、マウスでカメラを旋回させてもプレイヤーの向きはそのままというのに気づいて、左右キーをプレイヤーの旋回に変更します。2時間ぐらいで対応完了。

 

トレイルとパーティクルを加えて、操作が一通りできるようになったよ報告ツイート。

 

企画の狙いだった軌跡で夜空に虹をかけるがいい感じで実現できてます。企画の筋が良さそうだと感じることができました^^ 

 

ジャンプの動きやジャンプ時にパーティクルを沢山出すとか演出を調整して、寝る前に考えた想定から30分ほど早い9:35に操作の実装を完了しました。

 

星の収集からエンディングの発動、リセット 9:35-12:40

星と星の収集、収集完了後のリセットです。以下のような感じでテストステージに星を並べて、色やアニメを付けたり、スタンプ帳へ反映させたり、全て取った時にクリアのタイムラインを開始したり、状態を元に戻すギミックを作ったり。

星のギミックの開発の様子

マップ作り 12:40-16:50

前日に購入していたおにぎりを食べながらマップ作りに取り掛かります。この段階まで想定より20分早くたどり着きましたが、余裕があるのはここまででした。

 

当初の予定は、スタートの地面があって、そこから上空に向かってところどころに地面が浮いていて、星も浮いていて、スタート地点から他のプレイヤーを含めてステージ全体が見上げられる、という感じでした。しかし実際に跳んでみると、近くにオブジェクトが無いと浮遊感を感じません。ということで、浮いている岩の間を抜けながら星を集めていくという方針に変更しました。

 

7つの星をどのように配置しようか。星が7つ > 7つの傷を持つ男 > 北斗...七星...。という電波を受信。ネットで北斗七星の画像を探して、真上から見てざっくり同じような形に星を配置しました。高さや岩の配置は後回しにして、星を並べた状態で跳んでみてステージの広さを確認しました。最初は1Kmぐらい必要かな?と予想していたのですが、飛んでみたら星まで遠い^^; 調整の結果、スタートからゴールまで250mぐらいになりました。

真上から見た星の配置

広さが決まったら、岩を並べたり、星の高さで緩急をつけたり、外に出られないように見えない壁で囲ったり、PureNatureによさげな雪のパーティクルがあったのでゴール付近に配置したり、ちょっとシロクマ隠したりして、16:47頃にマップの初期版が完成しました。

 

遊んでいるとなかなか面白いし、シロクマなどの予定になかったことをしたため、ここで2時間ほど予定オーバー!ゲーム説明やアップロードにかなり多めの時間を割り当てたり、20時に完了するような余裕を持たせた見積もりだったのでまだ間に合いますが、ここからは時間との勝負です!

 

クリア演出 16:50-17:50

全ての星を取り終えた時のクリアの演出の作成です。タイムラインを使って空に星を流そうと考えていたのですが、Playerの状態を反映させられるのはPlayer UIだけ、ということに作り始めてから気付きました^^; Canvas上でアニメするか・・・とも考えたのですが、時間がありません。ここはメッセージだけ!と割り切って、それっぽいメッセージをPanelで表示する演出をして、あとはスタートに戻るワープを作って、見込み通り1時間で完了させました。

この「どうでもいい演出案」が、クリア時に星をアニメさせるとか、シロクマに乗るとか、動く星があるとか、敵がいるとか、そういったものです。無くても成立するものは省いて公開に向けて集中です。

 

ハイスコアと仕上げ 17:50-20:20

自分の記録は保存できるので、クリアまでの時間がこれまでの最短だったら記録更新を称える機能を付けました。これで予定していた最低限の機能が揃いました。まだバランス調整に使える時間が残っていたので仕上げに入ります。

 

繰り返し遊んでみて、マップの配置を調整したり、ちょっと印象的なものが欲しくなってストーンヘンジみたいなのを配置しました。また、当初からあった案の一つである動く星を1つだけ実装。

 

まだバグが残っていましたが、時間内に解決できるか分かりません。一先ず、バグの内容をスタートの後ろのパネルに操作説明と一緒に書いておいて、バンカー版として公開することにしました。

 

ラストスパート 20:20-21:00

少し時間を残して応募ができました。あとは時間が許す限り、開始時に腕輪を置く音が同時に鳴ってうるさかったり、腕輪を置いて30秒するとスタート地点に強制的に戻されるバグを潰していきました。

 

20:46

 

20:55

 

バグ修正版のアップロードが完了したのは20:59でした。応募締め切りはサーバーの負荷が大変なことになっていたと思いますが、Clusterさんのサーバーは見事持ちこたえてくれました。素晴らしい環境です。

 

21:22

 

以上で「天翔ける腕輪」の応募が完了し、Cluster GAMEJAM 2022 Springを完走しました!!

 

今後のテーマ

今回の参加の目的の一つは、自分に足りないところを知り、これから取り組むテーマを見つけることでした。以下が挙がりました。

  • 「奇抜」「驚き」「笑い」の企画を考えるための入力とイメトレ
  • 他のワールドで圧倒された演出やギミックを観察して、ノウハウを増やす

他に取り組んでいることがあれこれあってこの一ヶ月で早速できていませんが^^;

これを書きながら思い出しました。振り返り大切。

 

最後に

結果は、お陰様で乗り物部門の大賞をいただくことができました!!

 

名前が呼ばれるまでは、他の受賞者の皆さんの作品を眺めて一人反省会をしていました。参加の目的だった今後のテーマを得ることや、実際にワールドを作る経験を得ること、応募作品数を1つでも増やして大会を盛り上げることは達成することができました。これでよし!と思っていたところで受賞というボーナスが加わりました。全力で取り組んで公開すると、時々、想定していなかった良いことがあると改めて感じました。参加すること大切

 

この後、念願だったシロクマに乗れるようにしたり、操作性を調整したりしたものが、現在公開されているものです。実は、当初の目的であった他のプレイヤーの飛ぶ姿を見る、というのが叶っていません。是非、お誘いあわせの上、大勢で星を巡ってみてください!!

cluster.mu

 

授賞式の時は、MagicaVoxelで作ったボクセルキャラクターをオリジナルアバターにしました。作成手順はこちら。

am1tanaka.hatenablog.com

 

使用アセット

AssetStore以外の素材

github.com

なにはなくとも。このプロジェクトを整理して作り始めました。乗り物をはじめ、様々なギミックの応用例が見れる有用な作例の塊です。

 

ontama-m.com

プレイ中、クリア時のBGMはこちらから。ワールドの雰囲気を作り出してくれました。

 

kenney.nl

スタンプ帳の星などちょっとした素材に利用しました。CC0で一通りの素材が見つかるので重宝してます。

 

freefonts.jp

日本語用のフォントアセットはこのフォントから作成しました。

 

fonts.google.com

等幅フォントはこちらから。

 

www.mixamo.com

リング装着中の基本姿勢などはこちらから。

 

Asset Store 

assetstore.unity.com

背景の足場や構造物、雪のエフェクトに利用しました。

 

assetstore.unity.com

夜空に利用しました。空で迷ったら大抵これに行き着きます。

 

assetstore.unity.com

効果音はいつもお世話になっている自分的定番アセットです。

 

assetstore.unity.com

スタート付近のリセットゾーンや、ゴールの先にあるワープゾーンはこのパーティクルから。

 

assetstore.unity.com

シロクマ親子のモデルとアニメモーションはこちらから。本当はここから沢山動物出したかったのですが間に合わず。またの機会に。

 

assetstore.unity.com

Mixamoで入手したモーションの編集に利用しました。

 

assetstore.unity.com

星のボクセルモデルの読み込みに利用しました。作品内ではありませんが、Cluster用のカスタムアバター作成でも利用しました。

 

 

GitHub Desktopをアップデートしたら外付けドライブのリポジトリが読めなくなった

表題の通りなのですが、GitHub DesktopをアップデートしたらPCの内蔵ドライブのリポジトリは問題ないのですが、外付けドライブのものが開けなくなりました。

解決策

4/14にアップデートが出て、外付けドライブのリポジトリを開こうとすると以下のような警告が表示されるようになりました。

f:id:am1tanaka:20220414202803p:plain
警告

これのadd an exception for this directoryのリンクをクリックすると、安全なディレクトリとして指定する設定が書き込まれて開けるようになりました。

原因

原因はGitのセキュリティ設定の影響だったようです。詳しくは以下の通り。

stackoverflow.com

内蔵ドライブのリポジトリフォルダーのセキュリティ設定が、Windowsにログインしたユーザーのものであれば問題なく開けるということです。外付けドライブはFATフォーマットでフォルダーの所有設定がないため、何らかの方法で設定ファイルに安全なフォルダーとして指定を加えないとgitで管理できなくなったわけです。外付けでなくても、同様にリポジトリフォルダーや.gitフォルダーがログインユーザーのアクセスが未設定だと、設定が必要になるようです。

設定

先に示したリンクのクリックを押さなくても、直にGitの設定ファイルにリポジトリのパスを加えればアクセスできるようになるようです。

gitのグローバル設定が~/.gitconifgにあるので、そのファイルを開いて、[safe]を書き加えて、その下の行にリポジトリディレクトリーを追加します。E:/ProjectFolders/Repoフォルダーなら下の通りです。

[safe]
    directory = E:/ProjectFolders/Repo

MagicaVoxelで作ったキャラをVRM化してclusterのアバターにする

clusterのゲームジャムイベントの時にデフォルトのアバターというのもなんだな~と思ってVoxelorerBirdの主人公キャラをアバター化してみました。

VoxelorerBirdの主人公にはすでにボーンを設定していたのであっさり持っていけると思っていたのですが、ボーンの構造が単純すぎてVRM化に失敗。clusterの公式サイトで紹介されているmixamoでボーンを自動設定するのも、ボクセルキャラが人型ではなかったため失敗。やむを得ないのでVoxel Importerであらためてボーンとウェイトを設定することにしました。その際の作業の備忘録です。

目次

使用ツール

以下のツールを使いました。

このうち、Voxel Importerは有料ですが、ボクセルを扱う最高のツールです。Unityでオリジナルのボクセルキャラを使ってゲームを作るならVery Animationとまとめてゲットしておくのがおススメです。

手順

MagicaVoxelでモデリング > Unityに読み込み > Voxel Importerでボーン設定 > dae形式でエクスポートして再読み込み > VRMにエクスポート、という流れです。

モデルの作成

Magica Voxelでキャラクターを作ります。いわゆるTポーズで作ります。Magica Voxelのサンプルのchr_knightとかのままだと変なになるので、あれを使う場合は装備を外して手を伸ばしてください。

NG

こんな感じに修正

左手の先に髪の毛が繋がってて駄目っぽいですが、Voxel Importerでうまいことウェイトを設定すれば切り離すことができます。斜め接続もVoxel Imporeterなら大丈夫です。

モデルができたらMagica Voxelのデフォルト形式のvox形式で保存すればモデル作成は完了です。Unityのプロジェクトがすでにあれば、プロジェクトのAssetsフォルダー以下の任意のフォルダーに入れればすぐに使えます。

ボーンの設定

作成したvoxファイルをVoxel ImporterでUnityに読み込んでボーンを設定します。

voxのインポート

  • 読み込み直後はボクセルの一辺1m相当で読み込まれて巨大なので、Scaleで適度な大きさに調整します。例えばchr_knightは高さが15ボクセルなのでそのままだと15mに相当します。Import Scaleのx,y,zすべてを0.08にすれば1.2m相当になります
  • Import Offsetは、Setボタンをクリックして、Feetを選択すると足元の丁度よさそうな場所が設定されます。

OffsetにFeetを設定

  • Animation欄でCreate > VoxelImporter > Editor > BoneTemplates > Maximum Humanoid(Mecanim).asset を選択して、必要なボーン構造を生成します

ボーンの生成

  • 生成したボーンのうち、Jawがあると後でエラーになるので削除します

Jawを削除

他にもclusterのドキュメントにないパーツは削除して構いません。

cluster. カスタムアバターの制限

  • RigのAnimationタイプに Humanoid を設定します

このままだとボーンが巨大すぎてclusterなどに持って行った時にちゃんと表示されないので、ボーンのサイズを合わせておきます。

  • Hierarchyウィンドウでvoxを読み込んだオブジェクトを開いて、Hipsを選択します

Hipsを選択

  • Inspectorウィンドウで Edit Bone Position をクリックして選択します

Bone Positionの設定

最初はこのぐらいボーンの大きさが合っていません。

Boneの位置合わせ

  • Scaling All欄に0.5を入力してApplyボタンを押して、それらしいサイズに調整します

ざっくりサイズ合わせ

ボーン合わせとウェイト設定は慣れるまで大変なので、先に動作確認のためにVRMに出力してみましょう。

VRMの作成

VRMを作成するには、notargsさんのUniVRMを利用します。

github.com

ドキュメントに従ってUnityプロジェクトにインポートしてください。

Voxel ImporterのオブジェクトのままだとVRMに書き出せないので、一度エクスポートしてUnityの通常のアセットにします。

  • InsectorウィンドウのVoxel Skinned Animation Objectの三点アイコンをクリックして Export COLLADA(dae) File を選択します

daeファイルでエクスポート

  • Assetsフォルダー内の適当な場所にエクスポートします。チェックは全てついていてOKです
  • Projectウィンドウでエクスポートしたdaeファイルを選択します
  • InspectorウィンドウでRigを選択して、Animation TypeがHumanoidになっていることを確認します。違う場合はHumanoidにしてApplyをクリックします
  • VRM0メニューから Export to VRM 0.x を選択します

VRMエクスポート

  • 下の方にあるTitle欄にアバター名を入力します。例えばchr_knightなど
  • Versionに適当なバージョン番号(0.1.0など)を入力します
  • Authorに著作権者名入力します。自作のモデルなら自分の名前、他の人のものならその人の氏名(chr_knightならEphtracy)を入力します

以上設定したら Export ボタンをクリックして出力します。デスクトップなどの適当なフォルダーを指定して出力すれば完了です。出力したvrmファイルをclusterのアバターなどにアップロードします。

clusterのアバターにアップロード

ウェイトを設定していないのでTポーズのままですが、ワールドに入れるはずです。

とりあえずアバター化成功!!

あとは必要に応じて Voxel Skinned Animation Object の Advanced 設定をしたり、ウェイトを設定して、daeファイルのエクスポートからVRMファイルを作り直せばOKです。

Voxel Importerでのウェイト設定

苦戦したのでコツをメモしておきます。なにはともあれ、まずは公式動画を一度視聴します。

youtu.be

以下が作業のコツです。

  • 近寄って作業したいのにモデルがカメラを突き抜けてしまうとか、回転位置がおかしくて操作しにくい場合、注視点を設定することで直せます。Hierarchyウィンドウでウェイトを設定したいキャラクターをクリックして選んで、Sceneウィンドウ上にマウスカーソルを移動させたら、Shift + F キーを押します。これでズームや回転が操作しやすくなります
  • Sceneウィンドウは Iso(等角図)にした方がパースがかからずに設定しやすいと思います

Isometricモード

  • ウェイト編集モードでモデルが真っ黒になって形状が確認しにくい時は、Spaceキーを押すと元の絵が表示できます
  • Spine(背骨)など親側から設定します
  • 全てのボーンにウェイトを設定する必要はなく、Spine, Left Upper Arm, Left Lower Arm, Left Hand, Head, Left Upper Leg, Left Lower Legあたりにざっくりと設定します
  • 頂点モード(Vertex)のRectでざっくりと設定すると、内部のボクセルもまとめて塗られて楽です
  • ボクセルが隣接していたり、斜めになって頂点が共有されている場合は、VoxelモードとVertexモードを上手に使い分けて塗ると正しく分割できます

髪とくっついている手をVoxelモードでウェイト設定

手にウェイトを設定。髪の毛は真っ黒なのでウェイト0

切り離してアニメできます

  • ウェイトは1か0でめりはりをつけて設定しておく感じ
  • まずはざっくり設定して、細かいところはあとで修正

こんなところです。はじめのうちはコツが掴めず何回もやり直しましたが、動画を参考に、アニメさせながら調整するとか、ざっくり設定する感覚が把握できると、結構さくさくと設定できるようになりました。

揺れもの

UniVRMのVR Spring Boneを使うと、髪の毛やしっぽなどの揺れものを設定できます。詳しくはVirtualCastの以下のWikiにあります。

virtualcast.jp

Voxel Importerで作成した場合はドキュメント内のsecondaryに該当するものがないので、以下のような手順で下準備します。

  • ボーンにしたい位置に空のゲームオブジェクトを作成して、Voxel Importerの Voxel Skinned Animation Object Bone をアタッチしてボーンにします
  • ウェイトを設定します
  • Collada形式でエクスポートして、Unityオブジェクトに変換します
  • daeファイルをHierarchyウィンドウにドラッグ&ドロップしてシーンに読み込みます
  • シーンに読み込んだルートオブジェクトの子供にスプリング管理用の空のゲームオブジェクトを作成して、VR Spring Boneコンポーネントをアタッチします

あとは、Virtualcastのドキュメントに従って揺れ設定をすればOKです。

移動や回転が速すぎると当たり判定を設定しても反対側にすり抜けてしまって戻らなくなったりしました。スプリング(Stiffness Force)を最大に固くして、抵抗(Drag Force)を大きくして、重力を軽くして(Gravity Dirを0に近づける)、動きを抑えてなんとなく安定させました。

まとめ

MagicaVoxelで作成したvoxファイルをUnityのVoxel Importerでボーンを入れて、UniVRMでVRMファイルに出力してclusterのアバターを作成しました。

  • Magica Voxelでモデルを作成してvoxファイル化
  • Voxel ImporterでvoxファイルをUnityにインポート
  • Voxel Importerでボーンの作成とウェイト設定
  • Voxel ImporterでモデルをエクスポートしてUnityオブジェクト化
  • UniVRMでVRMファイル出力

難関はウェイト設定ですが、Voxel ImporterのVoxelモードとVertexモードを活用すると、くっついているボクセルを切り離すことができるのでモデル作成の制約が減ります。ウェイト設定時に Shift + Fキーでキャラクターに注目することと、Spaceキーで元の絵が見えることを知ると作業効率が格段に上がりました。

ボクセルならモデルが作りやすいのでモデリングが苦手な人でも自分用のオリジナルアバターが作れるのではないかと思います。

参考URL

水面やUnlitシェーダーとDepth of Fieldの不具合対策メモ

Depth of Fieldと相性が悪いもの

オブジェクト指向を勉強するための素材となる海賊をモチーフにしたスゴロクもどきゲームを開発しています。

制作中の海賊スゴロク(仮)

箱庭っぽいイメージを狙ってPost ProcessingのDepth of Field(DOF:被写界深度)を設定して、水面はフリーながらカッコいいLowPoly Waterを組み込みました。いい感じでできたと思ったのですが、水面に配置したFadeの波紋オブジェクトが消えたり、手前にある水面がボヤけたりと謎の症状が発生しました。それらの問題の原因と解決策の備忘録です。

目次

問題

問題を検証するためのプロジェクトを作成しました。手元で確認したい場合にご活用ください。セットアップ方法はリポジトリのREADME.mdにあります。

github.com

3つ並んでいるうちの真ん中のCubeは正常に描画されています。Materialの設定は、シェーダーはStandard、Rendering ModeはOpaqueです。Cutoutでも正常に描画できます。

FadeとUnlitと水面

左右のCubeも同じ奥行きに配置しているのですが、以下のような問題が起きてます。

  1. 左のCubeの下の消失。StandardシェーダーのRendering ModeをFadeにすると発生します。Transparentでも同じ状態になります
  2. 右のCubeのボヤけ。Unlitシェーダーで描画しています
  3. 真ん中のCubeの下の水面のボヤけ。遠くにあるようにボヤけていますが、水面は立方体の直下です

原因

1. FadeやTransparentが消える

この症状は、FadeやTransparentのオブジェクトと、LowPoly Waterで作成した水面オブジェクトの位置関係で発生します。

消えたり現れたり

上からみるとこんな感じ

水面の中心がCubeの中心よりカメラに近くなると不具合が発生します。

LowPoly Waterの水面のRender Queueは、FadeやTransparentと同じ3000に設定されています。Render Queueは、描画処理の仕方や描画順を決める値です。詳しくは ShaderLab: SubShader 内のタグ - Unity マニュアルレンダリング順 - Queue タグにあります。

Render Queueは描画順などを決める設定で、値が小さい方から描画します。Render Queueが同じ値の時は、2500以下かどうかでルールが変わります。2500以下は不透明扱いになり、Zバッファ(奥行きバッファ=デプスバッファ)への書き込みとテストをしながら手前から奥へ描画していきます。Zバッファより奥のものは見えないはずなので描画を省いて時間を節約します。2500より大きい場合は半透明として扱います。奥の物が透けて見える可能性があるので、Zバッファは更新せず奥から手前に上書きしていきます。

ここで問題となるのが水面のシェーダーです。LowPoly Waterは独自のWaterShadedシェーダーで水面を描画します。Zバッファを参照して、水面下の不透明物と水面の距離に応じて波を表現したり、水面下を半透明で描いたりしています。この処理が描画済みの半透明のオブジェクトを考慮していないため、FadeやTransparentのオブジェクトが奥にあって先に描画されていた場合に水面が上書きして消してしまうのです。

2. Unlitのオブジェクトがボヤける

ボヤけるのはDepth of Fieldの効果ですが、Unlitのオブジェクトの奥行きが正しく反映されずボヤけ過ぎてしまっています。よく観察してみると位置によってボヤけ方が変わっています。

水面の下を観察

赤で囲った部分とそれより下では、囲んだ範囲の方が少しだけボヤけ具合が弱くなっています。また、左のFadeのCubeの表示範囲と赤で囲った部分が一致しています。FadeやTransformはZバッファに奥行きを書き込まないので、奥にある不透明なものまでの距離に応じたエフェクトがかかっているのです。Unlitも原因は同じです。Unlitは不透明でもZバッファに奥行きが書き込まれないのです。

Zバッファに書き込まれる時の条件が書かれている公式マニュアル。

docs.unity3d.com

以下の3つの条件が成立している状態でShadow Casterパスを描画する時に、ハードウェアがZバッファに奥行きを書き込みます。

  • マテリアルのRender Queueが2500以下
  • ZWrite On
  • ShadowCasterのパスが有効であること

Unlitは光の影響を受けないので影が描画されません。つまり、Zバッファを書き込むShadowCasterパスがないのです。Depth of Fieldが正しく反映されないのはこれが理由です。

3. 水面がボヤける

水面シェーダーのRender Queueが3000なので、水面の高さはZバッファに書き込まれません。どうせ半透明の描画順を調整するので、水面シェーダーを不透明なQueueにして、Shadow Casterを有効にすれば解決と思ったのですが、別の問題が発生しました。

水面が真っ白に!

これは開発中の海賊スゴロクで水面の高さをZバッファに書き込んだ時のスクショです。ボヤけは解決しましたが水面が真っ白になっています。水面の高さをZバッファに書き込んだことで水面のすぐ下に物があると水面シェーダーが判定してしまい、波の白で塗りつぶしてしまうのです。水系のシェーダーはこの辺りの対策が必要です。

これで全ての原因が分かったので解決していきます。

解決編

FadeとTransparentの消失とボヤけ問題

消失問題はRender Queueを調整して水面を先に描画するようにすれば解決します。更にボヤけを防ぐには、Render Queueを2500以下、かつ、Shadow Casterパスの追加をしたカスタムシェーダーを作ります。

新規にUnlitシェーダーを作成して、ZBufferShaderのような名前にして以下のようにします。

Shader "Unlit/ZBufferShader"
{
    SubShader
    {
        Tags { "Queue"="AlphaTest+1"}
        LOD 100

        Pass
        {
            Tags { "LightMode"="ShadowCaster"}

            ZWrite On
            ColorMask 0
        }
    }
}
  • Render QueueはAlphaTest+1として、Cutoutより後で描画します
  • ColorMask 0は色もアルファ値も書き込まないようにする設定で、Zバッファだけ書き込みます

StandardシェーダーのFadeでの描画に加えて、このシェーダーでもCubeを描画します。Standardシェーダーに手を加えるとか、自前で後付け描画するなどのスマートな方法が考えられますが、今回はMesh Rendererに複数マテリアルを設定することにします。

このシェーダーを設定したマテリアルを作成して、FadeのCubeのMesh Rendererにテクスチャを追加します。

Zバッファ専用マテリアル

「同じメッシュを別のマテリアルでそれぞれ描画するからパフォーマンス落ちるぞ。マルチパスを推奨」と言われてます。しかし、マルチパスでRender Queueを変える方法がなさそうだったので承知の上で押し通します。

これで問題が解決です。真っ白な部分がありますが、これは水面シェーダーが原因なので後で直ります。

水面シェーダーの影響で真っ白

水面がなければ大丈夫

注意!!

不透明なQueueにしたことで、手前から奥に描画することになります。そのため、このMaterialを設定したFadeやTransparentのオブジェクト同士が重なると奥にあるものが描画されなくなります。この辺りを完全に解決する手段はなさそうで、Render Queueをいじるなどして調整するようです。

Materialに表示されている警告が気になる場合は、自前でスクリプトを作成して描画すれば表示されなくなります。そのものズバり欲しい情報がLIGHT11さんのブログにありました。

light11.hatenadiary.com

やってることはMesh Rendererに警告されたことを自前のスクリプトに移動しただけのような気がするので、警告は消えますがパフォーマンス的には同じような気がします。本題の海賊スゴロクではこの問題は対応しなかったので今回は手を出しませんでした。サブメッシュが絡んできたら必要になるかも知れませんし、実際にはパフォーマンスの違いがあるかも知れません。その辺の問題が出た時にまた調査ということにします。

Unlitシェーダーのボヤけ問題

カスタムのUnlitシェーダーを作成して、ShadowCasterを有効にしたパスを追加します。これはよくある問題のようであちこちで解決策が見つかりました。公式を参考に解決します。

docs.unity3d.com

ついでにデフォルトだと半透明に対応していないので対応させました。新規にUnlitシェーダーを作成して、UnlitZBufferという名前にして以下のコードを書きます。

Shader "Unlit/UnlitZBuffer"
{
    Properties
    {
        _Color("Main Color", Color) = (1,1,1,1)
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue" = "AlphaTest+1" "IgnoreProjector"="True"}
        LOD 100

        Pass
        {
            Tags {"LightMode"="ShadowCaster"}
            ZWrite On
            ColorMask 0
        }

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Color;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv) * _Color;
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

今回は追加パスとかは不要なので、既存のMaterialのシェーダーをこれに変更して解決です。

カスタムUnlitZシェーダー適用!

半透明の部分が変な感じになりますが、これも水面の問題です。中央のCubeの色が変わるのはgif化した時の影響なので実際には起きません。

水面のボヤけ問題

この解決には、水面シェーダー用のカメラが必要になります。解決のヒントは先日ゲットしたTanuki Digital - Asset StoreさんのSUIMONO Water Systemで見つけました。

assetstore.unity.com

確認したところ、水の演出のために沢山のカメラが使われていました!!

カメラ軍団!!

水面と水面下でそれぞれDOFを適用するなら別の奥行きが必要になりますし、水面下の半透明物は?とか考え出すと水系は大変だなと実感しました。優秀なアセット作者の皆様に感謝!!

水面シェーダー用のカメラの作成

水面下のZバッファを描画する水面用のカメラを作ります。メインカメラと同じものが必要なので、分かりやすくメインカメラの子供にしました。描画前に位置を合わせればいいので実際には場所はどこでも良さそうです。SUIMONOでは水面用のオブジェクトにまとめられてました。

  • HierarchyウィンドウのMain Cameraを右クリックして、Cameraを追加します
  • 追加したCameraのAudio ListenerをRemoveします
  • 新規でC#スクリプトを作成して、名前をUnderWaterCameraにして、以下のようにします
using UnityEngine;
using UnityEngine.Rendering;

[ExecuteInEditMode]
public class UnderWaterCamera : MonoBehaviour
{
    /// <summary>
    /// RenderTextureのサイズ
    /// </summary>
    static int renderTextureSize => 512;

    /// <summary>
    /// ビット数
    /// </summary>
    static int renderTextureDepth => 24;

    Camera sourceCamera;
    Camera underWaterCamera;
    RenderTexture underWaterTex;
    float currentAspect;

    private void Awake()
    {
        sourceCamera = transform.parent.GetComponent<Camera>();
        underWaterCamera = GetComponent<Camera>();
        underWaterCamera.CopyFrom(sourceCamera);
        underWaterCamera.cullingMask = underWaterCamera.cullingMask & (-1 ^ LayerMask.GetMask("Water"));
        underWaterCamera.clearFlags = CameraClearFlags.Depth;
        underWaterCamera.depth = -100;
        underWaterCamera.depthTextureMode = DepthTextureMode.Depth;
        underWaterCamera.targetTexture = null;
        UpdateRenderTex();
    }

    private void LateUpdate()
    {
        UpdateRenderTex();
        Shader.SetGlobalTexture("_WaterDepthTex", underWaterTex);
    }

    private void OnDestroy()
    {
        DestroyTexture();
    }

    void DestroyTexture()
    {
        if (underWaterCamera.targetTexture != null)
        {
            underWaterCamera.targetTexture = null;
        }
        if (underWaterTex != null)
        {
            DestroyImmediate(underWaterTex);
            underWaterTex = null;
        }
    }

    /// <summary>
    /// RenderTextureを生成
    /// </summary>
    void UpdateRenderTex()
    {
        if ((sourceCamera == null) || (underWaterCamera == null)) return;

        if (underWaterTex != null)
        {
            if (currentAspect != sourceCamera.aspect)
            {
                currentAspect = sourceCamera.aspect;
                DestroyTexture();
            }
            else
            {
                return;
            }
        }
        underWaterTex = new RenderTexture(
            renderTextureSize, renderTextureSize,
            renderTextureDepth,
            RenderTextureFormat.Depth,
            RenderTextureReadWrite.Linear);
        underWaterTex.dimension = TextureDimension.Tex2D;
        underWaterTex.autoGenerateMips = false;
        underWaterTex.anisoLevel = 1;
        underWaterTex.filterMode = FilterMode.Point;
        underWaterTex.wrapMode = TextureWrapMode.Clamp;
        underWaterCamera.aspect = sourceCamera.aspect;
        underWaterCamera.targetTexture = underWaterTex;
        currentAspect = sourceCamera.aspect;
    }
}
  • これを先に作成したCameraにアタッチします
  • Hierarchyウィンドウで Ocean をクリックして選択して、LayerをWaterにします

これで水面下を描画するカメラと、水面のレイヤー設定ができました。スクリプトでは、Main Cameraの設定をCopyFromでコピーしたり、Waterレイヤーを描画候補から外すためのcullingMaskの設定などをしています。描画するテクスチャは_WaterDepthTexという名前で、シェーダーにGlobalTextureで渡します。

警告やエラーが出る場合

RenderTexture.Create: Depth|ShadowMap RenderTexture requested without a depth buffer. Changing to a 16 bit depth buffer.というような警告が表示されて、grabがどうこうというエラーが出る場合があります。その時は、81行目のDepthをDefaultに変更してみてください。

// 81:
            RenderTextureFormat.Default,

これで一旦エラーが消えるので、作業を完了させてください。作業が完了したらDepthに戻します。それでとりあえず動きました。このエラーは水系の色々なアセットで発生しているようですが、今回は直す決め手を見つけることはできませんでした。

WaterShadedの改造

仕上げにLowPoly WaterのWaterShadedシェーダーに手を加えます。やることは以下の通りです。

  1. 水面下の距離を_WaterDepthTexから取得するようにします
  2. Render Queueを、不透明でFadeやUnlitより前に描画するように変更します(AlphaTest-1)
  3. 水面描画時にZWriteをOnにします。これをしないと水面が描画されません
  4. Shadow Casterパスを追加します。これでDOFを有効にします

ProjetウィンドウのAssets > LowPolyWater_Pack > Shaders フォルダーを開いて、WaterShadedシェーダーをダブルクリックして開きます。以下に従って修正してください。

  • 26行目付近の_CameraDepthTexture_WaterDepthTexに書き換えます
  • 159行目のhalf depth=から始まる行にある_CameraDepthTexture_WaterDepthTexに書き換えます
// 159:
            half depth = SAMPLE_DEPTH_TEXTURE_PROJ(_WaterDepthTex, UNITY_PROJ_COORD(i.screenPos));

これでMain CameraのZバッファではなく、こちらで作成した_WaterDepthTexから奥行きを取り出すようになります。カメラのaspectを正しく設定しておけば、正方形のRenderTextureからちゃんとアスペクト比に従って参照してくれます。

次にRender Queueを変更します。

  • 183行目付近のTagsを以下のように修正します
// 183:
    Tags {"RenderType"="Transparent" "Queue"="AlphaTest-1"}

水面描画時のZWriteをOnにします。

// 193:
            ZWrite On

207行目の}の後ろで改行して、以下のShadowCasterのパスを追加します。

// 208:

    Pass{
            Tags { "LightMode" = "ShadowCaster"}
            ZWrite On
            ColorMask 0
    }

以上で完了です。Playすると水面がくっきり表示されるようになります。

水面くっきり!

この時、島の下が真っ白になっている場合は、CameraにアタッチしたスクリプトDepthのところをDefaultに変更したのではないかと思います。元のDepthに戻してみてください。

Unlitが白くなるのを解消する

右のCubeの下半分が真っ白です。これはCubeのZバッファに水面シェーダーが反応して波を描いてしまうからです。水面と同じくこのCubeのレイヤーをWaterにすることでぱっと見では解決です。

Waterで問題を回避

よくよく見るとCubeの場所だけ水面がくっきり見えています。DOFがCubeの距離で反映するからですが、これ以上は別テーマになりそうなので今回はここまでにしておきます。

まとめ

ちょっと見栄えを良くしようと思って入れたDepth of Fieldが思わぬ問題を巻き起こしました。理屈を知ってみると、DOFと透過物との相性の悪さ、特に水面はなかなかに難儀な対応が必要ということが実感できました。これらを対策済みの水や透明系アセットの有難さが分かりました。

この調査を通じて、以下のようなことを知ることができました。

  • Render Queueは2500以下が不透明、それ以降が透明扱いになり、描画ルールが変わる
  • ZバッファはRender Queueが2500以下、ZWrite On、Shadow Casterのパスでハードウェアに書き込まれる
  • 不透明のキューでもBlend設定で半透明の描画はできる
  • ShadowCasterでZバッファを書き込む場合は、他のパスもZWrite Onにしないと描画されなかった(水面)
  • エフェクト用のカメラでRenderTextureに欲しい画像を書き込んで、Shader.SetGlobalTexture()でシェーダーに渡せる
  • カメラのaspectを設定すれば、正方形のRenderTextureからスクリーン座標で色を取り出せる
  • RenderTextureを作成する時に謎の警告とエラーがでたら、とりあえずRenderTextureFormat.Defaultで作成しておく

今回の作業では、公式ドキュメントに加えてLIGHT11さんのブログに大変お世話になりました。助かりました。

Win, Mac, WebGL, Android(Pixel3a)では動作確認しました。Pixel3aだとかなり重い感じでしたが動いてはいました。

memo:シェーダーのRender Typeについて

Render Typeは特殊なエフェクトなどのためにシェーダーを置き換えたい時に、置き換える候補を指定するのに使うとのこと。

docs.unity3d.com

Zバッファのみとか不透明なQueueで半透明にしてたりとかイレギュラーなことをしていて何を設定するのが正解か分からなかったので、今回はなんとなくで設定しています。何かのエフェクトが正しく描画されない場合はこの辺りが原因かも知れません。

参考/関連URL

Unity Adsの初期化がネット未接続時に失敗した時の対策

デジゲー博で展示する際に、Voxelorer BirdのUndoが回復されない症状が発生しました。会場では自動的にネットに接続されないため、広告やUndo回復を取得するためのサーバーアクセスに失敗していたのが原因と気付いて手元のスマホテザリングしてみましたが、広告を見れるようにするにはアプリの再起動が必要でした。ネットに接続すればアプリを再起動しなくても広告を見れるようにする方法を調査したところ、現行のUnity Adsの動作で分かりづらいところが見つかったのでまとめておきます。

  • 2021/12/27 「さらなる不具合」を追記

確認した環境

  • Unity2020.3.9f1
  • Unity Ads 3.7.5と4.0.0

Unity Adsの初期化失敗時の仕様と問題点

Unity Adsを利用する際には、Initialize()を一度呼び出しておきます。呼び出し時にインターネットに接続されていなかった場合、その場では初期化は失敗しますが、ネットに接続されたら自動的に初期化が実行される仕様になっています。

この仕様はUnity2020.3.9f1のエディター上では動かないようで、初期化に失敗するとアプリを再起動する必要がありました。念のため、AndroidiOSで試したところ、実機ではちゃんと仕様通りに再初期化することが分かりました。

Unity Ads 3.7.5ではもう一つ問題があって、初期化が失敗した後に再初期化が成功した際にOnInitializationComplete()が呼ばれません。そのため、このコールバックを受け取る前提で作成していたVoxelore Birdはアプリの再起動が必要になったのでした。4.0.0では修正されています。

広告の初期化はLoad()を使う

Unity Ads3.x.xでは、IsReady()で広告を表示する準備できたかどうかを確認できたのですが、4.0.0ではこのメソッドが廃止されてしまいました。Load()を手動で呼び出して、コールバックで読み込み成功を確認することになったようです。

docs.unity.com

さらなる不具合

4.0.0では、広告の初期化時にネットに接続できない状態でアプリを一時停止させてしまうと、アプリの再開後にネットに接続しても初期化は実行されず、Inittialize()を呼び出してもOnInitializationFailed()が呼ばれて失敗するようです。

また、広告の読み込み成功後にネットから切断してShow()を呼ぶと、OnUnityAdsShowFailure()が呼ばれて失敗します。

まとめ

まとめると以下のような感じです。

  • Advertisement.Initialize()は起動時に一度呼び出せばよい
  • UnityAds3.7.5では、OnInitializationComplete()が呼ばれない可能性があるので、IsReady()で完了を確認してからShow()を実行
  • UnityAds4.0.0では、Load()で必要な広告種類を手動で読み込み、OnUnityAdsAdLoaded()が呼ばれたことを確認してからShow()を実行

UnityAdsのマニュアルの手順に従って実装しておけば問題は回避できるということですね。Unityエディター上では再初期化がされない可能性があるので、正常に動いていないようでも実機で試すのをお忘れなく。

追記

マニュアルは勿論読んでたはずなのでおかしい・・・とこの記事を書きながら思っていたのですが、最後にマニュアルの更新日を見て納得しました。更新されたのはこの記事を書いた前日の12/17でした^^; Unity Adsの調査をしたのは数日前なので、その頃にはないドキュメントだったのですね。

参考URL

ゆるく使うUnityTest

Qiita Advent Calender 2021 Unity カレンダー1の8日目の記事です。

前の日は @neusstudio さんの 【Unity】Vivox でボイスチャットを始めよう! - Qiita です!

次の日は @nkjzm さんの【Unity】テスト対象のプレハブを名前で検索するUtilityメソッド - Qiita です!

この記事は、Unityに標準で用意されているテストフレームワークを使ったことがない人や、使ってみたけど今一つ使いどころが分からなかった人向けに、既存のプロジェクトに手軽にテストを導入する例をご紹介します。

f:id:am1tanaka:20211203152122p:plain

目次

ブログの動作環境

  • Unity 2020.3.9f1
  • Creator Kit - Puzzle 1.0

Unity Test Frameworkとは

Unityには、.NETプラットフォーム向けのテストフレームワークである NUnit を元にした Unity Test Framework がデフォルトで用意されています(以降、UnityTestと書きます)。以下、公式マニュアルです。

nunit.org

docs.unity3d.com

ソフトウェアのテストについてはあちこちで述べられているので詳細は割愛しますが、関数やクラス、システムが予想通りに動くかどうかを自動的に確認するためのものです。テストのために引数と結果の組み合わせを検討することで実装内容が明確化できることや、手動でテストする手間の削減、実装後のリファクタリングによるエンバグの発見など、テストの導入には多くのメリットがあります。プログラミングにある程度慣れてきた段階で、軽くでもよいので一度試してみると発見があって面白いと思います。

ゆるく使うとは

テストというとTDD(テスト・ドリブン・デベロップメント)などを語りたくなりますが、既存のプロジェクトでも導入するメリットはあります。このブログでは、Unity Hub2.4.5の「使い方を学ぶ」にあるCreator Kit: Puzzleに用意されているサンプルステージのレベル1を自動操作して、星3つを獲得したかを確認するテストを作ってみます。すぐに享受できるメリットからはじめて、徐々にテストへの理解を深めていくのもよいだろうと思います。

Creator Kit: Puzzleをテストする

プロジェクトの読み込みからテストの作成までの手順です。

対象プロジェクトを開く

  • Unity Hubを起動して、使い方を学ぶからCreator Kit: Puzzleを選択します

f:id:am1tanaka:20211203113922p:plain
チュートリアルを開く

  • はじめての時は、プロジェクトをダウンロード をクリックします
  • ダウンロードが完了したら、 プロジェクトを開く をクリックします
  • Projectウィンドウから Creator Kit - Puzzle > Scenes > ExampleScenes を開いて Level01 シーンをダブルクリックすると、以下のシーンが開きます

f:id:am1tanaka:20211203152122p:plain
Level01

Playして遊んでみてください。スペースキーで仕掛けを動かして、ゴールに玉を転がしたらクリアです。

テスト内容

作成するテストは以下のような流れにします。

  • Level01を読み込む
  • 開始時に星の獲得数を0に設定
  • 適当な秒数が経過するまで、ゴール待機のループ
    • ゴールしたら星が3つ獲得できたか確認して、ループ終了
    • ボールが一定の位置を通過したら仕掛けを作動
  • ループが終了してゴールしていなければ失敗

テストスクリプトの作成

UnityTestで最初にやることは、テスト用のフォルダーを作成することです。

  • ProjectウィンドウでTestsフォルダーを作成したいフォルダー(Assetsなど)を右クリックして、Create > Testing > Tests Assembly Folder を選択します。フォルダー名はTestsのままでよいかと思います

f:id:am1tanaka:20211203121503p:plain
Testフォルダーの作成

この手順はプロジェクトで1回だけでOKです。あとは必要に応じてテストスクリプトを作成します。

  • 作成した Tests フォルダーを右クリックして、 Create > Testing > C# Test Script を選択して、Level01Testsなどの名前でスクリプトを作成します

f:id:am1tanaka:20211203121725p:plain
テストスクリプトの作成

作成したスクリプトから不要な行を削除したりして整理します。単体テスト用のメソッドの前には[Test]、コルーチンで複合的なテストを行うメソッドには[UnityTest]を書きます。今回は単体テストはしないので[Test]は不要です。コメントも消して以下のようにすっきりさせておきます。

using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class Level01Tests
{
    [UnityTest]
    public IEnumerator Level01TestsWithEnumeratorPasses()
    {
        yield return null;
    }
}

以上で上書き保存します。

テストの実行

テストコードは何も書いてませんがテストを実行してみましょう。Unityに切り替えて、以下を操作してください。

  • Windowメニューから General > Test Runner を選んで、テストランナーウィンドウを起動します

f:id:am1tanaka:20211203125825p:plain
テストランナーの起動

  • PlayMode をクリックします

f:id:am1tanaka:20211203130000p:plain
PlayModeに切り替え

  • 先ほど作成したテスト Level01TestsWithEnumeratorPasses をクリックして選択したら、Run Selected をクリックしてテストを実行します

f:id:am1tanaka:20211203130100p:plain
特定のテストを実行

以上でテストランナーが起動します。まだ何もテストコードを書いていないので成功して終了します。

f:id:am1tanaka:20211203130232p:plain
起動成功

テストのためのAssembly Definitionファイルの作成

UnityTestの面倒ポイントの一つが、テストスクリプトからプロジェクトのスクリプトを参照するための Assembly Definitionファイルを作る必要があることです。ある程度の段階までは触らない機能なので、知らない場合はとにかく以下の操作をして必要なファイルを作成してください。

  • Assets/Creator Kit - Puzzle Scripts フォルダーを右クックして、Create > Assembly Definition を選択します

f:id:am1tanaka:20211203134445p:plain
Assembly Definitionの作成

  • 作成されたアセットの名前をCKPuzzle などにします
  • Inspectorウィンドウの Assembly Definition References欄の List is Empty の右下の + をクリックします
  • 追加される欄の None の右の二重丸をクリックして、Unity TextMeshProを選択します
  • 同様に、com.unity.cinemachine と Unity.Postprocessing.Runtime を追加します
  • 下の方の Apply ボタンを押します

スクリプトから参照しているものが他にもある場合は、都度、同様の操作で追加する必要があります。

  • 先ほど作成した Tests フォルダー内の Testsファイル をクリックして選択します
  • Inspectorウィンドウの Assembly Definition References欄に、先ほどと同様の手順で CKPuzzle への参照を追加します
  • 下の方の Apply ボタンを押します

以上でテストランナーからプロジェクトのスクリプトを参照できるようになります。

シーンの切り替えとタイムアウト

テストコードの作成開始です。まずは「Level01を読み込んで」「指定秒数待って」「終了時に指定秒数の経過状態をテスト」してみましょう。

先ほどのLevel01Testsスクリプトを以下のようにします。

  • 冒頭に以下のusingを追加します
// 6:
using UnityEngine.SceneManagement;
  • テストメソッドを以下のように実装します
// 10:
    [UnityTest]
    public IEnumerator Level01TestsWithEnumeratorPasses()
    {
        float timeOut = 10;

        SceneManager.LoadScene("Level01");
        yield return null;
        var timing = GameObject.FindObjectOfType<TimingRecording>();
        while (timing.timer < timeOut)
        {
            yield return null;
        }

        Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか");
    }

以上できたら上書き保存してテストランナーを実行してください。操作せずに放っておくと、10秒経過したらテストが失敗して実行が停止します。

テストを開始するとInitTestScene?????という感じの名前のテスト用のシーンが起動します。このシーン上にテストに必要なオブジェクトをInstantiateで配置する想定なのですが、今回のようにステージが必要なテストをコードで用意するのは面倒です。15行目のようにLoadScene()でシーンを読み込めば手軽にテストを始められます。

16行目:シーンを読み込んだら yield return null; で1フレーム待って、シーンを初期化させてます。

17行目:経過時間はTimingRecordingスクリプトtimerで確認できます。この辺がpublicなのは助かります(アクセサなどで読み出し専用にしてたらなお良し)。必要なインスタンスはテストコードなので多少効率が悪くても問題ないだろうということで FindObjectOfType<>() で強引に取得しました。

18行目:timeOutに設定した秒数が経過するまで待つwhile()には、後ほどパドル操作とクリアチェックのコードを追加します。

23行目:最後にAssertで経過秒数がtimeOutの時間以内であることを確認します。今は時間が経過するまでwhileループを抜けないので必ずテストは失敗します。

f:id:am1tanaka:20211203140921p:plain
最初のテスト失敗

状態をテストするのはAssert.That()というNUnitが提供するstaticメソッドを使います。最初に検証したいデータが入っている変数、2番目に予想する結果、3番目に失敗時に表示するコメントを書きます。2番目の引数には色々なものが用意されています。詳しくはこの辺りこの辺りを参照してください。

仕掛けを動かす

テストから仕掛けを動かせるようにするために、元のコードに少し手を加える必要があります。Projectウィンドウから Assets > Creator Kit - Puzzle > Scripts > InteractivePuzzlePieces フォルダーを開いて InteractivePuzzlePiece スクリプトをエディターで開きます。24行目あたりに仕掛けを動かすためのコードがありますが、Inputが直書きされているのでテストから操作できません。ちょこっと手を加えます。

  • InteractivePuzzlePieceクラスに以下のstatic変数を追加します
// 22:
    public static bool interactKeyState;
  • FixedUpdate()のif文に以下のように条件を追加します
// 24:
    protected void FixedUpdate ()
    {
        if ((Input.GetKey (interactKey) || interactKeyState) && m_IsControlable)
        {
            ApplyActiveState ();
        }
        else
        {
            ApplyInactiveState ();
        }
    }

これでBaseInteractivePuzzlePiece.interactKeyStatetrueを代入すれば仕掛けが動き、falseなら解除します。

本来は、入力を管理するクラスを作成して入力を取りまとめた方がいいのですが、本論から外れるので今回はお手軽な手法にしました。

テストを以下のように変更します。

// 10:
    [UnityTest]
    public IEnumerator Level01TestsWithEnumeratorPasses()
    {
        float timeOut = 10;
        float activateX = -1.5f;

        SceneManager.LoadScene("Level01");
        yield return null;

        var marble = GameObject.Find("Marble");
        var timing = GameObject.FindObjectOfType<TimingRecording>();
        while (timing.timer < timeOut)
        {
            yield return null;
            if (marble.transform.position.x > activateX)
            {
                BaseInteractivePuzzlePiece.interactKeyState = true;
            }
        }

        Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか");
    }

上書きしてテストを実行したら操作せずに眺めていてください。玉がactivateXを越えたら仕掛けが自動的に動いてクリアします。まだクリア判定をしていないのでテストは終わりません。UnityのPlayボタンを押して手動で停止してください。

クリアと結果の判定

クリアと獲得した星の数の確認を追加します。どちらもSceneCompletionスクリプトで確認できます。必要なパラメーターはpublicで宣言されているのでそのまま利用できます。

以下のようにテストスクリプトにコードを追加します。

// 10:
    [UnityTest]
    public IEnumerator Level01TestsWithEnumeratorPasses()
    {
        float timeOut = 10;
        float activateX = -1.5f;

        SceneManager.LoadScene("Level01");
        yield return null;

        var comp = GameObject.FindObjectOfType<SceneCompletion>();
        comp.sceneReference.earnedStars = 0;

        var marble = GameObject.Find("Marble");
        var timing = GameObject.FindObjectOfType<TimingRecording>();
        while (timing.timer < timeOut)
        {
            yield return null;
            if (marble.transform.position.x > activateX)
            {
                BaseInteractivePuzzlePiece.interactKeyState = true;
            }

            if (comp.panel.activeSelf)
            {
                Assert.That(comp.sceneReference.earnedStars, Is.EqualTo(3), "星3つ");
                break;
            }
        }

        Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか");
    }

追加したのは以下の行です。

  • 19行目付近
        var comp = GameObject.FindObjectOfType<SceneCompletion>();
        comp.sceneReference.earnedStars = 0;
  • 32行目付近
            if (comp.panel.activeSelf)
            {
                Assert.That(comp.sceneReference.earnedStars, Is.EqualTo(3), "星3つ");
                break;
            }

上書き保存をしてテストを実行したら操作せずに見ていてください。クリアしたのち、結果が表示されるタイミングでテストが失敗します。

f:id:am1tanaka:20211203151004p:plain
星が足りなかった

Expected: 3
But was: 0

上記は、獲得した星の数が「3を予想したが結果は0だった」ということで動作結果と一致します。これでテストコード完成です!

仕上げ

テストコードの14行目付近のfloat activateX = -1.5f;の値を変えれば、仕掛けが動く場所を調整できます。星3つ獲得できたらテストが成功するので、良さそうな値を探してみてください。数当てみたいでこれはこれで楽しめると思います。

これは本来の使い方ではありませんが、自動テストで同じ状況を繰り返し再現できることで、バグを探したり、コード変更の影響を確認するのが楽になりそうなことを実感いただければ幸いです。

また、テストコードの15行目付近に以下のコードを追加してみてください。

// 15:
        Time.timeScale = 4;

テストを4倍速で実行できます。これもテストの便利なところです。ただし、あまり速くしすぎると物理演算の誤差で結果が不正確になるので倍率はほどほどに。

得られた知見

今回のようなテストをする場合に重要になるのが以下の2点です。

  • 操作を外部からできるようにする
  • 状態を外部から把握しやすくする

何かを操作するコードに直接Inputを書き込むと今回のようにテストがしにくくなります。オブジェクトを制御するクラスと、入力値を読み取ってアクションに変換するクラスは分けた方がテストがしやすくなります。テストがしやすくなるだけではなく、カットシーンでプレイヤーを自動制御したり、リプレイ機能を付けることも簡単にできるようになるオマケが付いてきます。

ユーザー操作を想定したテストをする場合、人間が無意識にやっている操作できるようになるまで待つというのをテストコードで実装する必要があります。状態を外部から把握しやすくすることで、操作したい状況になるまでの待機が簡単に実装できるようになります。このことは、例えば会話中はプレイヤーの操作を停止させたい、とか、メニューを表示するアニメ中は操作を停止したい、というような仕組み作りに役立ちます。最初からこのような設計で作っておけばゲーム中の面倒な処理が簡単に実装できます。

テストを前提にしたシステムを考えることと、設計について学ぶことは近い関係にあります。ゆるい使い方に慣れてきたら、改めてテストや設計について学んでみてください。

実用例

現在開発中のVoxelorer BirdでもUnityTestを利用しています。

f:id:am1tanaka:20211206232753p:plain
VoxelorerBirdのテスト一覧

個人制作のそれほど大きなプロジェクトではないので必要と感じた以下のようなもののみ用意しています。

  • 保存データの確認
    • テスト用のファイルにすることで、自由に保存状態を変えてテストできます
  • 発話やメニュー、操作の排他処理確認
    • 確認のための手順が多いので、テストで自動操作させることで大幅に省力化できました
  • 起動からステージクリア、ステージ選択の流れの確認
    • 機能追加や変更をすると不測の不具合が出る可能性があるので、時々、ゲームを一通り操作してみるのが肝要です。手動でやるのは億劫なので、これこそ自動テストの出番です
  • Undoやシナリオキューの開発
    • 動きがややこしい処理はTDDの出番です。予想外の不具合がいくつも見つかったので大いに助かりました

f:id:am1tanaka:20211207004145g:plain
100回クリアテスト

こんな感じで普通にアプリを起動してステージを読み込んで、じゃかじゃかボタンやプレイヤーの操作をテストランナーに実行させて不具合がないかも確認できます。これで見つけた予想外の不具合が結構あるのでテストの恩恵を実感しています。

最後に

UnityTestは最初から用意されているので、Assembly Definitionファイルの作成さえなんとかなれば導入ハードルはそれほど高くありません。今回紹介したPlayモードのテストは、コルーチンに馴染んでいれば感覚的に利用できると思います。

記事の中で入力のコードを変更しましたが、「テストをしやすいコードにするにはどうしたらよいか」を考えるのは、良い設計への道しるべです。テストの雰囲気が掴めたら、本来のテスト手法や手順も学んでみてください。とっかかりとして、新しい挑戦をしたい時に見る本 Vol3 - まんてらスタジオ - BOOTHの第6章、murnanaさん執筆の「Unityとテスト駆動開発で作る〇×ゲーム」がオススメです。

booth.pm

以上、Qiita Advent Calender 2021 Unity カレンダー1の8日目の記事でした。

前の日は @neusstudio さんの 【Unity】Vivox でボイスチャットを始めよう! - Qiita です!

次の日は @nkjzm さんの【Unity】テスト対象のプレハブを名前で検索するUtilityメソッド - Qiita です!

参考URL