20191205のUnityに関する記事は13件です。

Unityでピンボールのフリッパーを作る

はじめに

この記事は SLP KBIT Advent Calendar 2019 の21日目の記事です。
SLP KBIT Advent Calendar 2019

概要

ピンボールが作りたくなったので、Unityで実装していきたいと思いました。
中でも今回はピンボールのボールをはじく部分をHingeJointを用いてつくっていきます。

実行環境

Unity version:2019.2.9f1

参考URL

スクリプトリファレンス
Hinge Jointでシーソーを作る
【Unity】 重力を変更する

事前準備

scene上に3Dオブジェクトを置きます。

名前 3D Object
Floor Cube
Ball Sphere
AxisL, R Cylinder
FlipperL, R Cube

このように配置しました。
image1.png

HingeJoint

ヒンジとはドアの接合部に使われているような回転の支点となる部分の事だそうです。
UnityではHingeJointがついている物体をヒンジで接合されているように動かすことができるようです。
今回はHingeJointの中でもSpringという変数をつかってばねの力でボールを弾こうと思います。

使ってみます

まずHingeJointComponentを軸になるオブジェクトに加えます。
軸には重力がかかってほしくないのでUseGravityのチェックを外し、
物理の影響を受けてほしくないのでIs Kinematicのチェックをつけます。
ここではAxisRにAdd Componentしています。
スクリーンショット (8).png

HingeJointは2つのRigidbody(剛体)をつなげるので動かす部分にRigidbodyを加えます。
ここではFlipperRにAdd Componentしています
スクリーンショット (7).png

2つのRigidbodyをHingeJointで繋げるために、
AxisRのHingeJoint内のConenected BodyにFlipperRをドラッグアンドドロップします。

これをAxisLとFlipperLにも行います。

この後AxisRのHingeJointのEdit Joint Angular LimitsでHingeの向きと方向を編集します。
Axisの値で調整します。
矢印を軸として、円に沿って力が加わるので上向きにします。(反時計回りが正)

変更前
スクリーンショット (9).png
変更後
スクリーンショット (10).png
この時点でAxisRのHingeJointの
UseSpringにチェックを入れ、Springに40000、Damperに0、TargeetPositionを-60にすると
スクリーンショット (11).png
このようになりました。(AxisLも変更済み)

この時にフリッパーが回転しすぎたりしないようにAxisRのHingeJoint内のUse Limitsにチェックを入れ、
Minに-60、Maxに0を入れます。(AxisLも同様)

この後、左クリックで左フリッパー、右クリックで右フリッパーを動かしてみます。

コード

Flipper.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Flipper : MonoBehaviour
{
    // Inspecterで値を変更する
    public float spring = 40000;
    public float openAngle = 60; // 開く角度
    public float closeAngle = 0; // 閉じる角度

    // Hinge Joint
    private HingeJoint hjL; // AxisL
    private HingeJoint hjR; // AxisR

    // JointSpring
    private JointSpring jL; // AxisL
    private JointSpring jR; // AxisR

    void Start()
    {
        // AxisLとAxisRを探す
        GameObject flipperL = GameObject.Find("AxisL");
        GameObject flipperR = GameObject.Find("AxisR");

        // HingeJointを受け取る
        hjL = flipperL.GetComponent<HingeJoint>();
        hjR = flipperR.GetComponent<HingeJoint>();

        // Springを受け取る
        jL = hjL.spring;
        jR = hjR.spring;
    }

    void Update()
    {
        // 左クリックを押す
        if (Input.GetMouseButtonDown(0))
        {
            jL.spring = spring;
            jL.targetPosition = openAngle;
            hjL.spring = jL;

        }
        // 左クリックを離す
        if (Input.GetMouseButtonUp(0))
        {
            jL.spring = spring;
            jL.targetPosition = closeAngle;
            hjL.spring = jL;
        }
        // 右クリックを押す
        if (Input.GetMouseButtonDown(1))
        {
            jR.spring = spring;
            jR.targetPosition = -openAngle;
            hjR.spring = jR;
        }
        // 右クリックを離す
        if (Input.GetMouseButtonUp(1))
        {
            jR.spring = spring;
            jR.targetPosition = closeAngle;
            hjR.spring = jR;
        }
    }   
}

Update関数内でボタンを検知しています。
左クリックをすると40000の力で60度左フリッパーが回転、
右クリックをすると40000の力で-60度右クリックが回転します。

実行結果

Edit→Project Settings→PhysicsでGravityの方向を変更します。
フリッパーが重力の影響を受けないようにFlipperL、FlipperRのUseGravityのチェックを外しました。
重力を反映させるためBallにRigidbodyをつけます。
実行するとこんな感じになりました。
gifanime-compressor.gif

カクついていますが何とか動いています。

終わりに

結構ちゃんとフリッパーしてくれていると感じました。
ピンボールはフリッパーだけではできないので他の部分も実装してみたいと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「MonKey - Productivity Commands」でパラメータを渡す

PONOS Advent Calendar 2019の6日目の記事です。

昨日は私の「MonKey - Productivity Commands」のカスタムコマンドでさらにUnity上の作業を効率化するでした。

はじめに

assetstore.png
MonKey - Productivity Commands - Asset Store

本記事はPONOS Advent Calendar 2019の以下の記事の続編となります。

今回はコマンドにパラメータを渡して実行する方法について紹介します。

なお、

  • Unity 2019.2.12f1
  • MacOS 10.14.6

の環境で動作確認しています。

コマンドのパラメータを指定する

コマンドの実行時にパラメータを追加するためにはMonKey.Command属性の付与されたstaticメソッドの引数に対してMonKey.CommandParameter属性を付与します。
MonKey.CommandParameter属性の第一引数にはパラメータ表示時の説明文を設定してください。

using MonKey;

public static class SampleCommands
{
    [Command("Sample Parameter Command",
        QuickName = "SPC",
        Help = "好きな数字を選んでコンソールへ出力する。"
    )]
    public static void SampleParameterCommand(
        [CommandParameter("好きな数字を選んでください。")] // 「value」をコマンドのパラメータ化する。
        int value
    )
    {
        Debug.Log($"選んだ数字は\"{value}\"です。");
    }
}

このコマンドをコマンドパレットで開くと以下のような表示になります。
パラメータが設定されたコマンドは、コマンド名の左側に吹き出しアイコンが表示されており、パラメータの有無を判別できます。
ParameterCommand_01.png
このコマンドを選択すると、以下のようなパラメータ入力画面が表示されます。
ParameterCommand_02.png
任意のパラメータ(今回はint型のパラメータなので整数値)を入力して実行します。
ParameterCommand_03.png
コマンドに渡されたパラメータの値を使用して、処理が実行されました。

利用可能なコマンドパラメータの型

MonKeyがデフォルトでサポートしているパラメータの型は以下の通りです。
これらの型の引数はMonKey.CommandParameter属性を付与するだけでコマンドパラメータ化が可能です。

  • String
  • Int
  • Float
  • Double
  • Byte
  • Bool
  • Char
  • Long
  • Short
  • Enum
  • Vector2
  • Vector3
  • Vector4
  • Quaternion
  • Color
  • Object
  • Component
  • GameObject
  • LayerMask
  • Scene
  • Type
  • Arrays

なお、MonKey.Editor.Internal.CommandParameterInterpreteを継承したクラスを用意することで、ここにある以外の型もパラメータとして利用することが出来ます。

使用例:Rect型のコマンドパラメータを受け取り、コンソールに矩形情報を出力する

今回は使用例として、デフォルトで定義されていないRect型のコマンドパラメータに対応してみましょう。

Rect型を入力するルールを決める

MonKeyのコマンド実行に入力されるパラメータは文字列です。
そのままではRect型のコマンドパラメータとして渡すことはできないので、入力された文字列を適切にRect型へパースする処理が必要となります。

Rect型のオブジェクトを作成するためには「x」「y」「width」「height」の4個の数値が必要ですので、今回はこれらの4個の数値をカンマ(,)区切りで入力してもらうルールにします。

このルールに従うとコマンドパラメータに
1, 2, 100, 200
という文字列が入力された場合、
x = 1, y = 2, width = 100, height = 200
というRectオブジェクトが作成されます。

Rect型のコマンドパラメータを持つコマンドを作成する

次にRect型をパラメータに指定したコマンドを用意します。

[Command("Rect Parameter Command",
    QuickName = "RPC",
    Help = "矩形情報を出力する。"
)]
public static void RectParameterCommand(
    [CommandParameter]
        Rect value
)
{
    Debug.Log($"入力された矩形の情報:{value.ToString()}");
}

入力された矩形情報をコンソール出力するだけの、シンプルなコマンドです。
さて、試しにこのコマンドを実行してみますが…
ParameterCommand_04.png
パラメータの入力後に「Error」と表示され、コマンドを実行することができませんでした。
Rect型はデフォルトでサポートされている型ではなく、「文字列を適切にRect型へパースする処理」もまだ実装していないので、Rect型のコマンドパラメータをコマンドへ渡せずにエラーとなってしまっているようです。

また、コマンド内の以下の処理についても、valueがnullで渡されてしまうため、NullReferenceExceptionが発生していました。

Debug.Log($"入力された矩形の情報:{value.ToString()}");

Rect型に対応するためのCommandParameterInterpreterを作成する

さて、それでは「文字列を適切にRect型へパースする処理」を実装するために専用のCommandParameterInterpreterクラスを用意しましょう。

今回はRect型に対応するので、RectInterpreterという名前にします。
スクリプトはEditor以下に設置してください。

// Rectの構造体をコマンドパラメータで使用するためのCommandParameterInterpreter。
class RectInterpreter : MonKey.Editor.Internal.CommandParameterInterpreter
{
    [InitializeOnLoadMethod]
    static void AddInterpreter()
    {
        // CommandParameterInterpreterの定義を追加する。
        AddInterpreter(new RectInterpreter());
    }

    RectInterpreter() : base(typeof(Rect)) // 対応する型を指定する。今回はRect。
    {
    }

    // 入力された文字列を、コマンドパラメータの型にパースする。
    public override bool TryParse(string text, out object obj, System.Type subType = null)
    {
        // カンマ区切りでx, y, width, heightの4個の数値を指定させる。
        var parts = text.Split(',');
        if (parts.Length == 4)
        {
            // それぞれの数値をfloatへ変換し、Rectを作成する。
            float[] values = new float[4];
            for (int i = 0; i < 4; ++i)
            {
                float result;
                if (float.TryParse(parts[i], out result))
                {
                    values[i] = result;
                }
            }
            var rect = new Rect(values[0], values[1], values[2], values[3]);
            obj = rect;     // パースした結果はobjに格納する。
            return true;
        }

        obj = null;
        return false;
    }
}

では、もう一度実行してみましょう。
ParameterCommand_05.png
ParameterCommand_06.png
ParameterCommand_07.png
入力した文字列から矩形情報を作成し、コンソールに出力することができました!

おわりに

コマンドパラメータを指定してコマンドを実行することで、柔軟に処理を実行することができます。
MonKeyの優れている点の一つだと思うので、是非活用していきましょう。

明日は@karizumaiさんです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】Unity Collaborateでリアルタイム通信コンテンツの制作フローを効率化

Unity Collaborate

Unity Collaborate は Unity Teams の一部です。Unity Team は、小さなチームがクラウド環境で Unity のプロジェクトを保存、共有、同期することを可能にします。Collaborate を使用すると、場所や役割に関係なくチーム全体がプロジェクトに貢献できます。

引用元:Unity Collaborate

要するに協力しながらの作業が簡単にできちゃうってことですが、今回は少し目的が違います。

今回の目的は1人で2台のPCを使ってリアルタイム通信ゲームの開発を進めることです。

Unity Collaborateを使うまでは、
Googleドライブに実行ファイルをZip形式に圧縮したものをアップし、
もう1台のPC側で解凍&ダウンロードしてやってました。

ただ、そのやり方だと少しの変更を試すだけであっても
そこそこの待ち時間が発生してしまって無駄が多かったです。
一人で楽にリアルタイム通信の検証を行うにはどうすればいいか探していたところ、
下記記事を発見し、Unity Collaborateでの開発を推奨していたので、
試してみたら本当に楽に進められました。

【PUN】アバター生成・同期

ただ、設定が行ったり来たりでややこしかったので、メモを残します。

設定手順

前提として、UnityID(Unityのアカウント)は既に持っていることとします。無料、有料は問いません。
また、今回のやり方だとUnityIDは2つ必要となります。それに伴って、メールアドレスも2つ必要です。

追加したいメンバーに招待を送り、共同で開発する権限を与えるようなイメージです。

1台目のPCでプロジェクト作成

まずは1台目のPCでプロジェクトを作成します。
そして、タブのWindowGenaralServicesを選択します。
Collaborateを選択し、下記画像の黄色マーカーのリンクに飛びます。

GotoDashboard.PNG

Unity Dashboard

ダッシュボードに飛んだら、Usersを選んで次に進みます。

CoUser.PNG

下記画像の箇所をページ内から探してリンクに飛びます。

CoManageState.PNG

UnityID

先ほどのリンク先がUnityIDのマイページになっているはずです。
メンバー&グループに進みます。

CoUnityID.PNG

そこから招待ページに飛びます。
メールアドレスと役割(Userでいいと思います)を選択して、招待します。
招待される側にメールが届きますが、特に承認のフローなどは必要ありませんでした。

CoMenaberAdd.PNG

再度、Unity Dashboardへ

ここでもう一度Unity DashboardUsersに戻ります。
そして、先ほど招待したメンバーを下記画像の箇所から選択し、追加します。

CoUserAdd.PNG

2台目のPCでプロジェクト作成

次に2台目のPCでプロジェクトを作成します。
こちらは先ほどのUnityIDとは別のものでログインして作成します。
そして、先程と同様にServicesを選択し下記画面を呼び出します。

collab.PNG

※ここで上記画面が出ない場合は、SETTINGSを選択し、Unlink ProjectすればOKです。
CoUnLink.PNG

あとは招待されたOrganizationを選択し、共同編集したいプロジェクトを選択すれば設定完了です。

collab2.PNG

途中から導入した場合

今回、Unity Collaborateを知るのが遅かったので
片方のPCではプロジェクトはある程度作り終えた状態で、もう片方には全く何もない状態でした。

プロジェクトの大きさによってはかなり時間がかかりますが、
GitのCloneのようなことが可能です。
まっさらなプロジェクト側でCollabを選択し、左下のView Historyを選択します

CoViewHistory.png

Restoreを押すと「ローカルを置き換えますよ?」という警告が出て、承認するとデータを引っ張てきてくれます。

CoRestore.PNG

変更点の共有

片方で変更した内容をもう片方に反映させるために、Publishという作業を行います。
GitのPush的なやつです。

CoPublish.png

アップロードが完了すると、もう片方のプロジェクトでは矢印が変更されて、Updateが可能になります。

Cloud.JPG

実際に使ってみた感想

情報が無くて大変でしたが、知ってるかどうかの差なので
一度準備のフローを知った以上は二回目以降は超便利システムだと思いました。

今回の用途だと片方のプロジェクトしか変更しないので、
コンフリクトなどの問題は心配する必要はなさそうです。

もっと良い効率化があれば教えてください。

参考リンク

初心者向け!Unity Collaborateを使ってみる

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Adressableのおいしいとこだけ使いたい?Scriptable Build Piplineがあるじゃない

この記事ではAssetBundleの基本的な知識は説明しません。
すでにAssetBundleを利用したことがある人向けの記事になっています。

新機能 Adressable Asset System

アセットをビルドから切り出せるAssetBundleをより簡単に扱えるようになるシステムが登場しました。
https://blogs.unity3d.com/jp/2019/07/15/addressable-asset-system/

  • 任意の文字列でアセットを簡単にAssetBundleからLoadできる
  • AssetBundleの依存関係解決の処理も用意してある
  • AssetBunldeのまとめかたを調整できるEditor
  • プロファイラで読み込み状況を確認できる

といった便利な機能が用意されているリソース管理のオールインワンといってもいいパッケージです。
addressable.png
今からAssetBundleを導入する必要があるひとは、まずは考えるべき選択肢かとおもいますが

  • できて間もないから不安で使いたくない
  • すでにある自作ライブラリで運用してる
  • 自作ライブラリのほうが便利だ
  • Adressableの仕組みが合わない

などの理由でAdressable Asset Systemを使わないという選択肢を取っている人もいると思います。

しかし、Adressable Asset Systemではできるけど、古いUnityではできなかった機能が中にはいくつか存在します。個人的に一番欲しかった機能はBuiltInShaderを分離する機能です。

なぜBuiltInShaderを分離するのか

https://docs.unity3d.com/ja/2017.4/ScriptReference/BuildPipeline.BuildAssetBundles.html

Unity5から導入されたこのメソッドでは、事前にAssetBundle名を割り当てたアセットを収集してAssetBunldeを簡単に作ってくれるのですが、あまり細かな制御はできませんでした。

手軽にできる反面、よく調べないと気が付かない罠に気が付かずにパフォーマンスを悪化させていることがあります。

複数のAssetBundleに同じアセットが重複して保存される

特に考えずにビルドしたときに陥る罠に、いくつかのAssetBundleに同じデータが重複して入ってしまうという問題があります。
image.png (↑ New Render Textureが4つのmaterialのAssetBundleそれぞれに含まれてしまっている様子)

うっかりテクスチャやマテリアルやShaderなど、いくつかのPrefabなどで使いまわすアセットにAssetBundle名を指定し忘れるとこのようなことになります。AssetBundleは依存関係を構築できるので、共通で使うアセットにもちゃんとAssetBundle名を設定してあげれば基本的には回避できます。

ただし、このときどうしてもAssetBundle名を設定できない困ったアセットがあります。それがUnityに組み込まれているBuiltInAssetです。Project Viewに表示されないのでAssetBundle名を指定できず、それを参照しているアセットがあるAssetBundleにはすべて重複して別々のアセットとして保存されてしまいます。

BuiltInShaderはモバイル版の天敵

そんなBuiltInAssetの中でもさらにタチが悪いのがShaderの存在です。Shaderは実行時に初めて使うときにコンパイルされるのですが、上記の問題で重複したShaderは別ものとして扱われるので、毎回コンパイルされてしまいます。特にUI-DefaultやStanderedShaderなどよく使われるShaderは地獄のような状況になる場合があります。(注1)

image.png

この問題を回避するために、わざわざDefaultのUIのShaderとマテリアルを残らず差し替えたり、徹底的にBuiltInShaderを使わないなどの辛い対応をした人もいるかと思います。(注2)

前置きがながくなりましたがこの問題を簡単に解決できるのがScriptable Build Piplineになります。

注1) そもそもUnityも最初は数個のパッチファイルを作る程度に考えていて、AssetBundleを滅茶苦茶細かく分けて作ることを想定していなかったのかもしれない。大量のアセットを頻繁にアップデートで細やかに更新するようなソシャゲの運用は想定していなかった可能性がある
注2) もしかしたらスマートな方法がもっとあったかもしれない

Scriptable Build Pipline (SBP) を使う

正直Scriptable Build PiplineはAddressable Asset Systemを作る上で既存のAssetBundleビルドシステムの限界に気が付いて作られた福産物なのかと思えるほど、Addressableのビルドシステムのために必要な機能が盛り込まれています。
(Assetのロードする際の名前を好きに変えたりできるようになってる)

http://tsubakit1.hateblo.jp/entry/2018/12/03/202044

細かい説明はテラシュールブログがとても分かりやすいのですが、簡単に説明するならSBPを使うことで、AssetBundleのビルドの処理にTaskを自由に作ってカスタマイズできるようになります。

DefaultBuildTasks.Preset.AssetBundleBuiltInShaderExtraction

そしてforumの公式の人の回答を見るとすでに待ち望んでいた機能が用意されいるのがわかります。SBPでビルドする処理のサンプルが以下です。

https://forum.unity.com/threads/standard-shader-duplicated-in-asset-bundle-build.593248/

public static class BuildAssetBundlesExample
{
   public static bool BuildAssetBundles(string outputPath, bool useChunkBasedCompression, BuildTarget buildTarget, BuildTargetGroup buildGroup)
   {
       var buildContent = new BundleBuildContent(ContentBuildInterface.GenerateAssetBundleBuilds());
       var buildParams = new BundleBuildParameters(buildTarget, buildGroup, outputPath);

       if (useChunkBasedCompression)
           buildParams.BundleCompression = BuildCompression.DefaultLZ4;

       IBundleBuildResults results;
        var tasks = DefaultBuildTasks.Create(DefaultBuildTasks.Preset.AssetBundleBuiltInShaderExtraction);
       ReturnCode exitCode = ContentPipeline.BuildAssetBundles(buildParams, buildContent, out results, tasks);
       return exitCode == ReturnCode.Success;
   }
}

DefaultBuildTasksというPresetを提供する機能の中に AssetBundleBuiltInShaderExtraction なるカスタマイズされたTaskが用意されているのです!

var tasks = DefaultBuildTasks.Create(DefaultBuildTasks.Preset.AssetBundleBuiltInShaderExtraction);

特に重要なのは CreateBuiltInShadersBundle.csに記載されている処理なのですが、BuiltInAssetを探して別のAssetBundleにまとめる処理のサンプルコードが書かれています。

実際にこの方法でビルドするとUnityBuiltInShaders.bundleが生成され、BuiltInShaderの重複ロードが発生しなくなります。

AssetBundleBrawserで確認すると、AssetBundleからBuiltInShaderの参照が切り離されているのが確認できます。
image.png

このSBPはAdressableを使っていなくても使えるPackageなので、既存のAssetBundleを管理するライブラリがある人でも導入できる可能性があります。

しかしAssetBundleManifestに対応せず

ただし、悲しいことに、DefaultBuildTasksはAssetBundleManifestに対応していませんでした…

(一部抜粋)DefaultBuildTasks.cs
buildTasks.Add(new PostWritingCallback());

// Generate manifest files
// TODO: IMPL manifest generation

return buildTasks;

Manifestを使わないAdressableのために用意した機能なので、Manifestを作る機能が後回しになるのは仕方ないことなのかもしれませんが、Manifestがなければ依存関係のAssetBundleを探せなくなってしまいます。

IBundleBuildResults からManifestの代わりを自作する

実はManifestをロードしていなくても、依存関係のあるAssetBundleを読み込むことは可能です。Manifestはあくまで依存関係のあるAssetBundleを取得するメソッドを用意しているだけなので、そのリストさえ自分で作成してしまえば代わりにすることができます。

ありがたいことにContentPipeline.BuildAssetBundlesIBundleBuildResultsにビルドの結果が記載されているのでそれを使います。

interface IBundleBuildResults {
    Dictionary<string, BundleDetails> BundleInfos { get; }
}

class BundleDetails {
        public string FileName {get; set;}
        public uint Crc {get; set;}
        public Hash128 Hash {get; set;}
        public string[] Dependencies {get; set;}
}

このresultからBundleInfosをファイルにシリアライズして保存すれば、AssetBundleManifest.GetAllDependenciesなどの基本的なメソッドの代わりを提供するクラスは自作することができます。

AssetNameがファイルパスになっているのを直す

Manifestを自作することで任意のアセットをロードするのに必要なすべてのAssetBundleを読み込むことができました。しかし、以前のUnityのAssetBundleのLoad時にアセットをロードするのに必要な文字列はアセットのファイル名だったのですが、SBPのデフォルトではファイルパスになってしまっています。

これをもとに戻すにはBundleBuildContentAddressesを変更する必要があります。

var buildContent = new BundleBuildContent(ContentBuildInterface.GenerateAssetBundleBuilds());
//GC気になる人は適宜直してね
foreach (var adress in buildContent.Addresses.Keys.ToList())
{
    buildContent.Addresses[adress] = System.IO.Path.GetFileName(buildContent.Addresses[adress]);
}

AddressesのKeyはGUID, Valueがファイルパスになっているので、これをファイル名に書き換えればファイル名でAssetBundle.Loadが利用できるようになります。

ManifestとAssetNameが大きな変更になっていますが、ここまでカスタマイズできるようになれば、既存のAssetBundleのライブラリでもSBPを使えるようになるのではないでしょうか? (注3)

注3) IgnoreTypeTreeChangeが現状未サポートらしいので、それが無理だとキツイかも

まとめ

今回はBuiltInShaderを分離するためだけにSBPを導入してみましたが、DefaultBuildTasksを参考にTaskを自作することでかなりの自由なAssetBundleのビルドパイプラインを構築することができます。

  • 差分ビルドで変更のあったAssetBundleを抽出したい
  • 別のプロジェクトのAssetBundleと組み合わせて運用したい
  • ビルドする前にファイルを差し替えたい
  • ビルド直前に暗号化してAssetBundleに入れたい

などはできるのではないかと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Airtest+Pocoで始めるUnityの自動テスト

はじめに

本記事はQualiArts Advent Calender 2019 6日目の記事です。

皆さんはUnityで開発を行う上で、UIテストの自動化はされてますでしょうか?
運用で開発を進めていくと、既存のUIフローで進行不能になってしまった、仕様と違う挙動になってしまった、表示崩れが発生してしまった、などなど実装によるデグレが発生してしまうというのはよく聞く問題だと思います。
こうした既存のUIフローを回帰的にテストし、問題を検出するというのは大切なことですが、同時に人力でやるにはとても労力のかかることでもあります。
そこで役に立つのがUIテストの自動化です。単純な検証フローであれば、人間の手ではなく機械的にそれらを行うことによって、かかるコストを削減しつつヒューマンエラーを取り除くことができます。

本記事ではそんなUnityでのUIテストの自動化を行う上で有用なAirtestというツール及びフレームワークと、それに付随して効果を発揮するPocoというフレームワークについて説明していきます。

Airtest

34292813.png
Airtestは中国のNetEase社より提供されているアプリケーションのテスト用のツールになります。
ゲームを主にターゲットとして作られており、Android、iOS、Webなど、様々なプラットフォームにおけるアプリケーションに対応しています。
使用するスクリプトの言語はpythonで、特にアプリケーション側で用意など要さずにテストを実行することが可能です。

http://airtest.netease.com/

AirtestはIDEが提供されており、IDE上で端末の接続、テストスクリプトの構築、実行など様々な作業が快適に行えるようになっています。

ide.png

上が実際のIDEの画像です。ダーク調な感じで個人的にはとても使いやすいです。
中央のScript Editorが実際のテストのスクリプトを記述する場所です。デフォルトで必要なセットアップのスクリプトが記述されています。
テストのスクリプトを保存する時は.airという形式のプロジェクトとして保存します。テストの実行はこの.air単位で選択を行います。
テストを実行した際には.airの配下に結果などが出力されます。

環境の準備

Airtestのスクリプトの説明に入る前に、Airtestを使う上でのテスト環境の準備を行いましょう。
iOS、Android、Webなど様々な環境でテストを行うことが可能ですが、本記事ではAndroid実機をUSB接続しての環境構築について説明していきます。

connection.png

接続されているandroid端末は上の画像のConnection Panelという画面で確認することができます。
端末を接続し、refresh ADBというところを押すと、接続した端末が一覧に表示されます。(シリアル番号は消しています。)

panel.png

後はテストを実行する端末に対してconnectというボタンを押すと接続を行うことができます。簡単ですね。
一覧に端末が表示されない、connectボタンが押せないという場合にはUSB接続周りの設定やデバッグ接続の権限などを見直してみてください。

connected.png

接続が完了すると、接続した端末のキャプチャ画面が表示されるようになります。
以上で接続は完了です。

Poco

PocoはAirtestと共に提供されている様々なゲーム開発プラットフォームでのUI周りのテストを記述するためのフレームワークになります。
https://github.com/AirtestProject/Poco

Pocoを使うことで、iOS/Androidのネイティブアプリ、Cocos-2dx、Unityといった様々なプラットフォームでのUIテストをより実装に沿った形で記述することができます。

Unityであればヒエラルキー情報やTextといった指定のUIコンポーネントのオブジェクトを参照することが可能です。
例えば「決定」という文字のボタンを押す、特定のオブジェクトの子にある「Button」というコンポーネントがついているものを長押しする、など、かなり実装目線でのテストスクリプトを記述することができるので大変便利です。

Pocoを使用するには、アプリケーション側でSDKの導入とその対応が必要になります。Unity側の対応については後述しています。

スクリプトの構築

Airtest+Pocoを使ったスクリプトはシンプルで、Python自体に知見がなくとも直感的に記述することが可能です。
基本的に、ユーザーのタップや長押し、UIがあるかどうかの確認といった1アクション単位で記述を行います。

スクリプトの例
__author__ = "udon"

from airtest.core.api import *
from poco.drivers.unity3d import UnityPoco

poco = UnityPoco()

# タップして
touch([0.0, 1.0])
#スワイプして
swipe([0.0, 1.0], [0.0, 2.0])

# キーポードから文字を入力して
keyevent("Test")
# 待機して
sleep(1.0)

# 「ボタン」というオブジェクトをタップ
poco("button").click()

Airtest API

Airtestの提供するAPIは、端末上でのアクションのテストを直感的に書くためのものです。

import_Poco
from airtest.core.api import *

機能を使用するには上のimportをスクリプトに追加します。
AirtestのAPIを使用することで、タップ、スクリーンショット、テキスト入力など、端末上で行いたいアクションをスクリプトで記述することができます。

AirtestAPIの例
# 座標を指定してタップ
touch([0.0, 1.0])

# テキストの入力
text("hoge")

# スクリーンショット
snapshot(msg="hogehoge")

# 待機
sleep(1.0)

スクリプトは直接記述することももちろん可能ですが、IDEのGUIベースでボタンをぽちぽちしながら組み立てることも可能です。

assistant.png

上画像のように、IDEにAirtest AssistantというGUIが用意されており、ここにあるボタンを押すことでスクリプトに処理を加えることができます。
Airtest Assistantは端末を接続することで、タップなど端末のUIに依存したメニューを選択することができます。
そしてAirtestで面白いのが、タッチ操作において画像ベースの処理を直接的にスクリプトに起こすことができることです。

touch.png

例えば上の画像はChromeのロゴと周りのピンクの部分も含めた画像を認識し、その座標をタップするという処理になります。
画像の設定もお手軽で、Airtest Assistantから画像を使用するメニュー(touch、swipe、existsなどUIに関わるもの)を選択し、接続した端末のキャプチャ画面からドラッグ&ドロップで行うことができます。

select.png

画像ベースでタップしたり、指定のUIがあるかどうか知りたいのような簡単なテストであれば、これだけで組めてしまいます。
他にも様々な機能があるので、詳しく機能が知りたいという方は公式のドキュメントを読むことを推奨します。

Airtest APIドキュメント
https://airtest.readthedocs.io/en/latest/all_module/airtest.core.api.html

iOSやandroidなどのプラットフォームごとのAPIも用意されているので、apkのインストールなどのシナリオから組み立てる場合には活用することができます。

Poco API

Pocoは前述の通り、プラットフォームに対応したテストを書くためのフレームワークです。

import_Poco
from poco.drivers.unity3d import UnityPoco
poco = UnityPoco()

上のimportをスクリプトに追加することでPocoの機能を利用することができます。

Pocoで使う基本的なUIProxy
poco("hoge")

Pocoでは上のスクリプトのようにGameObjectの名前を引数としたオブジェクトを多用します。
これをUI Proxyというもので、UI上のオブジェクトをスクリプト上のオブジェクトとして扱えるものです。
UI Proxyには様々な機能が用意してあり、それらを使うことで色んなアクションをそのオブジェクトに対して働きかけることができます。

buttonのタップ
poco("button").click()

例えば上の1行で、「button」という名前のGame Objectを検索し、その位置をタップするという意味のスクリプトになります。

PocoAPIの例
#テキストコンポーネントのオブジェクトの取得
poco("hoge", text="huga")

#オブジェクトのロングタップ(2秒)
poco("hoge").long_click(duration=2)

#オブジェクトが出現するのを待つ
poco("hoge").wait_for_appearance

#オブジェクトが消失するのを待つ
poco("hoge").wait_for_disappearance()

#オブジェクトの存在フラグ(bool)
poco("hoge").exists()

#子オブジェクト
poco("hoge").child("huga")

#子オブジェクトをタップ
poco("hoge").child("huga").click()

上がUI Proxyの機能の一例になります。UI Proxyを使ったアクションは色々用意されており、様々なUIテストのケースに対応することが可能です。
コンポーネントや階層構造など、Unity上の構造に合わせられるため、少しレイアウトやデザインが変更したことによるテストの破綻も防ぐことができます。
Pocoに関しても、詳しく機能が知りたい方は公式のドキュメントを読んでみてください。

Poco APIドキュメント
https://poco.readthedocs.io/en/latest/source/poco.pocofw.html

特定のGame Objectが出てきた後に、ボタンのGame Objectを押したい!特定のGame Objectにぶら下がってるTextを押したい!などUnityライクなテストをやるときに本当に便利なフレームワークになっています。

スクリプトのサンプル

APIの説明だけじゃいまいち具体的なスクリプトのイメージがつかないかと思います。そんな時に役立つのが公式のサンプルです。
公式ではそれぞれのAPIの説明とともに、動画付きで具体的なスクリプトの説明を行っています。
実際にスクリプトを書いてみよう!となった方はこちらを参考にしつつ書くのがベターです。(筆者もかなり参考にしました。)

Airtest APIを利用したサンプル
https://airtest.readthedocs.io/en/latest/README_MORE.html#example

Poco APIを利用したサンプル
https://poco.readthedocs.io/en/latest/source/README.html#tutorials-and-examples

テストする

AirtestやPocoのスクリプトについて説明したところで、どうやってテストを実行するか説明していきます。

Poco SDKの準備

前述にもある通り、Pocoの機能を使うにはアプリケーション側にPocoのSDKを組み込む必要があります。
Poco SDK自体はGithubで公開されているので、そちらからクローンするかダウンロードをしてください。

Poco-SDK
https://github.com/AirtestProject/Poco-SDK

落としてきた中に「Unity3D」というフォルダがあるので、それをUnityのプロジェクトに持ってこればSDKの導入は完了です。
コンパイルエラーが起きてしまう場合には使っていないGUIシステム用スクリプトのフォルダを消してやると解決します。
筆者はuGUI環境だったので、「ngui」「fairygui」の2つのフォルダを削除しました。

次にPoco SDKがアプリケーション側に適応されるようにする必要がありますが、これについてはシンプルでPocoManagerというスクリプトをシーン上の適当なオブジェクトにアタッチすれば大丈夫です。

テストの実行

IDEの再生ボタンを押すと、Script Editorにあるテストスクリプトが実行されます。

test.png

実行中は現在の実行箇所が青くハイライトされ、ログは下のViewerに出力されます。
後はテストが終了するまで、見守れば大丈夫です。

ちなみにAirtestはCLIも用意されており、IDEからではなくCLIで実行することも可能です。
CLIはpythonのライブラリなので、使う際にはpipでインストールしてやってください。

CLIの導入
pip install -U airtest
CLIでテストの実行
airtest run {.airのパス} --device Android:///

テストの実行結果はlogと画像によって出力されます。
1つのコマンドを実行するたびにAirtest側でスクリーンショットをとっており、テストの状況がどうなっていたかを確認することができます。
また、logと画像を元にhtml形式のレポートを出力することも可能です。出力するにはIDE上でCmd+Lを押すと実行されます。

report.png

出力すると、上の画像のようにいい感じに整形された見た目になります。
画像の参照パスが絶対パスになっているため、Jenkinsなど自動化したルーチンでhtmlを出力する際には注意が必要になります。

ちなみにhtmlの出力についてもCLIで実行可能です。

CLIでhtmlの出力
airtest report {.airのパス}

上記のコマンドを叩くことで、同様のhtmlが出力されます。

まとめ

スクリプトの記述から実行まで説明したのでめちゃくちゃ長くなってしまいましたが、要約するとAirtestはUnityのアプリケーションのテストを行う上でかなり便利なツールということです。

画像やレイアウトベースのテストであれば、前述のように直感的なスクリプトを用意するだけで、いい感じのテストを作ることができます。
また、画像やレイアウトベースのテストではレイアウトの変更によってテストのメンテナンスが必要になるという懸念がありますが、Pocoという存在のおかげでヒエラルキーベースのテストを構成しそのリスクを緩和することができます。
前述にもあるようにCLIも用意されているため、Jenkinsなどのインテグレーションツールと組み合わせることでシステム全体を自動化したりできます。

もっと詳細なAirtestの機能が見たい方は、とにかく公式ドキュメントを読んでみてください。
本当に多機能なツールで、ここでは紹介しきれなかった有用なものも多く存在します。
Android以外で活用したい!という方も公式ドキュメントには各プラットフォームの対応が書いてあります。

https://airtest.readthedocs.io/en/latest/index.html

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

迷路ゲームにランキング機能をつけるお話

N高アドカレ16日目の記事ですー
データベース一切わかんない人がオンラインランキングの実装を頑張った話になります。

はじめに

軽く自己紹介

N高の江坂プロクラの高三の受験生です。
大学に無事AOで合格して時間ができたのでアドカレ参加。
UnityとBlenderでゲーム作ったり色々してます。
Webとサーバーサイドはほとんどわかんないです…

ゲームの説明

迷路ゲーム自体は自作キャラがモデリングできたからそれを歩かせてゲームにしたいという思いから作ったもので、シンプルなものです。
スクリーンショット 2019-12-03 13.55.58.png
敵キャラのゴーストを避けながらゴールを目指すゲームです。
このゲームにオンラインランキング(タイムアタックのハイスコア)をつけるときの話です。

スクリーンショット 2019-12-04 15.21.26.png
自信作の自作キャラ

どうやってスコアを保存するか

はじめにどうやってクリアタイムを保存させようかと考えました。
ゲームに記憶させるのか、データをサーバーに保存するか…と色々考えていた時に、こちらのUnity + NCMBでスコアランキングを実装するという記事を見つけました。この記事の通りにやってみることにしました。

記事を参考に作業

NCMB側の設定

Googleでログインしました。

そしてクラスをHighScoreという名前で作成。

Unity側の設定

UnityにNCMBのパッケージを導入。

NCMBSettingsという名前でオブジェクトを作ってNCMBSettingsというスクリプトをアタッチしました。

アプリケーションキーとクライアントキーをNCMBSettingsのインスペクタにコピペ。

ここまでは事前準備です、次にコードを書いていきます。

クリアタイムの保存

データを保存するためのオブジェクト(箱)を作ってその中にクリアタイムを保存します。

NCMBObject HighScore = new NCMBObject("HighScore");

これでデータを入れるオブジェクトを作ります。

HighScore["Score"] = script.totalTime;

HighScoreに値を代入。

私は別の時間制御をしているスクリプトのtotalTimeを参照させるようにしています。

HighScore.SaveAsync();

そしてNCMB側に保存させます。

私が実際に書いたコードの一部を参考に置いておきます。

 void OnTriggerEnter(Collider collision)
    {
        if (collision.gameObject.tag == "Player")
        {
            gameclear.SetActive(true);
            TimerUI.SetActive(false);

            NCMBObject HighScore = new NCMBObject("HighScore");
            HighScore["Score"] = script.totalTime;
            HighScore.SaveAsync();
        }
    }

プレイヤーとゴールの判定オブジェクトが衝突判定を起こした時に、gameclearUIの表示、制限時間UIの非表示と同時にクリア時の時間をデータベースに送っています。

これで、データベースにクリア時の時間が記録されました。
スクリーンショット 2019-12-03 15.03.20.png

クリアタイムの取得

クリアタイムをデータベースから取得して表示させます。

NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>("HighScore");

取得したデータはNCMBQueryに格納されるのでこれを先に作ります。

query.OrderByAscending("Score");
query.Limit = 5;

並び替え&5つだけ取得させる設定

取得設定ができました。

そして実際に取得させようと思い、

query.FindAsync ((List<NCMBObject> objList ,NCMBException e) => {
        if (e == null) {
          objList[0]["Score"] = score;
          objList[0].SaveAsync();
        }
      });

このコードを参考にしてコードを書いたところ、エラーでデータの取得ができませんでした。
Scoreデータを関数に代入する方法がわからず、しばらく試行錯誤しました。

query.FindAsync((List<NCMBObject> objList, NCMBException e) =>{
if (e == null)
{
HighScore_1 = System.Convert.ToSingle(objList[0]["Score"]);
HighScore_2 = System.Convert.ToSingle(objList[1]["Score"]);
HighScore_3 = System.Convert.ToSingle(objList[2]["Score"]);
HighScore_4 = System.Convert.ToSingle(objList[3]["Score"]);
HighScore_5 = System.Convert.ToSingle(objList[4]["Score"]);
}

結果このコードでHighScoreにListのScoreを代入することができました…

どうやらSaveAsyncが邪魔してたみたいでした…

ランキング表示

取得して関数に代入した値をランキングのUIとして表示させます。

参考に私が書いたコード置いときます。

using UnityEngine;
using UnityEngine.UI;
public class Ranking_1 : MonoBehaviour
{
    private Text Ranking1;

    public GameObject Ranking;
    ranking script;

    public GameObject Next;
    Next script2;

    int display;

    float totalTime;
    int minute;
    float seconds;

    void Start()
    {
        script = Ranking.GetComponent<ranking>();//HighScoreを代入した関数を参照させる設定
        script2 = Next.GetComponent<Next>();//HighScoreを表示させる関数を参照させる設定
        Ranking1 = GetComponentInChildren<Text>();
    }
    void Update()
    {
        totalTime = script.HighScore_1;//totalTimeにHighScore_1の値を入れる

        display = script2.rankingdisplay;//HighScoreを表示させるための関数

        if (display == 1)
        {
            minute = (int)totalTime / 60;
            seconds = totalTime - minute * 60;

            Ranking1.text = minute.ToString("00") + ":" + ((int)seconds).ToString("00");
        }

    }
}

HighScoreの値とHighScoreを表示させる関数の値を参照させる設定をして、HighScoreを表示させる関数が1の時にHighScoreをUIに表示させるようにしています。
ゲームクリア画面からランキング画面の画面推移時に関数を0から1にして表示させることができます。
(もうちょっといいコードがかけそうな気がしたけど、私のコーディング力ではこれぐらいしかかけなかった…)

スクリーンショット 2019-12-04 15.16.24.png
ゲームクリア画面
Nextボタンを押すと
スクリーンショット 2019-12-04 15.16.31.png
ランキング表示させることができた!

追記

ゲームにランキングが追加できたので、しばらくテストプレイしていると、なぜか大量のAPIリクエストを送っていた…
スクリーンショット 2019-12-04 15.44.06.png
この日と次の日はすごい数送ってました…その数約5万件…
今は改善して減りましたが…
大量のAPIリクエストの原因は、ゴール後にUpdate処理で1フレームに1回、データベースのデータを取得していたからでした…

void Update()
    {

        int GameclearJudgment = script.Gameclearfunction;

        if (GameclearJudgment == 1)
        {
            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>("HighScore");

            query.OrderByAscending("Score");
            query.Limit = 5;

            query.FindAsync((List<NCMBObject> objList, NCMBException e) =>{

                if (e == null)
                {
                    HighScore_1 = System.Convert.ToSingle(objList[0]["Score"]);
                    HighScore_2 = System.Convert.ToSingle(objList[1]["Score"]);
                    HighScore_3 = System.Convert.ToSingle(objList[2]["Score"]);
                    HighScore_4 = System.Convert.ToSingle(objList[3]["Score"]);
                    HighScore_5 = System.Convert.ToSingle(objList[4]["Score"]);
                }


            });

        }
    }

問題のコード
ゲームクリアした時に値が1になる関数を参照して、ゲームクリアしていたらデータベースの値を取得するようにする処理をUpdateに書いてしまった。

    void Update()
    {

        int RankingJudgment = script.Rankingfunction;


        if (RankingJudgment == 1)
        {
            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>("HighScore");

            query.OrderByAscending("Score");
            query.Limit = 5;

            query.FindAsync((List<NCMBObject> objList, NCMBException e) =>{

                if (e == null)
                {
                    HighScore_1 = System.Convert.ToSingle(objList[0]["Score"]);
                    HighScore_2 = System.Convert.ToSingle(objList[1]["Score"]);
                    HighScore_3 = System.Convert.ToSingle(objList[2]["Score"]);
                    HighScore_4 = System.Convert.ToSingle(objList[3]["Score"]);
                    HighScore_5 = System.Convert.ToSingle(objList[4]["Score"]);
                }

                script.RankingJudgmentReset();

            });

        }
    }

データベースのデータを取得した後に参照元の関数を0にリセットするように改善。
無事にリクエスト数が減りました。

まとめ

データベースを触るのは初めてだったのでわからないことも多かった…

NCNBは細かい設定が必要ないのでわかりやすくて触りやすかった


次はランキングのスコアと同時にuser nameを表示できるようにしたい…
WebGL書き出ししてWeb上で遊べるようにしようか検討中。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DockerでUnity ML-Agentsを動作させてみた(v0.11.0対応)

Unity ML-Agents(v0.9.1)をDocker上で動作させてみました。

UnityやUnity ML-Agentsの環境構築などは下記をご参考ください。

Macでhomebrewを使ってUnityをインストールする(Unity Hub、日本語化対応)
https://qiita.com/kai_kou/items/445e614fb71f2204e033

MacでUnity ML-Agentsの環境を構築する(v0.11.0対応) - Qiita
https://qiita.com/kai_kou/items/0d40157cbc303fb10c22

手順

v0.10.1まではDockerでの学習方法についてドキュメントがあったのですが、v0.11.0の時点でドキュメントが削除されています。情報としては若干古くなりますが、v0.11.0でもこちらを参考にして動作させることができます。

ml-agents/Using-Docker.md at 0.10.1 · Unity-Technologies/ml-agents
https://github.com/Unity-Technologies/ml-agents/blob/0.10.1/docs/Using-Docker.md

Dockerのインストール

Dockerがインストールされていない場合、インストールします。

Macでbrewコマンド利用
> brew cask install docker
(略)

> docker --version
Docker version 19.03.4, build 9013bf5

※Dockerを初回起動すると初期設定のためにパスワード入力が求められます。

UnityにLinuxビルドサポートコンポーネントを追加する

Unity Hubを利用してUnityにLinuxビルドサポートコンポーネントを追加します。
Unityのバージョンは2019.2.10f1 を利用しています。

Unity Hubがインストールされていない場合は下記をご参考ください。

Macでhomebrewを使ってUnityをインストールする(Unity Hub、日本語化対応)
https://qiita.com/kai_kou/items/445e614fb71f2204e033

  • Unity Hubアプリを起動する
  • [インストール] > [Unityの利用するアプリ]右側にある[︙]をクリックして[モジュールを加える]を選択する 2019_11_06_13_02のコピー.png 2019_11_06_13_02のコピー2.png
  • [モジュールを加える]ダイアログの[Platforms]にある[Linux Build Support]にチェックを入れて[実行]ボタンをクリックする 2019_11_06_13_03.png

学習用のUnityアプリをダウンロードしてビルドする

ML-Agentsリポジトリに含まれているサンプルを学習できるようにします。

ML-Agentsリポジトリをダウンロード

適当なディレクトリにリポジトリをダウンロードする。

> mkdir 適当なディレクトリ
> cd 適当なディレクトリ
> git clone https://github.com/Unity-Technologies/ml-agents.git

Unityアプリからサンプルプロジェクトを開く

Unity Hubでアプリを立ち上げます。
ML-Agentsを利用するにはUnityのバージョン2017.4 以上が必要となります。今回は2019.2.10f1 を利用しました。

アプリが立ち上がったら「開く」ボタンから任意のディレクトリ/ml-agents/UnitySDK フォルダを選択します。

2019_11_05_16_58.png
2019_11_05_17_00のコピー.png
2019_11_05_17_01のコピー.png

Unityエディタのバージョンによっては、アップグレードするかの確認ダイアログが立ち上がります。

2019_11_05_17_02のコピー.png

「確認」ボタンをクリックして進めます。アップグレード処理に少し時間がかかります。

スクリーンショット 2018-09-13 10.50.01.png

起動しました。

今回は、サンプルにある[3DBall]Scenesを利用します。

  • Unityアプリの下パネルにある[Project]タブから以下のフォルダまで開く
    • [Assets] > [ML-Agents] > [Examples] > [3DBall] > [Scenes]
  • 開いたら、[3DBall]ファイルがあるので、ダブルクリックして開く

スクリーンショット 2019-11-06 10.13.57.png

Scenes(シーン)の設定

ML-Agentsで学習させるための設定です。

  • Unityアプリの[Edit]メニューから[Project Settings]を開く
    スクリーンショット 2019-08-19 14.23.05.png

  • [Inspector]パネルで以下の設定を確認する

    • [Resolution and Presentation]の[Run In Background]がチェックされている
    • [Display Resolution Dialog]がDisableになっている スクリーンショット_2019_08_19_14_25のコピー.png

ビルド設定

  • Unityアプリの[File]メニューから[Build Settings]を選択して[Build Settings]ダイアログを開く
    • [Add Open Scenes]をクリックする
    • [Scenes In Build]で[ML-Agents/Examples/3DBall/Scenes/3DBall]にチェックを入れる
    • [Platform]でPC, Mac & Linux Standalone が選択されていることを確認する
    • [Target Platform]をLinux に変更する
    • [Architecture]をx86_64 に変更する
    • [Server Build]にチェックを入れる
    • [Build]ボタンをクリックする
    • ファイル保存ダイアログで以下を指定してビルドを開始する
      • ファイル名: 3DBall
      • フォルダ名: 任意のディレクトリ/ml-agents/unity-volume

Build_Settings
Build_Settings

すると、unity-volume に以下フォルダ・ファイルが出力されます。

> ls 任意のディレクトリ/ml-agents/unity-volume

3DBall.x86_64 3DBall_Data

ハイパーパラメーターファイルの用意

ハイパーパラメーターファイルをunity-volume フォルダにコピーしておきます。

> cp ml-agents/config/trainer_config.yaml ml-agents/unity-volume/

Dockerイメージを作成する

すでにDockerfileが用意されているので、docker build するだけ。楽々ですね。
ml-agents-on-docker はイメージ名となりますので、任意でOKです。

> docker build -t ml-agents-on-docker ./ml-agents

(略)
Removing intermediate container c1384ee9d5a6
 ---> 9ff8832e88dc
Step 19/20 : EXPOSE 5005
 ---> Running in 53253a272fb4
Removing intermediate container 53253a272fb4
 ---> f0b43146ad36
Step 20/20 : ENTRYPOINT ["mlagents-learn"]
 ---> Running in f80f7504b790
Removing intermediate container f80f7504b790
 ---> 9caddd5a62b1
Successfully built 9caddd5a62b1
Successfully tagged ml-agents-on-docker:latest


> docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
ml-agents-on-docker latest              9caddd5a62b1        2 minutes ago       1.23GB

注意点

ml-agents ディレクトリ直下でdocker build コマンドを実行するとエラーになるのでご注意ください。

> cd ml-agents
> docker build -t ml-agents-on-docker .

error checking context: 'file ('/Users/xxx/xxxxx/ml-agents/UnitySDK/Temp') not found or excluded by .dockerignore'.

v0.10.0からPython 3.7.xにも対応しましたが、Dockerfile を覗いてみるとPyhtonのバージョンは3.6.4 のままでした。

Dockerfile一部抜粋
ENV PYTHON_VERSION 3.6.4

Dockerコンテナの実行

ビルドできたら実行してみます。

bashの場合
# ml-agents-3dball: コンテナ名(任意)
# ml-agents-on-docker: Dockerでビルド時に付けたイメージ名
# 3DBall: Unityでビルド時に付けたアプリの名前(拡張子なし)

> cd ml-agents
> docker run -it --rm \
  --name ml-agents-3dball \
  --mount type=bind,source="$(pwd)"/unity-volume,target=/unity-volume \
  -p 5005:5005 \
  -p 6006:6006 \
  ml-agents-on-docker:latest \
    --docker-target-name=unity-volume \
    --env=3DBall \
    --train \
    trainer_config.yaml

mlagents-learn コマンドの--docker-target-name オプションはdocker run コマンドの--workdir(-w) に置き換えることもできます。

> docker run -it --rm \
  --name ml-agents-3dball \
  --mount type=bind,source="$(pwd)"/unity-volume,target=/unity-volume \
  -w /unity-volume \
  -p 5005:5005 \
  -p 6006:6006 \
  ml-agents-on-docker:latest \
    --env=3DBall \
    --train \
    trainer_config.yaml

fishシェルで実行する場合は、"$(pwd)""$PWD" に置き換えます。

fishの場合
> cd ml-agents
> docker run -it --rm \
  --name ml-agents-3dball \
  --mount type=bind,source="$PWD"/unity-volume,target=/unity-volume \
  -p 5005:5005 \
  -p 6006:6006 \
  ml-agents-on-docker:latest \
    --docker-target-name=unity-volume \
    --env=3DBall \
    --train \
    trainer_config.yaml

実行すると、学習が始まります。
trainer_config.yamlmax_steps で指定されているステップ数が完了するか、ctrl + c キーで学習が終了します。

> docker run ()



                        ▄▄▄▓▓▓▓
                   ╓▓▓▓▓▓▓█▓▓▓▓▓
              ,▄▄▄m▀▀▀'  ,▓▓▓▀▓▓▄                           ▓▓▓  ▓▓▌
            ▄▓▓▓▀'      ▄▓▓▀  ▓▓▓      ▄▄     ▄▄ ,▄▄ ▄▄▄▄   ,▄▄ ▄▓▓▌▄ ▄▄▄    ,▄▄
          ▄▓▓▓▀        ▄▓▓▀   ▐▓▓▌     ▓▓▌   ▐▓▓ ▐▓▓▓▀▀▀▓▓▌ ▓▓▓ ▀▓▓▌▀ ^▓▓▌  ╒▓▓▌
        ▄▓▓▓▓▓▄▄▄▄▄▄▄▄▓▓▓      ▓▀      ▓▓▌   ▐▓▓ ▐▓▓    ▓▓▓ ▓▓▓  ▓▓▌   ▐▓▓▄ ▓▓▌
        ▀▓▓▓▓▀▀▀▀▀▀▀▀▀▀▓▓▄     ▓▓      ▓▓▌   ▐▓▓ ▐▓▓    ▓▓▓ ▓▓▓  ▓▓▌    ▐▓▓▐▓▓
          ^█▓▓▓        ▀▓▓▄   ▐▓▓▌     ▓▓▓▓▄▓▓▓▓ ▐▓▓    ▓▓▓ ▓▓▓  ▓▓▓▄    ▓▓▓▓`
            '▀▓▓▓▄      ^▓▓▓  ▓▓▓       └▀▀▀▀ ▀▀ ^▀▀    `▀▀ `▀▀   '▀▀    ▐▓▓▌
               ▀▀▀▀▓▄▄▄   ▓▓▓▓▓▓,                                      ▓▓▓▓▀
                   `▀█▓▓▓▓▓▓▓▓▓▌
                        ¬`▀▀▀█▓


INFO:mlagents.trainers:CommandLineOptions(debug=False, num_runs=1, seed=-1, env_path='3DBall', run_id='ppo', load_model=False, train_model=True, save_freq=50000, keep_checkpoints=5, base_port=5005, num_envs=1, curriculum_folder=None, lesson=0, slow=False, no_graphics=False, multi_gpu=False, trainer_config_path='trainer_config.yaml', sampler_file_path=None, docker_target_name='unity-volume', env_args=None, cpu=False)
INFO:mlagents.envs:
'Ball3DAcademy' started successfully!
Unity Academy name: Ball3DAcademy
        Number of Training Brains : 0
        Reset Parameters :
                gravity -> 9.8100004196167
                scale -> 1.0
                mass -> 1.0

(略)
2019-11-07 05:31:38.593118: I tensorflow/core/platform/cpu_feature_guard.cc:142] Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 FMA
2019-11-07 05:31:38.604956: I tensorflow/core/platform/profile_utils/cpu_utils.cc:94] CPU Frequency: 2400000000 Hz
2019-11-07 05:31:38.607307: I tensorflow/compiler/xla/service/service.cc:168] XLA service 0x39ccf70 executing computations on platform Host. Devices:
2019-11-07 05:31:38.607465: I tensorflow/compiler/xla/service/service.cc:175]   StreamExecutor device (0): <undefined>, <undefined>
(略)
2019-11-07 05:31:40.131532: W tensorflow/compiler/jit/mark_for_compilation_pass.cc:1412] (One-time warning): Not using XLA:CPU for cluster because envvar TF_XLA_FLAGS=--tf_xla_cpu_global_jit was not set.  If you want XLA:CPU, either set that envvar, or use experimental_jit_scope to enable XLA:CPU.  To confirm that XLA is active, pass --vmodule=xla_compilation_cache=1 (as a proper command-line flag, not via TF_XLA_FLAGS) or set the envvar XLA_FLAGS=--xla_hlo_profile.
INFO:mlagents.envs:Hyperparameters for the PPOTrainer of brain 3DBall:
        trainer:        ppo
        batch_size:     64
        beta:   0.001
        buffer_size:    12000
        epsilon:        0.2
        hidden_units:   128
        lambd:  0.99
        learning_rate:  0.0003
        learning_rate_schedule: linear
        max_steps:      5.0e4
        memory_size:    256
        normalize:      True
        num_epoch:      3
        num_layers:     2
        time_horizon:   1000
        sequence_length:        64
        summary_freq:   1000
        use_recurrent:  False
        vis_encode_type:        simple
        reward_signals:
          extrinsic:
            strength:   1.0
            gamma:      0.99
        summary_path:   /unity-volume/summaries/ppo_3DBall
        model_path:     /unity-volume/models/ppo-0/3DBall
        keep_checkpoints:       5
WARNING:tensorflow:From /ml-agents/mlagents/trainers/trainer.py:223: The name tf.summary.text is deprecated. Please use tf.compat.v1.summary.text instead.

WARNING:tensorflow:From /ml-agents/mlagents/trainers/trainer.py:223: The name tf.summary.text is deprecated. Please use tf.compat.v1.summary.text instead.



INFO:mlagents.trainers: ppo: 3DBall: Step: 1000. Time Elapsed: 10.062 s Mean Reward: 1.167. Std of Reward: 0.724. Training.
(略)
INFO:mlagents.trainers: ppo: 3DBall: Step: 10000. Time Elapsed: 109.367 s Mean Reward: 36.292. Std of Reward: 28.127. Training.
(略)
INFO:mlagents.trainers: ppo: 3DBall: Step: 49000. Time Elapsed: 520.514 s Mean Reward: 100.000. Std of Reward: 0.000. Training.
(略)
Converting /unity-volume/models/ppo-0/3DBall/frozen_graph_def.pb to /unity-volume/models/ppo-0/3DBall.nn
IGNORED: Cast unknown layer
IGNORED: StopGradient unknown layer
GLOBALS: 'is_continuous_control', 'version_number', 'memory_size', 'action_output_shape'
IN: 'vector_observation': [-1, 1, 1, 8] => 'sub_3'
IN: 'epsilon': [-1, 1, 1, 2] => 'mul_1'
OUT: 'action', 'action_probs'
DONE: wrote /unity-volume/models/ppo-0/3DBall.nn file.
INFO:mlagents.trainers:Exported /unity-volume/models/ppo-0/3DBall.nn file
INFO:mlagents.envs:Environment shut down with return code 0.

WARNING がかなり出力されますが、学習できました。

TensorBoard を利用して学習の進捗を視覚的に確認することもできます。

> docker exec -it \
  ml-agents-3dball \
  tensorboard \
  --logdir=/unity-volume/summaries \
  --host=0.0.0.0

(略)
  _np_qint32 = np.dtype([("qint32", np.int32, 1)])
/usr/local/lib/python3.6/site-packages/tensorflow/python/framework/dtypes.py:525: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  np_resource = np.dtype([("resource", np.ubyte, 1)])
TensorBoard 1.14.0 at http://0.0.0.0:6006/ (Press CTRL+C to quit)

スクリーンショット 2019-11-07 10.51.50.png

学習結果をアプリに組み込む

学習結果は、ml-agents/unity-volume フォルダ内に保存されます。
それをUnityアプリに組み込むことで学習結果をUnityアプリに反映できます。

> ls unity-volume/

3DBall.x86_64       3DBall_Data         csharp_timers.json  models              summaries           trainer_config.yaml

> tree unity-volume/models/
unity-volume/models/
└── ppo-0
    ├── 3DBall
    │   ├── checkpoint
    │   ├── frozen_graph_def.pb
    │   ├── model-50000.cptk.data-00000-of-00001
    │   ├── model-50000.cptk.index
    │   ├── model-50000.cptk.meta
    │   ├── model-50001.cptk.data-00000-of-00001
    │   ├── model-50001.cptk.index
    │   ├── model-50001.cptk.meta
    │   └── raw_graph_def.pb
    └── 3DBall.nn

2 directories, 10 files

Unityアプリの設定

Playerの設定を行います。

  • Unityアプリの[Edit]メニューから[Project Settings]を選択する
  • [Inspector]ビューの[Other Settings]欄で以下を確認・設定する
    • Scripting BackendがMono になっている
    • Api Conpatibility Levelが.NET 4.x になっている スクリーンショット 2019-11-06 11.23.45.png

学習結果ファイルの取り込み

ターミナルかFinderで学習結果を以下フォルダにコピーします。

  • 学習結果ファイル: models/ppo-0/3DBall.nn
  • 保存先: UnitySDK/Assets/ML-Agents/Examples/3DBall/TFModels/

※すでに保存先に3DBall.nn ファイルが存在していますので、リネームします。

> cp models/ppo-0/3DBall.nn ml-agents/UnitySDK/Assets/ML-Agents/Examples/3DBall/TFModels/3DBall_new.nn
  • Unityアプリの下パネルにある[Project]タブから以下のフォルダまで開く
    • [Assets] > [ML-Agents] > [Examples] > [3DBall] > [Scenes]
  • 開いたら、[3DBall]ファイルがあるので、ダブルクリックして開く
  • [Hierarchy]パネルから[Agent]を選択する
  • Unityアプリの[Project]パネルで以下フォルダを選択する
    • [Assets] > [ML-Agents] > [Examples] > [3DBall] > [TFModels]
  • Unityアプリの[Inspector]パネルにある[Model]という項目に[TFModels]フォルダ内の3DBall_new.nnファイルをドラッグ&ドロップする
    2019_11_06_11_28のコピー.png

  • Unity上部にある[▶]ボタンをクリックする

これで、学習結果が組み込まれた状態でアプリが起動します。

参考

Macでhomebrewを使ってUnityをインストールする(Unity Hub、日本語化対応)
https://qiita.com/kai_kou/items/445e614fb71f2204e033

MacでUnity ML-Agentsの環境を構築する(v0.11.0対応) - Qiita
https://qiita.com/kai_kou/items/0d40157cbc303fb10c22

ml-agents/Using-Docker.md at 0.10.1 · Unity-Technologies/ml-agents
https://github.com/Unity-Technologies/ml-agents/blob/0.10.1/docs/Using-Docker.md

Macでhomebrewを使ってUnityをインストールする(Unity Hub、日本語化対応)
https://qiita.com/kai_kou/items/445e614fb71f2204e033

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityライセンスのalfファイルを自動でulfにしたい!

CIのActivateとかでライセンスを自動でActivateさせたい!

CIでUnityを扱う時はJenkinsとかであれば問題ないのですが、CircleCIやGitHub Actionsを使用するときにDockerでのライセンス認証では.ulfファイルというのが必要になってきます。

現在.ulfファイルをコマンドラインから生成することはできません。生成するにはブラウザ経由の一択です。
それを今回Puppeteerというnode.jsのツールを使って自動化してみました。

※今回の認証フローはPersonalEdition固定になります。

Puppetterとは、Webブラウザでの操作をソースコードから行えるものになります。
詳しくはこちら
Puppeteer

今回のリポジトリはこちら
MizoTake/unity-license-activate

実装

npm経由でPuppeteerを入れて以下のjsで実装しました。

今回はライセンスの認証が必要になるので https://license.unity3d.com/manual のページで操作を行います。
手元にあるalfファイルから最終的にulfファイルをダウンロードする操作になります。

叩くコマンドは以下になります
node activate.js $email $password $alf_file_path

以下が今回のScriptの全容ですが細かく分けてどうなっているのか下で記述します。

activate.js
const puppeteer = require('puppeteer')
const fs = require('fs')

;(async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  const downloadPath = process.cwd()
  const client = await page.target().createCDPSession()
  await client.send('Page.setDownloadBehavior', {
    behavior: 'allow',
    downloadPath: downloadPath
  })

  await page.goto('https://license.unity3d.com/manual')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const email = `${process.argv[2]}`
  await page.type('input[type=email]', email)

  const password = `${process.argv[3]}`
  await page.type('input[type=password]', password)
  await page.click('input[name="commit"]')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const input = await page.$('input[name="licenseFile"]')

  const alfPath = `${process.argv[4]}`
  await input.uploadFile(alfPath)

  await page.click('input[name="commit"]')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const selectedTypePersonal = 'input[id="type_personal"][value="personal"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedTypePersonal
  )

  const selectedPersonalCapacity =
    'input[id="option3"][name="personal_capacity"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedPersonalCapacity
  )

  await page.click('input[class="btn mb10"]')

  await page.waitForNavigation()

  await page.click('input[name="commit"]')

  let _ = await (async () => {
    let ulf
    do {
      for (const file of fs.readdirSync(downloadPath)) {
        ulf |= file.endsWith('.ulf')
      }
      await sleep(1000)
    } while (!ulf)
  })()

  function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds)
    })
  }

  await browser.close()
})()

画面ごとの処理

1.png

  // ライセンス認証を行うページに行く
  await page.goto('https://license.unity3d.com/manual')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  // ライセンス認証を行うページに飛ばしたがリダイレクトでUnityのログインページに飛んでいる

  //コマンド引数からメールアドレスとパスワードをとってくる
  const email = `${process.argv[2]}`
  await page.type('input[type=email]', email)

  const password = `${process.argv[3]}`
  await page.type('input[type=password]', password)

  // Sign inのボタンを押す
  await page.click('input[name="commit"]')

2.png

  const input = await page.$('input[name="licenseFile"]')

  // コマンドライン引数で指定したpathからfileを添付
  const alfPath = `${process.argv[4]}`
  await input.uploadFile(alfPath)

  // Nextボタンを押す
  await page.click('input[name="commit"]')

3.png

これでファイル添付ができてることがわかります。ファイル添付までできるのすげぇ…Puppeteer…

4.png

  // Personal Editionの選択
  const selectedTypePersonal = 'input[id="type_personal"][value="personal"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedTypePersonal
  )

  // Personal Edition選択後に出てくるのOptionを選択
  const selectedPersonalCapacity =
    'input[id="option3"][name="personal_capacity"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedPersonalCapacity
  )
  
 // Nextボタンを押す
  await page.click('input[class="btn mb10"]')

この画面の実行後はこうなっています。
5.png

6.png

  // Download license fileボタンを押す
  await page.click('input[name="commit"]')

  // ダウンロードが始まるので手元に.ulfファイルができるまで待つ
  let _ = await (async () => {
    let ulf
    do {
      for (const file of fs.readdirSync(downloadPath)) {
        ulf |= file.endsWith('.ulf')
      }
      await sleep(1000)
    } while (!ulf)
  })()

  function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds)
    })
  }

以上のような流れになっています。

さいごに

Puppeteer便利!!!!

  await page.screenshot( {
    path: "./example.png"
  });

でScreenShotを撮りつつ実装してました。

ブラウザでしか行えない操作もこれがあればできるので色々と捗るんじゃないかなと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityの.alfファイルから自動で.ulfをダウンロードしたい!

CIのActivateとかでライセンスを自動でActivateさせたい!

CIでUnityを扱う時はJenkinsとかであれば問題ないのですが、CircleCIやGitHub Actionsを使用するときにDockerでのライセンス認証では.ulfファイルというのが必要になってきます。

現在.ulfファイルをコマンドラインから生成することはできません。生成するにはブラウザ経由の一択です。
それを今回Puppeteerというnode.jsのツールを使って自動化してみました。

※今回の認証フローはPersonalEdition固定になります。

Puppetterとは、Webブラウザでの操作をソースコードから行えるものになります。
詳しくはこちら
Puppeteer

今回のリポジトリはこちら
MizoTake/unity-license-activate

実装

npm経由でPuppeteerを入れて以下のjsで実装しました。

今回はライセンスの認証が必要になるので https://license.unity3d.com/manual のページで操作を行います。
手元にあるalfファイルから最終的にulfファイルをダウンロードする操作になります。

叩くコマンドは以下になります
node activate.js $email $password $alf_file_path

以下が今回のScriptの全容ですが細かく分けてどうなっているのか下で記述します。

activate.js
const puppeteer = require('puppeteer')
const fs = require('fs')

;(async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  const downloadPath = process.cwd()
  const client = await page.target().createCDPSession()
  await client.send('Page.setDownloadBehavior', {
    behavior: 'allow',
    downloadPath: downloadPath
  })

  await page.goto('https://license.unity3d.com/manual')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const email = `${process.argv[2]}`
  await page.type('input[type=email]', email)

  const password = `${process.argv[3]}`
  await page.type('input[type=password]', password)
  await page.click('input[name="commit"]')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const input = await page.$('input[name="licenseFile"]')

  const alfPath = `${process.argv[4]}`
  await input.uploadFile(alfPath)

  await page.click('input[name="commit"]')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  const selectedTypePersonal = 'input[id="type_personal"][value="personal"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedTypePersonal
  )

  const selectedPersonalCapacity =
    'input[id="option3"][name="personal_capacity"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedPersonalCapacity
  )

  await page.click('input[class="btn mb10"]')

  await page.waitForNavigation()

  await page.click('input[name="commit"]')

  let _ = await (async () => {
    let ulf
    do {
      for (const file of fs.readdirSync(downloadPath)) {
        ulf |= file.endsWith('.ulf')
      }
      await sleep(1000)
    } while (!ulf)
  })()

  function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds)
    })
  }

  await browser.close()
})()

画面ごとの処理

1.png

  // ライセンス認証を行うページに行く
  await page.goto('https://license.unity3d.com/manual')

  await page.waitForNavigation({
    timeout: 60000,
    waitUntil: 'domcontentloaded'
  })

  // ライセンス認証を行うページに飛ばしたがリダイレクトでUnityのログインページに飛んでいる

  //コマンド引数からメールアドレスとパスワードをとってくる
  const email = `${process.argv[2]}`
  await page.type('input[type=email]', email)

  const password = `${process.argv[3]}`
  await page.type('input[type=password]', password)

  // Sign inのボタンを押す
  await page.click('input[name="commit"]')

2.png

  const input = await page.$('input[name="licenseFile"]')

  // コマンドライン引数で指定したpathからfileを添付
  const alfPath = `${process.argv[4]}`
  await input.uploadFile(alfPath)

  // Nextボタンを押す
  await page.click('input[name="commit"]')

3.png

これでファイル添付ができてることがわかります。ファイル添付までできるのすげぇ…Puppeteer…

4.png

  // Personal Editionの選択
  const selectedTypePersonal = 'input[id="type_personal"][value="personal"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedTypePersonal
  )

  // Personal Edition選択後に出てくるのOptionを選択
  const selectedPersonalCapacity =
    'input[id="option3"][name="personal_capacity"]'
  await page.evaluate(
    s => document.querySelector(s).click(),
    selectedPersonalCapacity
  )
  
 // Nextボタンを押す
  await page.click('input[class="btn mb10"]')

この画面の実行後はこうなっています。
5.png

6.png

  // Download license fileボタンを押す
  await page.click('input[name="commit"]')

  // ダウンロードが始まるので手元に.ulfファイルができるまで待つ
  let _ = await (async () => {
    let ulf
    do {
      for (const file of fs.readdirSync(downloadPath)) {
        ulf |= file.endsWith('.ulf')
      }
      await sleep(1000)
    } while (!ulf)
  })()

  function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds)
    })
  }

以上のような流れになっています。

さいごに

Puppeteer便利!!!!

  await page.screenshot( {
    path: "./example.png"
  });

でScreenShotを撮りつつ実装してました。

ブラウザでしか行えない操作もこれがあればできるので色々と捗るんじゃないかなと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「MonKey - Productivity Commands」のカスタムコマンドでさらにUnity上の作業を効率化する

PONOS Advent Calendar 2019の5日目の記事です。

昨日は@nimitsuさんのGameLift RealTimeServerで遊んでみよう for Unity(AWS設定編)でした。

はじめに

本記事はPONOS Advent Calendar 2019の2日目の記事である「MonKey - Productivity Commands」のコマンド操作でUnity開発を効率化するの続編となります。
assetstore.png
MonKey - Productivity Commands - Asset Store

前回はMonKeyを使用してどんな事ができるのか、導入について書かせてもらいました。
今回は少し発展して、カスタムコマンドの作り方についてまとめたいと思います。

MonKeyには予め130超もの豊富なコマンドが用意されており、様々な処理をコマンドから実行できますが、それらのコマンドだけではそのプロジェクト特有の処理まではサポートできません。
カスタムコマンドを利用することにより、プロジェクト独自の処理についてもコマンド化し操作時間を短縮することができるので、MonKeyを有効利用したいと思っている方は参考にしてみてください。

なお、

  • Unity 2019.2.12f1
  • MacOS 10.14.6

の環境で動作確認しています。

カスタムコマンド

MonKey.Command属性をstaticメソッドに付与することで、そのメソッドをコマンド化することができます。
なお、スクリプトはEditorフォルダ以下に存在している必要がありますのでご注意ください。

using MonKey;

public static class SampleCommands
{
    [Command("Sample Command 01")] // 引数にMonKey上のコマンド名を指定する
    static void SampleCommand01()
    {
        Debug.Log($"コマンド\"SampleCommand01\"を実行したログです。");
    }
}

SampleCommand01.png
MonKeyのコマンドパレット上に表示されました!
image.png
コマンドを実行すると、メソッド内に記述した処理が実行されています。
このように、メソッドをMonKeyのコマンド化するのは非常に簡単です。

使用例:選択中のGameObjectの階層パスをコンソールに出力する

さて、ここからはより実用的なカスタムコマンドを作成してみます。
今回作成するのは「選択中のGameObjectの階層パスをコンソールに出力する」コマンドです。
Example.png
例えば、上のようなHierarchyで実行した場合には「Parent/SecondSon/Grandchild/Great-grandson」とコンソールにログ出力する挙動となります。

『SecondSon』の下にある『Grand-grandson』のことを他の人に伝えたいとき、Hierarchyの階層のパスを提示できれば、『EldestSon』の下にいるGrand-grandsonを誤って参照しまうことを防止できます。

コマンドの基本情報を設定する

前項で作成したSample Command 01ですが、他のコマンドと異なり「ヘルプ」や「短縮されたコマンド名」が表示されておらず、実際に使うには不便でした。
今回はそれらコマンドに関する基本情報をちゃんと設定してみましょう。

MonKey.Command属性には上記の「ヘルプ」や「短縮されたコマンド名」のようなコマンドの基本情報を設定するためのプロパティが用意されています。

MonKey.Command属性のプロパティ(抜粋)

プロパティ 用途
Name コマンド名。コマンドの検索に用いられる。
QuickName 短縮されたコマンド名。
3文字以内に収めることを推奨されている。
Help コマンド名の下に表示されるヘルプ。
1行より長くなるとフォーマットが完全ではない可能性がある。

実際にこれらのプロパティを使用して「ヘルプ」と「短縮されたコマンド名」が設定されたSample Command 02を作成します。

[Command(
    "Sample Command 02",
    QuickName = "SC2",
    Help = "選択中のGameObjectの階層パスをコンソールに出力する"
)]
static void SampleCommand02()
{
}

SampleCommand02_01.png
設定した「ヘルプ」と「短縮されたコマンド名」がコマンドパレット上に表示されていることがわかります。
また、短縮されたコマンド名である「sc2」を入力しただけでSmaple Command 02が候補として表示されます。

コマンドから選択されているGameObjectを参照する

次に、実際に選択中のGameObjectを参照してコンソールに出力する、というコマンドの処理部分を実装してみましょう。
現在選択されているGameObjectを参照するためには、通常エディタ拡張で使用しているUnityEditor.Selectionではなく、MonKeyから提供されているMonKey.Editor.MonkeyEditorUtils.OrderedSelectedGameObjectsを使用します。

(なお、選択されたGameObjectではなく、選択されたTransformを取得することのできるMonKey.Editor.MonkeyEditorUtils.OrderedSelectedTransformも用意されています)

IEnumerable<GameObject>で返ってくるので、foreachで列挙し処理していきます。

選択中のGameObjectを取得し、そのパスを出力するコードは以下になります。

using MonKey.Editor;

...

// OrderedSelectedGameObjectsから選択中のGameObjectを列挙できる。
foreach (var selectedGameObject in MonkeyEditorUtils.OrderedSelectedGameObjects)
{
    // 親のGameObjectの名前を手前に連結していく。
    var pathBuilder = new System.Text.StringBuilder(selectedGameObject.name);
    var parent = selectedGameObject.transform.parent;
    while (parent != null)
    {
        pathBuilder.Insert(0, parent.name + "/");
        parent = parent.transform.parent;
    }

    Debug.Log(pathBuilder.ToString());
}

SampleCommand02_02.png
この選択状態でコマンドを実行すると、
SampleCommand02_03.png
このように、各GameObjectの階層をパスとして出力することができました。

コマンドを実行できる条件を設定する

現在のSample Command 02は、GameObjectを全く選択していなくてもコマンドを実行することができます。

選択していない状態で実行したとしてもMonkeyEditorUtils.OrderedSelectedGameObjectsの要素が0で返ってくるだけなのでエラーが発生することはありませんが、それではコマンドを実行する意味がないため、「GameObjectを選択している状態でのみ」実行できるように、検証条件を設定してみましょう。

コマンドの検証条件はMonKey.Command属性のDefaultValidationプロパティで設定します。
DefaultValidationにはMonKey.DefaultValidation列挙体の値が使用できます。

MonKey.DefaultValidation列挙体(抜粋)

用途
AT_LEAST_ONE_GAME_OBJECT 1個以上のGameObjectを選択している。
AT_LEAST_TWO_GAME_OBJECTS 2個以上のGameObjectを選択している。
IN_PLAY_MODE プレイモード中である。
IN_EDIT_MODE プレイモード中ではない。

今回は、「GameObjectを選択している状態でのみ」実行できるようにしたいので、MonKey.DefaultValidation.AT_LEAST_ONE_GAME_OBJECTを指定します。

[Command(
    "Sample Command 02",
    QuickName = "SC2",
    Help = "選択中のGameObjectの階層パスをコンソールに出力する",
    DefaultValidation = DefaultValidation.AT_LEAST_ONE_GAME_OBJECT
)]
static void SampleCommand02()
{

SampleCommand02_04.png
GameObjectを選択していない状態でコマンドを選択すると…
SampleCommand02_05.png
ご覧のように「Select at least one GameObject」の警告が表示され、コマンドを実行することができません。

コマンドの製作者が自身だけで使用する分には使い方を気をつければいいので、検証条件の定義は必須ではありませんが、他者へ共有する予定がある場合は検証条件を正しく定義して正しい状況で使用してもらえるようにしたいですね。

おわりに

カスタムコマンドを作成してMonKeyが実行できる機能を充実させることで、Unity上の作業を更に効率化することができます。

なお、Unityエディタ上からstaticメソッドを実行するにはUnityEditor.MenuItem属性でメニューアイテム化する、という方法もありますが、MonKeyなら処理の実行条件の検証を行ったり実行時に引数を与えることができるので、メニューアイテムとして実行するよりも柔軟な処理の実行が可能となります。
そういった用途で実行したい処理がある場合にはMonKeyのカスタムコマンド作成を検討してみてください。

さて、PONOS Advent Calendar 2019は6日目も続けて私@e73ryoが担当する予定です。
次回は引数を与えてカスタムコマンドを実行する方法について紹介したいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】公式のソースコードを閲覧する。

公式のソースコードはGitHubから閲覧可能。
GitHub

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VR剣戟ゲーのための自作当たり判定処理

はじめに

SEKIROみたいな剣の斬り合いがVRでやりたい!!!!

こんにちは、ZeniZeniです。
昨今、Sword Of GargantuaSword Master VRなど、面白い良VR剣戟ゲームが増えてきました。
それらをプレイしてると、自分の理想の剣戟ゲームというのを作りたい欲がふつふつと湧き上がってきます。
というわけで絶賛開発中です。

今回は、VRで剣戟ゲームを作るための第一歩として、剣を高速で振っても、剣の当たり判定と剣同士が交差した座標を取得できる機能を実装しようと思います。
下の動画のようなことができるようになります。

これは、剣が交差した瞬間の座標を取得して、その座標から火花のエフェクトを発生させています。

実装方法

実装方法ですが、コリダーは使わずにやっています。
なぜかというと、高速な物体同士の当たり判定は、コリダーだと簡単にすり抜けてしまうからです。
下の動画くらいの速度が限界でした。

プラス、座標を取得するために小さいコリダーを大量に配置していたので、剣を変えるときは設定がめんどくさいですし、パフォーマンスもよろしくなさそうです。

それではコリダーを使わない当たり判定の実装方法を考えていきましょう。
まず、剣と剣がぶつかった判定をどうとるかを考えてみます。
これは三次元空間において、ある線分と線分の距離が一定値以下になったときを考えればよさそうです。

剣同士の距離の導出

計算方法

それでは、$点(p_{11}, p_{12})$からなる線分$L_1$と、$点(p_{21}, p_{22})$からなる線分$L_2$の距離$d$を導出していきます。
こちらのサイトを参考にしてみます。
まず線分$L_1$の方向ベクトルを$V_1$、線分$L_2$の方向ベクトルを$V_2$として、$V_1$と$V_2$の外積、すなわち線分$L_1$から線分$L_2$に垂直なベクトル$n$を求めます。
$L_1$上の任意の点$P_1$から$L_2$上の任意の点$P_2$へのベクトルを$V_{12}$とすれば、ベクトル$n$とベクトル$V_{12}$の内積が、そのまま距離$d$となります。
剣同士の距離の導出.png

計算がうまくできないとき

上記の導出では、線分同士が同一平面上にあるときには距離$d$は必ず0になり、正確な値が出ません。
ベクトル$V_{12}$とベクトル$n$が垂直になり、内積$(V_{12},n) = 0$となるからです。(垂直なベクトルの内積は0)
実際の所、剣同士をぶんぶん振り回している中で、剣同士が同一平面上になるときなど滅多にないのですが、一応考慮しておきます。

剣同士が交差した座標の導出

計算方法

火花を剣同士がぶつかった瞬間にぶつかった場所から発生させたいので、剣同士が交差した座標を導出していきます。
これがちょっとめんどくさいです。
考え方としては、まず線分$L_2$上の点で、線分$L_1$に最も近い点を$P_{min}$とします。その点$P_{min}$上から線分$L_1$への垂線の方向ベクトルの単位ベクトルを$\hat{n}$とします。
剣同士の交点ですが、例えば剣同士が10cmより小さくなったときを剣同士が接触したと考えれば、剣同士の交点は剣同士の互いに最も近い点二つの中点とするのがよさそうです。
ゆえに交点$M$は、剣同士の距離を$d$とすれば

M = P_{min} + \frac{d}{2} * \hat{n} 

となります。

それでは次に、$P_{min}$を導出していきます。

まず、線分$L1$上の任意の点$P_1$は、線分$L_1$の始点$P_{11}$の$x$座標を$P_{11x}$、線分$L_1$の方向ベクトル(始点$P_{11} - $ 終点$P_{12}$)の$x$成分を$v_{1x}$のようにあらわすとして、状態変数$t_1$$(0 \leqq t_1 \leqq 1)$を用いれば

\begin{align}
P_1 &= (l_{1x},l_{1y},l_{1z}) \\
    &= (p_{11x} + t_1v_{1x},p_{11y} + t_1v_{1y},p_{11z} + t_1v_{1z}) \\
\end{align}

と表せます。
また線分$L2$上の任意の点$P_2$も同様にして

\begin{align}
P_2 &= (l_{2x},l_{2y},l_{2z}) \\
    &= (p_{21x} + t_2v_{2x},p_{21y} + t_2v_{2y},p_{21z} + t_2v_{2z}) \\
\end{align}

と表せます。

すると距離$d$は

\begin{align}
d^2 &= (l_{1x} - l_{2x})^2 + (l_{1y} - l_{2y})^2 +(l_{1z} - l_{2z})^2 \\
    &= (v_{1x}^2 + v_{1y}^2 + v_{1z}^2)t_1^2 \\
    & \quad \quad  + 2(v_{1x}v_{2x} + v_{1y}v_{2y} + v_{1z}v_{2z})t_1t_2 \\
    & \quad \quad  + 2(v_{1x}(p_{11x} - p_{21x}) + v_{1y}(p_{11y} - p_{21y}) + v_{1z}(p_{11z} - p_{21z}))t_1 \\
    & \quad \quad  + (v_{2x}^2 + v_{2y}^2 + v_{2z}^2)t_2^2 \\
    & \quad \quad  + 2(v_{2x}(p_{21x} - p_{11x}) + v_{2y}(p_{21y} - p_{11y}) + v_{2z}(p_{21z} - p_{11z}))t_2 \\
    & \quad \quad  + (p_{11x}-p_{21x})^2 + (p_{11y}-p_{21y})^2 + (p_{11z}-p_{21z})^2
\end{align}

というようにあらわせます。
うへぇ…って思いますよね、僕は思いました。
これを次数に注目して、係数は適当な文字に置き換えて、平方完成してみます。

\begin{align}
d^2 &= At_1^2 + Bt_1 + Ct_1t_2 + Dt_2^2 + Et_2 + F \\
&= A\biggr(t_1 + \frac{C}{2A}t_2 + \frac{B}{2A}\biggr)^2 + \biggr(D - \frac{C^2}{4A}\biggr)\biggr(t_2 + \frac{E - \frac{2BC}{4A}}{2D-\frac{C^2}{4A}}\biggr)^2 -\frac{B^2}{4A} + F
\end{align}

今求めようとしている点$P_{min}$は、$d$が最小のとき、すなわち平方完成した部分が0になるときなので、

\begin{align}
t_2 &= - \frac{E - \frac{2BC}{4A}}{2D-\frac{C^2}{4A}} \\
&= \frac{BC - 2AE}{4AD - C^2}
\end{align}

のときです。
したがって$P_{min}$は$P_2 = (p_{21x} + t_2v_{2x},p_{21y} + t_2v_{2y},p_{21z} + t_2v_{2z})$の$t_2$に$\frac{BC - 2AE}{4AD - C^2}$を代入したものとなります。

絶対に交差しないとき

剣が絶対に交差しない状況のときは上のような計算をするのは無駄なので、そのような状況は早い段階ではじきましょう。
剣が絶対に交差しない状況は、下図のようなときです。
線分同士の交差判定例01.png
これにz座標の判定も加わります。

実際のコード

それでは実際に書いた線分同士の距離と交点を導出するコードがこちらです。

線分同士の距離とその交点を同時に取得したかったので、IntersectionInfoという構造体を作っています。
線分はLineという構造体を作成していて、剣の刃の部分の根本と剣先の2点を設定してください。

IntersectionChecker.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

[Serializable]
public struct Line
{
    public Transform p1;
    public Transform p2;
}

public struct IntersectionInfo
{
    public float Distance;
    public Vector3 MidPoint;

}

public class IntersectionChecker : MonoBehaviour
{
    public Line l1;
    public Line l2;

    public IntersectionInfo info;

    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            var t = GetIntersectionInfo(l1, l2);
            Debug.Log("distance is " + t.Distance);
            Debug.Log("mid point is " + t.MidPoint);
        }
    }

    public IntersectionInfo GetIntersectionInfo(Line line1, Line line2, float dThreshold = 0.5f)
    {
        //各平面で交差していない時は排除
        if (!CheckIntersectionException(line1, line2))
        {
            Debug.Log("not intersect");
            info.Distance = -1;
            return info;
        }

        Debug.Log("intersect!");
        var p11 = line1.p1.position;
        var p12 = line1.p2.position;
        var p21 = line2.p1.position;
        var p22 = line2.p2.position;

        var v1 = p12 - p11;
        var v2 = p22 - p21;
        var v12 = p22 - p11;

        var n = Vector3.Cross(v1, v2).normalized;
        var d = Mathf.Abs(Vector3.Dot(n, v12));

        //線分同士が同一平面上にあるとき
        if (d == 0)
        {
            if (IsInSamePlane(line1, line2))
            {
                Debug.Log("lines are in same plane");
                info.Distance = -1;
                return info;
            }
        }

        //dThresholdより離れている時を排除
        if (d > dThreshold)
        {
            info.Distance = -1;
            return info;
        }
        info.Distance = d;

        //線分ががもう一つの線分に対して手前か奥にあるかの判定
        var side = (Vector3.Cross(v1, v12).y < 0 ? 1 : -1);

        var tmpA = v1.x * v1.x + v1.y * v1.y + v1.z * v1.z;
        var tmpB = 2 * (v1.x * (p11.x - p21.x) + v1.y * (p11.y - p21.y) + v1.z * (p11.z - p21.z) );
        var tmpC = 2 * (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z);
        var tmpD = v2.x * v2.x + v2.y * v2.y + v2.z * v2.z;
        var tmpE = 2 * ( v2.x * (p21.x - p11.x) + v2.y * (p21.y - p11.y) + v2.z * (p21.z - p11.z) );
        //var t2 = -( tmpE - ( (2 * tmpB * tmpC) / (4 * tmpA) ) ) / ( 2 * (tmpD - ( (tmpC * tmpC ) / (4 * tmpA) )) );
        var t2 = ( tmpB * tmpC - 2 * tmpA * tmpE) / ( 4 * tmpA * tmpD - tmpC * tmpC);
        Debug.Log("P min is " + (p21 + (t2 * v2)));
        info.MidPoint = p21 + (t2 * v2) + ((d/2) * side * n);
        return info;

    }

    public bool IsInSamePlane(Line line1, Line line2)
    {
        var p1 = line1.p1.position;
        var p2 = line1.p2.position;
        var p3 = line2.p1.position;
        var p4 = line2.p2.position;

        var v1 = p2 - p1;
        var v2 = p3 - p1;
        var v3 = p4 - p1;

        var det = (v1.y * v2.z * v3.x) + (v1.z * v2.x * v3.y) + (v1.x * v2.y * v3.z) 
                  - (v1.z * v2.y * v3.x) - (v1.x * v2.z * v3.y) - (v1.y * v2.x * v3.z);
        return det == 0;
    }

    public bool CheckIntersectionException(Line line1, Line line2)
    {
        var p1 = line1.p1.position;
        var p2 = line1.p2.position;
        var p3 = line2.p1.position;
        var p4 = line2.p2.position;

        //x座標チェック
        if (p1.x <= p2.x)
        {
            if ((p3.x < p1.x && p4.x < p1.x) || (p2.x < p3.x && p2.x < p4.x))
            {
                return false;
            }
        }
        else
        {
            if ((p3.x < p2.x && p4.x < p2.x) || (p1.x < p3.x && p1.x < p4.x))
            {
                return false;
            }
        }
        //y座標チェック
        if (p1.y <= p2.y)
        {
            if ((p3.y < p1.y && p4.y < p1.y) || (p2.y < p3.y && p2.y < p4.y))
            {
                return false;
            }
        }
        else
        {
            if ((p3.y < p2.y && p4.y < p2.y) || (p1.y < p3.y && p1.y < p4.y))
            {
                return false;
            }
        }
        //z座標チェック
        if (p1.z <= p2.z)
        {
            if ((p3.z < p1.z && p4.z < p1.z) || (p2.z < p3.z && p2.z < p4.z))
            {
                return false;
            }
        }
        else
        {
            if ((p3.z < p2.z && p4.z < p2.z) || (p1.z < p3.z && p1.z < p4.z))
            {
                return false;
            }
        }


        return true;
    }


}

}

という感じになります。
交差しない場合やいくつかの例外時には、IntersectionInfoのDistanceは-1となります。

下図のような感じで設定してください。
線分同士の交差判定実例.png
後は、GetIntersectionInfo関数を呼んで得られたIntersectionInfoのMidPointで火花等のエフェクトを発生させればよいのです。

剣を速く振ったときだけ呼びたい場合、まず剣の振る速度を求める方法を考えると思います。
剣の振る速度は、SteamVR SDKアセットに入っている、VelocityEstimatorというコンポーネントを使うことをお勧めします。

開発、執筆にあたり、下記のサイト様を参考、引用させていただきました。

  1. 直線と直線の距離を与える公式
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

プロキシ環境下でUnity EditorのAsset store�にサインインできなかったのでUnity Hubを使用しないで問題回避した件

前略

会社のプロキシのせいでUnityがまともに動かったので、その対策を書きます。

環境

  • Unity: 2019.2.14f1
  • Unity Hub: 2.2.1

手順

UnityはUnity Hubからではなく、単体でインストールします。

こちらの「ダウンロード」-「Unityインストーラー」から「UnityDownloadAssistant-2019.2.14f1.exe」をダウンロードし、インストールします。

Fiddlerを導入します。インストール手順はこちらに従いました。プロキシの設定はこちらに従いました。

Fiddlerを導入すると、Unity EditorからAsset storeにアクセスできます。Fiddlerを使用しているので環境変数の設定は不要でした。しかしUnity Hubは通信エラーになります。Fiddlerのログを見ると407エラーが出ていることがわかります。Unity Hubが使えないので、Asset storeにサインインできません。

Unity Hubはアンインストールしておきます。
Unity EditorでAsset storeを開き、右上の「Sing In」ボタンをクリックします。「Create a Unity account」をクリックし、遷移先の画面で「Already have a Unity ID?」をクリックします。サインイン画面に飛ぶのでサインインします。これで認証が通り、Asset storeを使用できます。

草々

Unityの中の人、プロキシ環境下でも使えるようにしてください。オナシャッス。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む