20200204のUnityに関する記事は8件です。

iOSとmacのunityのnative pluginを作る

iOSとmacのunityのnative pluginを作る

unity editorで使えるmacOS用のbundleとiOS用のFrameworkのnative pluginを作成する手順です。
Bluetoothや動画作成など、Unityに実装されていない機能をiOS, macOSの機能を使うことで実現します。
UnityPluginXcodeTemplateをベースに作成します。
UnityPluginXcodeTemplateを使うことで、unityのnative pluginを作成するために必要なxcodeの複雑な設定の必要がなくなり、editorで動作確認できるbundleと実機用のframeworkが同時に開発できます。

今回作成するnative pluginの目的

  • unity(C#)には加算演算が実装されていない(設定)です。そこで、swiftの加算演算子を使って加算演算を実現するnative pluginを実装します。
  • 手順に着目しているので、今回は加算演算のみの実装ですが、同じ手順でbluetoothなどのnative pluginを作成しているので、参考になれば嬉しいです。
  • iOS, macOSで使えるbluetoothのnative plugin: https://github.com/fuziki/UnityCoreBluetooth

作成するC#クラス

  • NativeSuperCalculatorクラスです。
  • 加算演算ができるAddメソッドを持っています。
NativeSuperCalculator.cs
public class NativeSuperCalculator {
    public static int Add(int l, int r);
}

作成するSuperCalculator Xcodeプロジェクト

5つのターゲットがあります

Target Description
SuperCalculator.framework iOS BuildとExample_iOSでの動作確認に使用するframework
SuperCalculator_macOS.framework macOS_Exampleで動作確認に使用するframework
SuperCalculator_bundle.bundle macのUnity Editorで動作確認に使用するbundle
Example_iOS 作成した実装をUnityに組み込む前にiOSで動作確認するために使用する
Example_macOS 作成した実装をUnityに組み込む前にmacOSで動作確認するために使用する

作成するswiftクラス

SuperCalculatorクラスです。
加算演算ができるaddメソッドを持っています。

SuperCalculator.swift
@objcMembers
public class SuperCalculator: NSObject {
    public static func add(l: Int, r: Int) -> Int {
        return l + r  //swiftにしか加算演算子がない(設定)
    }
}

Xcode プロジェクトを作成する

1. UnityPluginXcodeTemplateをcloneしてrename

git clone https://github.com/fuziki/UnityPluginXcodeTemplate
mv UnityPluginXcodeTemplate/ SuperCalculatorPlugin/
cd SuperCalculatorPlugin

2. setting.ymlを編集

setting.yml
PROJECT_NAME: SuperCalculatorPlugin
FRAMEWORK_IOS: SuperCalculator
FRAMEWORK_MACOS: SuperCalculator_macOS
BUNDLE_MACOS: SuperCalculator_bundle
EXAMPLE_IOS: Example_iOS
EXAMPLE_MACOS: Example_macOS

3. xcodeprojを作成する

xcodegenを使用して、Xcodeディレクトリ下にxcodeprojを作成します。

make setup
open Xcode/SuperCalculatorPlugin.xcodeproj/

Swiftで機能を実装する

1. SuperCalculator.swiftの実装

  1. xcodeプロジェクトのNavigator AreaからLibrary/Sourcesを右クリック→ new file...を選択
  2. swift fileを選択し、next
  3. ファイル名はSuperCalculatorを指定、targetはSuperCalculator, SuperCalculator_macOS, SuperCalculator_bundleを選択してcreate
    スクリーンショット 2020-02-04 22.26.50.png

  4. SuperCalculatorクラスを実装する

  5. objective-cから呼び出すために@objcMembers属性をつけます。

SuperCalculator.swift
@objcMembers
public class SuperCalculator: NSObject {
    public static func add(l: Int, r: Int) -> Int {
        return l + r//swiftにしか加算演算子がない(設定)
    }
}

2. Unityからの呼び出し部分の実装

  1. NativePlugin.hの実装
  2. Unityのnative pluginはcの呼び出しなので、cのインタフェースを作成します。
  3. superCalculator_addを追加します。
NativePlugin.h
  #ifdef __cplusplus
  extern  "C" {
  #endif
     int add_one(int num);
+    int superCalculator_add(int l, int r);  //追加する
  #ifdef __cplusplus
  }
 #endif
  1. NativePlugin.mmの実装
  2. superCalculator_addが呼ばれたら、swiftのSuperCalculator.addを呼び出して計算します。
NativePlugin.mm
int superCalculator_add(int l, int r) {
    return (int)[SuperCalculator addWithL:l r:r];
}

3. macOS, iOSでの動作確認(optional)

  • viewDidLoadでsuperCalculator_addを呼び出して、正しく動作することを確認する。
    ※ iOSは実機のみサポートです。
ViewController.swift
override func viewDidLoad() {
    super viewDidLoad()
    // Do any additional setup after loading the view.
    let res = superCalculator_add(32, 42)
    print("res: \(res)")
}

UnityのNative Pluginとして導入する

1. iOS向けframeworkのビルド

  • schemeからSuperCalculatorを選択して、ビルドする。 スクリーンショット 2020-02-04 22.37.51.png

2. macOSのunity editor向けbundleのビルド

  • schemeからSuperCalculator_bundleを選択して、ビルドする。 スクリーンショット 2020-02-04 22.38.06.png

3. 成果物の確認

  • 4つ作成されているはず
    • Xcode/Out/Assets/SuperCalculatorPlugin/Plugins/iOS/SuperCalculator.framework.meta
    • Xcode/Out/Assets/SuperCalculatorPlugin/Plugins/iOS/SuperCalculator.framework
    • Xcode/Out/Assets/SuperCalculatorPlugin/Plugins/macOS/SuperCalculator_bundle_xxxxxxx.bundle
    • Xcode/Out/Assets/SuperCalculatorPlugin/Scripts/SuperCalculator_bundle.cs

4. 成果物をUnityにコピーする

  • Xcode/Out/Assets/SuperCalculatorPlugin/. を Unity/LibraryUser/Assets/Plugins/. にコピーする
mkdir Unity/LibraryUser/Assets/Plugins/SuperCalculatorPlugin
cp -r Xcode/Out/Assets/SuperCalculatorPlugin/. Unity/LibraryUser/Assets/Plugins/SuperCalculatorPlugin/.

Unityからnative pluginを使う実装

1. NativeSuperCalculator.csを作成し、NativeSuperCalculatorクラスを実装する

NativeSuperCalculator.cs
using System.Runtime.InteropServices;
namespace SuperCalculatorPlugin
{
    public class NativeSuperCalculator
    {
        [DllImport(SuperCalculator_bundle.IMPORT_TARGET)]
        private static extern int superCalculator_add(int l, int r);
        public static int Add(int l, int r) {
             return superCalculator_add(l, r);
        }
    }
}

2. NewBehaviourScriptなどで呼び出してみる

NewBehaviourScript.cs
using SuperCalculatorPlugin;
public class NewBehaviourScript : MonoBehaviour {
    void Start ()
    {
        int res = NativeSuperCalculator.Add(32, 45);
        Debug.Log("res: " + res);
    }
}

おわりに

Unityに実装されていない(設定の)加算をnative pluginを使って実現するプラグインを作成しました。
iOSの実機のみで使用する場合は、.mmファイルをベタ置きしてunityのビルドしたxcode projectにブリッジヘッダを設定すると言う方法もあるのですが、ブリッジヘッダの設定や、editorで動かないなど手間がかかるので、個人的にはbundleとframeworkを作りたい派です。
同様の手順でbluetoothや、ble keyboard, mp4作成など、unityに実装されていない、楽しい機能のネイティブプラグインを作成できるので、一度試してみて頂けると嬉しいです!

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

Unityで寿命はあるけど広くアクセスさせたい変数の置き場を考える

UE4にはGameModeやGameInstanceという便利なクラスがあります。

Unityにはない概念かなと思うので、すごく簡単に説明すると
・GameInstance:ゲーム全体が寿命のインスタンス
・GameMode:シーン寿命のインスタンス
で、各々がGetGameInstanceやGetGameModeなどの関数で簡単にアクセスできるものです。
厳密にはネットワーク挟んだりするとサーバ側ではインスタンスがサーバ上のみになったりするので厳密にはこれではないのですが、今回の目的とはちょっとそれてしまうので割愛します。
良い名前が思いつかなかったのでUE4さんから借りた程度に思ってください。

public staticなものをあちこちからアクセスしたいって需要は普通にあって、でも初期化と破棄のタイミングを間違えてバグらせてしまうというのは大いにあるとは思うのですが、シーン読み込み時に初期化、シーン破棄時に破棄され、なおかつAdditiveなシーンからGetGameModeをした場合でもCurrentSceneのGameModeが取れたらいいのではないかと思い、軽く作ってみました。

簡単なテストで動いてはいるのですが、実際バグマミレだと思うので、私が実環境で使い始めるまではこんな感じで。

https://github.com/yKimisaki/UniGameInstance

ちなみに2019.3の練習で作ったものなので、SerializeReference使っています。
ので2019.2以前では使えません。あしからず。

やりたいこと

Player.cs
using Tonari.Unity;
using UnityEngine;

namespace Assets.Scripts
{
    public class Player : MonoBehaviour
    {
        public void Start()
        {
            // このgameObjectがいるシーンのGameInstanceをもらう
            MyGameInstance gameInstance = gameObject.GetGameInstance<MyGameInstance>();
            // GameInstanceが持ってるアイテムマスタから装備情報をもらう
            var equipment = gameInstance.GetItemMaster(equipments[0].ItemId);

            // このgameObjectがいるシーンのGameModeをもらう
            MyBattleGameMode gameMode = gameObject.GetGameMode<MyBattleGameMode>();
            // 敵グループ管理のモデルに、自分を狙うようにお願いする
            // 敵グループ管理のモデルはバトル用ゲームモードが持っている
            gameMode.GetEnemyGroupModel().SetTargetPlayer(this);
        }
    }
}

使い方

GameInstanceのつくり方

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

namespace Assets.Scripts
{
    /* SerializeReferenceのためにSerializableつけてください */
    [Serializable]
    public class SampleGameInstance : Tonari.Unity.GameInstance
    {
        [SerializeReference]
        private List<int> numbers = new List<int>();

        public override void Initialize()
        {
            base.Initialize();

            /* 初期化ロジック */

            numbers.Add(5);
        }

        public override void Dispose()
        {
            /* 破棄ロジック */

            numbers.Clear();
            numbers = null;

            base.Dispose();
        }
    }
}

GameModeのつくり方

GameInstanceとだいたい同じです。

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

namespace Assets.Scripts
{
    /* SerializeReferenceのためにSerializableつけてください */
    [Serializable]
    public class SampleGameMode : Tonari.Unity.GameMode
    {
        [SerializeReference]
        private List<int> numbers = new List<int>();

        public override void Initialize()
        {
            base.Initialize();

            /* 初期化ロジック */

            numbers.Add(5);
        }

        public override void Dispose()
        {
            /* 破棄ロジック */

            numbers.Clear();
            numbers = null;

            base.Dispose();
        }
    }
}

設定の仕方

image.png

Windowメニューにこんなのを追加しているので開くと、

image.png

という質素な画面が出てきます。
この質素な画面でGameInstanceと、各シーンに紐づくGameModeを設定します。

プルダウンを押すと

image.png
image.png

さっき作ったGameInstanceやGameModeが出てくるので、選択して設定は終了です。

注意

GameModeを設定するには、設定したいシーンを開いてからGameModeSettingWindowを使用してください。
いったんは雑で、すみません。

寿命と初期化の方法について

GameInstanceとGameModeは、ScriptableObjectに初期値の入ったインスタンスを格納しています。
これはDefaultではResources直下にGameModeSettings.assetとして生成されます。
GameInstanceは起動時や、任意のタイミングでGameInstance.ResetInstance()を呼ぶことで、1つ前のインスタンスをDisposeし、初期インスタンスをディープコピーしてキャッシュします。
GameModeはシーンの読み込み時に初期インスタンスをディープコピーしてキャッシュし、シーンが破棄されるタイミングでDisponseします。

つまり、シーンA→B→Aと移動した場合、最初のAのGameModeインスタンスと最後のAのGameModeインスタンスは別物になります。
ディープコピーされたインスタンスの寿命は完全にシーンの寿命と一致します。

また、ゲーム内でエラーが起こりタイトルに戻った際にGameInstance.ResetInstance()をすることで、エラーの起きている時代のGameInstanceを破棄し、新しいGameInstanceを使用することができます。
マスタの再取得などはこのパターンでできるかと思います。

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

【Unityエンジニアになる!】Unity・C#の勉強スタート

初投稿。
Unityエンジニアになるべく、Unity・C#の勉強を本格的にスタートしました。
前職では通信会社で法人営業をしており、プログラミングとは無縁でした。
しかし独学で少しプログラミングを勉強していました。

そんな私がこれまでどんな勉強をしてきて、これから何を学んでいくのか、
これから本格的にプログラミングを学んでいく前にメモを残しておこうと思います。

【プロフィール】
・4年制普通大学 商学部 :経営学・競争戦略を勉強。
・学生時代にベンチャー企業でインターン :中小・ベンチャー企業社長にインタビュー。
・大学卒業後通信会社に就職 :法人営業。
→各組織でプログラミングに触れた経験は0。

【独学で勉強してきたこと】
〜大学4年時〜
○カレンダー
2018.10~11     ProgateでHTML・CSS・JavaScript(jQuery)・Pythonを学習
2018.12~2019.01  Python Djangoのチュートリアル(投票アプリの作成)
2019.02~03    「Python3 Django2.0 入門 Pythonで作るWebアプリケーション開発入門」
          (RSSリーダーアプリの作成)
○学んだこと
HTML・CSS・JavaScript(jQuery)の基礎 / BootStrapの基礎
pythonの基礎 / Djangoの基礎
Git/GitHub
コマンドプロンプト
(ネットでコマンドを調べてもMac・Linuxのものばかり出てきたので、WindowsPCに仮想マシン入れてUbuntu使ったりしてました)

〜社会人〜
○カレンダー
2019.05~07   「jQuery レッスンブック jQuery2.x/1.x 対応 ーソシム」
2019.08      ProgateでSwiftを学習
         「たった2日でマスターできるiPhoneアプリ開発集中講座 XCode10 Swift4.2 
           ーソシム」
2019.10     「Unityの教科書 2D&3Dスマートフォンゲーム入門講座 ー北村愛美 著」
            (サンプルアプリ6個)
2019.11     「ARKitとUnityではじめるARアプリ開発 ー薬師寺国安 著」(サンプルアプリ13個)
2019.12~2020.01 ARFoundationを触るもオクルージョン・人体検知がうまくできず挫折中

○学んだこと
JavaScript(JQuery)の基礎復習
Swiftの基礎 / Xcodeの基礎
Unityの基礎 / C#の基礎
Unity×ARkit
Unity×ARFoundation →挫折中

【これから学んでいくこと】
Unity・C#を使ってオリジナルアプリの作成
Git/GitHubを再勉強
(Blender?)
期間は、努力目標:2ヶ月、標準目標:3ヶ月

以上です。
Unityエンジニアになる!!

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

UX最強のベジェ曲線「κ-Curves」を完全に理解する

TL;DR

  • 全てのユーザ制御点上を通り、
  • 全ての曲率極大点がユーザ制御点上にある

そんな超便利なのにあまり知られていないパラメトリック曲線こと「κ-Curves」
SIGGRAPH 2017で出た論文です。新しいせいか、検索しても情報があまり出てきません。
この論文と同じ流れを、前提知識や行間を補いつつ日本語で追っていきます。

C#で実際に実装もしていきます。
論文に忠実に実装するとちょっとバグるので、その修正も。

※本記事では、上記論文から一部画像や式を引用しています。

image.png
これは論文から引用した図で、他の様々なパラメトリック曲線とκ-Curvesの比較。
左から順に、Interpolatory subdivision curve, Catmull-Romスプライン、Cubic B-スプライン、κ-Curvesです。

image.png
これは私が実装したκ-Curvesで描いたどう見ても鳥。白丸が制御点です。

※2020/2/7追記

これまで掲載していた実装には二分探索を含んでいましたが、重かったのでカルダノの公式による実装に変更しました。
また、行列計算のメモリ管理を見直しました。
記事公開当初の掲載内容と比べて5倍近く速くなっています。

事前知識

簡単のため、本記事ではxy平面上の曲線のみを考えます。
高校レベルの数学+行列の基礎知識で理解できる内容のはずです。

パラメトリック曲線

パラメトリック曲線とは、X座標、Y座標がそれぞれパラメータ$t$によって決まる曲線です。

\begin{align}
&f:\mathbb{R}\rightarrow\mathbb{R}^2\\
&f(t)=
\left(\begin{matrix}
x(t)\\
y(t)
\end{matrix}\right)
\end{align}

世の中には様々なパラメトリック曲線があります。いくつか雑に紹介すると、

  • (3次)エルミート曲線
    • $f(0), f(1), f'(0),f'(1)$の値が制約として与えられ、それを満たすような$t$の3次式。
    • あんまり融通が効かない。
  • (n次)ベジェ曲線
    • 連続する各セグメント$i$について、制御点$c_{i,0}, \cdots, c_{i,n}$が与えられる。
    • $c_{i,0}, c_{i,n}$を結ぶ線をそれ以外の制御点がそれぞれ引っ張ったような曲線になる。
    • 曲線は制御点$c_{i,0}, c_{i,n}$以外の上を通らない。
  • スプライン曲線
    • 3次曲線などをいくつも繋げたり重ねたりして一本の曲線を作るもの。
    • いろいろと種類がある。Catmull-Romスプライン、B-スプライン、NURBSなど。
    • 接続点を制御点として動かすタイプの場合、曲線は制御点上を通る。
      • が、通るだけで、期待した形になるとは限らない。
      • なんかひん曲がってしまう例(Excelの曲線ツール):image.png

と、他にもいろいろありますが、とにかくどれもこれも融通が効きません。
イラストソフトなどでポチポチクリックした場所をいい感じになめらかに繋いで曲線を作ってほしい場合、どれも力不足。

κ-Curvesは、曲率を制御することでこれを実現します。
※ベースはベジェ曲線なので、ベジェ曲線以外のことは忘れて大丈夫です。

曲率

κ-Curvesは、「ユーザー制御点の曲率の絶対値が極大になる」という特徴を持つ曲線です。
曲率とは、曲線上のある点の周りの微小区間を円弧に近似したときの円の半径逆数(符号付き)です。
要は、曲線上のある点の周囲がいかに急カーブかを示す値ですね。絶対値が大きいほど急になります。

軽く導出しておきましょう。

平面曲線$f:\mathbb{R}\rightarrow\mathbb{R}^2$において、ある微小区間$f(t)$~$f(t+\Delta t)$の長さを$\Delta s$、それが円弧だとしたときの中心角を$\Delta\theta$、半径を$r$とすると、$\Delta s=r\Delta\theta$が成り立ちます。これより、点$f(t)$における曲率$\kappa(t)$は、

\kappa(t)=\lim_{\Delta t\rightarrow0}\frac{\Delta \theta}{\Delta s}=\frac{d\theta}{ds}

と表されます。

ここで、$\dot{x}=\frac{dx}{dt},\ \dot{y}=\frac{dy}{dt}$とすると、点$f(t)$における傾きは

\tan\theta=\frac{dy}{dx}=\frac{\dot{y}}{\dot{x}}

ですが、この両辺を$t$で微分すると、

\begin{align}
\frac{1}{\cos^2\theta}\frac{d\theta}{dt}&=\frac{\dot{x}\ddot{y}-\dot{y}\ddot{x}}{\dot{x}^2}\\\\
\frac{d\theta}{dt}&=\frac{1}{1+\tan^2\theta}\frac{\dot{x}\ddot{y}-\dot{y}\ddot{x}}{\dot{x}^2}\\\\
\frac{d\theta}{dt}&=\frac{\dot{x}\ddot{y}-\dot{y}\ddot{x}}{\dot{x}^2 + \dot{y}^2}
\end{align}

が得られます。ドット2つは$t$での二階微分です。
また、$\Delta s$は微小区間の長さなので、

\frac{ds}{dt}=\sqrt{\dot{x}^2+\dot{y}^2}

であり、以上から

\begin{align}
\kappa(t)&=\frac{d\theta}{ds}=\frac{\dot{x}\ddot{y}-\dot{y}\ddot{x}}{(\dot{x}^2 + \dot{y}^2)^{\frac{3}{2}}}\\\\
&=\frac{f'(t)\times f''(t)}{\|f'(t)\|^3}
\end{align}

が導かれます。

κ-Curvesは、ユーザ制御点上でこの値(の絶対値)が極大値を取るような曲線である、ということです。

ベジェ曲線

先程少し触れましたが、κ-Curvesはベジェ曲線がベースになっています。
というか、曲線の式自体はベジェ曲線そのものなのです。
というわけで、まずはベジェ曲線について。

表式

一般の$d$次ベジェ曲線の$i$番目のセグメントは、$c_{i,j}$をそのセグメントのベジェ制御点とすると

c_i^d(t)=\sum_{j=0}^d\frac{d!}{(d-j)!j!}(1-t)^{d-j}t^jc_{i,j}

で表されますが、κ-Curvesにおいては次に示す2次ベジェ曲線を前提とします。$d$のことは忘れてください。

\begin{align}
c_i(t)&=(1-t)^2c_{i,0} + 2(1-t)tc_{i,1} + t^2c_{i,2}\\
&=(c_{i,0}-2c_{i,1}+c_{i,2})t^2-2(c_{i,0}-c_{i,1})t+c_{i,0}\\
\end{align}

二階微分まで求めておくと、

\begin{align}
c_i'(t)&=2(c_{i,0}-2c_{i,1}+c_{i,2})t-2(c_{i,0}-c_{i,1})\\
&=2((1-t)(c_{i,1}-c_{i,0})+t(c_{i,2}-c_{i,1}))\\
\\
c_i''(t)&=2(c_{i,0}-2c_{i,1}+c_{i,2})\\
&=2((c_{i,0}-c_{i,1}) + (c_{i,2} - c_{i,1}))
\end{align}

となります。

曲率

$i番目の$セグメントの各点$c_i(t)$における曲率を$\kappa_i(t)$とすると、

\begin{align}
\kappa_i(t)&=\frac{c_i'(t)\times c_i''(t)}{\|c_i'(t)\|^3}\\
&=\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})}{\|(1-t)(c_{i,1}-c_{i,0})+t(c_{i,2}-c_{i,1})\|^3}\\
\end{align}\\

となります。ここで$\triangle(c_{i,0},c_{i,1},c_{i,2})$は3つの制御点を結んだ三角形の符号付き面積で、つまり定数です。
……どこから出てきたんやお前、と言いたくなる式変形。
元論文にはこれが何の説明もなく出てきました。不親切です。

というわけで図解。同じ色の線分は平行です。
k0.png
$c_{i,0},c_{i,1},c_{i,2}$を3点とする平行四辺形の残りの点($c_{i,1}$の対角位置)を$P$とし、
$c_{i,0},c_{i,1},P$を3点とする平行四辺形の残りの点($c_{i,1}$の対角位置)を$R$とします。

$c_{i,0}$を原点としたときに、$\frac{1}{2}c_i'(t)=(1-t)(c_{i,1}-c_{i,0})+t(c_{i,2}-c_{i,1})$がどこを示す位置ベクトルになるかというと、見たまんま内分点なので図の点$Q$です。

同じように、$\frac{1}{2}c_i''(t)=(c_{i,0}-c_{i,1}) + (c_{i,2} - c_{i,1})$は点$R$を示します。

よって、これらの外積$\frac{1}{4}c_i'(t)\times c_i''(t)$の絶対値は、以下の領域の面積になります。
k1.png
これは、以下の領域の面積と等しいことが等積変形によってわかります。
k2.png
よって、$t$の値によらず、

\frac{1}{4}c_i'(t)\times c_i''(t)=2\triangle(c_{i,0},c_{i,1},c_{i,2})

であることが分かります。拍手。初等幾何は楽しいですね。

というわけで再掲すると、2次ベジェ曲線の$i$番目のセグメント上の点$c_i(t)$における曲率は、

\kappa_i(t)=\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})}{\|(1-t)(c_{i,1}-c_{i,0})+t(c_{i,2}-c_{i,1})\|^3}

で求められます。

※面積の符号については、最終的に絶対値で吸収されるのであまり気にしなくて大丈夫です。

曲率極大点

曲率の絶対値が極大になるときの$t$を$t_i$とすると、$\kappa'(t_i)=0$より、

\begin{align}
\kappa'(t_i)=-\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})\cdot 3(\|c_i'(t_i)\|)'}{\|c_i'(t_i)\|^4}&=0\\\\
2c_i'(t_i)c_i''(t_i)&=0\\\\
((c_{i,0}-2c_{i,1}+c_{i,2})t_i-(c_{i,0}-c_{i,1}))\cdot(c_{i,0}-2c_{i,1}+c_{i,2})&=0
\end{align}

というわけで

t_i=\frac{(c_{i,0}-c_{i,1})\cdot(c_{i,0}-2c_{i,1}+c_{i,2})}{\|c_{i,0}-2c_{i,1}+c_{i,2}\|^2}

となります。
$c_{i,1}$から見たローカル座標として、$c_{i,0}'=c_{i,0}-c_{i,1},\ c_{i,2}'=c_{i,2}-c_{i,1}$とおけば、

t_i=\frac{c_{i,0}'\cdot(c_{i,0}'+c_{i,2}')}{\|c_{i,0}'+c_{i,2}'\|^2}

とそこそこ綺麗な形になります。

このように、与えられたベジェ制御点から曲率極大点を求めるのは簡単です。
κ-Curvesはこの逆、与えられたユーザ制御点を曲率極大点$c_i(t_i)$とし、それを満たすベジェ制御点を逆算するシステムです。

κ-Curves

いよいよκ-Curvesを構成していきます。
問題の大枠は以下の通り:

入力:$n$個のユーザ制御点$p_i\ (0\le i<n)$
出力:$3n$個のベジェ制御点$c_{i,j}\ (0\le i<n,\ j=0,1,2)$

制約1:ユーザ制御点で極大曲率
制約2:$C^0$連続
制約3:$G^1$連続
制約4:ほぼ$G^2$連続

4つの制約を順に見ていきましょう。

※ベジェ制御点$c_{i,j}$の添字$i$については、ひとまずはループしているものとみなし、$\rm{mod}\ n$で考えます。
 範囲制限をつけずに$i+1$とか$i-1$とか書きますが怒らないでください。非ループ版への拡張は簡単です。

制約1:ユーザ制御点で極大曲率

$p_i=c_i(t_i)$を、$c_{i,1}$について整理してみます。

\begin{align}
p_i&=c_i(t_i)\\
p_i&=(c_{i,0}-2c_{i,1}+c_{i,2})t_i^2-2(c_{i,0}-c_{i,1})t_i+c_{i,0}\\\\
c_{i,1}&=\frac{p_i-(1-t_i)^2c_{i,0}-t^2c_{i,2}}{2t_i(1-t_i)}
\end{align}

※$t_i=0, 1$のときはそれぞれ$p_i=c_{i,0}, c_{i,2}$のときなので、個別に簡単に考えることができます。
これを先程導出した$t_i$の式:

t_i=\frac{(c_{i,0}-c_{i,1})\cdot(c_{i,0}-2c_{i,1}+c_{i,2})}{\|c_{i,0}-2c_{i,1}+c_{i,2}\|^2}

に代入して気合で整理すると、以下の$t_i$の三次方程式が得られます。

\|c_{i,2}-c_{i,0}\|^2t_i^3+3(c_{i,2}-c_{i,0})\cdot(c_{i,0}-p_i)t_i^2+(3c_{i,0}-2p_i-c_{i,2})\cdot(c_{i,0}-p_i)t_i-\|c_{i,0}-p_i\|^2=0

ごちゃごちゃしていますが、$c_{i,0}$から見たローカル座標で $c_{i,2}'=c_{i,2}-c_{i,0},\ p_i'=p_i-c_{i,0}$ と書き直せば、綺麗に……

\|c'_{i,2}\|^2t_i^3-3c'_{i,2}p'_it_i^2+(2p_i'+c_{i,2}')p_i't_i-\|p_i'\|^2=0

……プライムのせいでごちゃごちゃ度は増しましたが、短くはなりました。

ところで、この方程式の解は、必ず$[0,1]$内の一点に定まります。さすがに自明ではなく、証明は論文のAppendixにあります。
ただの3次方程式なので解の公式に突っ込むもよし、特定区間に解の存在が確定しているので二分探索するもよし、好きな方法で解を求めることができます。

制約2:C⁰連続

曲線全体位置が連続であることを保証する制約です。
各セグメント内が連続なのは自明として、隣り合うセグメントが端点で互いに接続していればよいので、
任意の$i$について、

c_{i,2}=c_{i+1,0}

が満たされればよいですね。簡単。次に行きましょう。

制約3:G¹連続

セグメントの接続点傾きが連続であることを保証する制約です。
これがないと、セグメントとセグメントの間で線が折れてしまいます。

任意の$i$について、ある$\lambda_i\in(0,1)$があって、

c_{i,2}=(1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1}

が満たされれば、$c_{i,2}=c_{i+1,0}$における接線は$c_{i,1}$と$c_{i+1,1}$を結ぶ直線に定まります。
κ-Curvesにおいてベジェ制御点は入力ではなく出力なので、これで全ての場合が網羅されています。

ユーザ制御点位置の別表示

またこの制約のもとでは、ユーザ制御点の位置$p_i=c_i(t_i)$を、$c_{i,0}, c_{i,2}$を使わずに以下のように表現可能です。

p_i=(1-\lambda_{i-1})(1-t_i)^2c_{i-1,1}+\big(\lambda_{i-1}(1-t_i)^2+(2-(1+\lambda_i)t_i)t_i\big)c_{i,1}+\lambda_it_i^2c_{i+1,1}

何でそんな変形を? と思うかもしれませんが、後で使います。

制約4:ほぼG²連続

セグメントの接続点曲率が(ほぼ)連続であることを保証する制約です。

\kappa_i(t)=\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})}{\|(1-t)(c_{i,1}-c_{i,0})+t(c_{i,2}-c_{i,1})\|^3}

で、任意の$i$について$\kappa_i(1)=\kappa_{i+1}(0)$であればいいので、

\begin{align}
\kappa_i(1)&=\kappa_{i+1}(0)\\\\
\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})}{\|c_{i,2}-c_{i,1}\|^3}
&=\frac{\triangle(c_{i+1,0},c_{i+1,1},c_{i+1,2})}{\|c_{i+1,1}-c_{i+1,0}\|^3}\\\\
\frac{\triangle(c_{i,0},c_{i,1},c_{i,2})}{\|c_{i,1}-c_{i+1,1}\|^3\lambda_i^3}
&=\frac{\triangle(c_{i+1,0},c_{i+1,1},c_{i+1,2})}{\|c_{i,1}-c_{i+1,1}\|^3(1-\lambda_i)^3}\\\\
\frac{\triangle(c_{i,0},c_{i,1},c_{i+1,1})}{\lambda_i^2}
&=\frac{\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})}{(1-\lambda_i)^2}
\end{align}

より(最後の式変形は図を書いて面積比に着目するとすぐ分かります)、

\lambda_i=\frac{\sqrt{\triangle(c_{i,0},c_{i,1},c_{i+1,1})}}{\sqrt{\triangle(c_{i,0},c_{i,1},c_{i+1,1})}+\sqrt{\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})}}

が得られます。

ところで、ベジェ曲線の曲率が0になることはないので、隣り合うセグメントの凹凸が逆である場合、曲率は必ず不連続になります。
このとき、$\triangle(c_{i,0},c_{i,1},c_{i+1,1}),\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})$の符号が異なるので、$\lambda_i$は実数ではなくなります。

一致させられないのであれば、せめて絶対値の差を0にしましょう。
つまり$|\kappa_i(1)|=|\kappa_{i+1}(0)|$を解くわけですが、$\kappa_i(1)$と$\kappa_{i+1}(0)$の符号は逆なので、$\kappa_i(1)=-\kappa_{i+1}(0)$を解けばいいことがわかります。
すると、曲率が連続の場合とほぼ同様の手順で、以下が求まります。

\lambda_i=\frac{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}}{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}+\sqrt{|\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})|}}

これは先程の式も包含できているので、制約式としてはこちらのみを使えばよさそうです。

制約まとめ

制約とその関連式をまとめると、

\left\{\begin{array}{ll}
(1)&\|c'_{i,2}\|^2t_i^3-3c'_{i,2}p'_it_i^2+(2p_i'+c_{i,2}')p_i't_i-\|p_i'\|^2=0\\
&(c_{i,2}'=c_{i,2}-c_{i,0},\ p_i'=p_i-c_{i,0})\\\\
(2)&c_{i,2}=c_{i+1,0}=(1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1}\\\\
(3)&p_i=(1-\lambda_{i-1})(1-t_i)^2c_{i-1,1}+\big(\lambda_{i-1}(1-t_i)^2+(2-(1+\lambda_i)t_i)t_i\big)c_{i,1}+\lambda_it_i^2c_{i+1,1}\\\\
(4)&\lambda_i=\displaystyle\frac{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}}{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}+\sqrt{|\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})|}}\\
\end{array}\right.

が満たされるようにベジェ制御点$c_{i,j}$を定めればよいことになります。
が、これをこのまま解析的に解くのは困難です。

アルゴリズム

式(1)~(4)を使ってできることは、

  • 全ての$c_{i,0}, c_{i,2}$があれば、(1)で全ての$t_i$を求められる
  • 全ての$\lambda_i, c_{i,1}$があれば、(2)で全ての$c_{i,0}, c_{i,2}$を求められる
  • 全ての$\lambda_i, t_i$があれば、(3)で全ての$c_{i,1}$を求められる(方法は後述)
  • 全ての$c_{i,0},c_{i,1},c_{i,2}$があれば、(4)で全ての$\lambda_i$を求められる

となります。
これらをうまく組み合わせて、全ての$p_i$から全ての$c_{i,j}$を出力したいわけです。

概要

適当な初期値から始めて、何度も式を適用することで正解に近づけていく方針を取ります。

  • Step0. 各$\lambda_i,\ c_{i,j}$を初期化
  • Step1. 式(4)で各$\lambda_i$を算出・更新
  • Step2. 式(2)で各$c_{i,0}, c_{i,2}$を更新
  • Step3. 式(1)で各$t_i$を算出・更新
  • Step4. 式(3)で各$c_{i,1}$を更新
  • If 満足
    • then return $c_{i,j}$
    • else goto Step1.

各ステップに分けて見ていきましょう。

Step0. 初期化

以下のように初期化します。

  • $\lambda_i=0.5$
  • $c_{i,1}=p_i$
  • $c_{i,2}=c_{i+1,0} = (1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1} = (c_{i,1} + c_{i+1,1})/2$

つまり、ユーザ制御点と中央のベジェ制御点が同じ場所にあり、
その他のベジェ制御点は隣接制御点の中点にある状態から始まります。
image.png
これは論文から引用した図で、初期化時の状態の例です。
黒い四角がユーザ制御点$p_i$で、$c_{i,1}$と一致しています。緑色の点は各セグメントの曲率極大点$c_i(t_i)$です。
今はまだ$p_i$と$c_i(t_i)$が離れていますが、この後のStep1~4のイテレーションを回すことで近づけていきます。
image.png
左から順に、初期状態、1ループ後、2ループ後、30ループ後(完全収束)です。

Step1. λの算出

式(4):

\lambda_i=\frac{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}}{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}+\sqrt{|\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})|}}

を適用します。やるだけ。

Step2. ベジェ制御点(両端)の更新

式(2):

c_{i,2}=c_{i+1,0}=(1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1}

を計算します。これもやるだけ。

Step3. 曲率極大点の算出

式(1):

\|c'_{i,2}\|^2t_i^3-3c'_{i,2}p'_it_i^2+(2p_i'+c_{i,2}')p_i't_i-\|p_i'\|^2=0\ \ \ (c_{i,2}'=c_{i,2}-c_{i,0},\ p_i'=p_i-c_{i,0})

を解きます。
実解がただ一つ$[0,1]$に存在することが分かっています。
カルダノの公式を愚直に組んでもいいですが(論文はそうしてるみたい?)、場合分けや例外処理が面倒なので私は二分探索しました。
計測してみたら二分探索が処理時間の8割だったのでさすがにやめました。カルダノの公式を組みました。

Step4. ベジェ制御点(中央)の更新

式(3):

p_i=(1-\lambda_{i-1})(1-t_i)^2c_{i-1,1}+\big(\lambda_{i-1}(1-t_i)^2+(2-(1+\lambda_i)t_i)t_i\big)c_{i,1}+\lambda_it_i^2c_{i+1,1}

を、全ての$c_{i,1}$について解きます。多分一番のめんどくさポイントです。

まず、係数を$\alpha_i, \beta_i, \gamma_i$として見やすく書き直しておきます。

p_i=\alpha_ic_{i-1,1}+\beta_ic_{i,1}+\gamma_ic_{i+1,1}

これは、以下のような連立方程式にまとめることができます。

\left(\begin{matrix}
\beta_0&\gamma_0&&&\alpha_0\\
\alpha_1&\beta_1&\gamma_1&&\\
&&\ddots&&\\
&&\alpha_{n-2}&\beta_{n-2}&\gamma_{n-2}\\
\gamma_{n-1}&&&\alpha_{n-1}&\beta_{n-1}
\end{matrix}\right)
\left(\begin{matrix}
c_{0,1}\\
c_{1,1}\\
\vdots\\
c_{n-2,1}\\
c_{n-1,1}\\
\end{matrix}\right)
=
\left(\begin{matrix}
p_0\\
p_1\\
\vdots\\
p_{n-2}\\
p_{n-1}\\
\end{matrix}\right)

行列部分は三重対角行列+角なので、高速にLU分解でき、解を$O(n)$で求めることができます。
さらに、これを上下に1つずつ拡張して

\left(\begin{matrix}
1&0&&&\\
\alpha_0&\beta_0&\gamma_0&&\\
&&\ddots&&\\
&&\alpha_{n-1}&\beta_{n-1}&\gamma_{n-1}\\
&&&0&1
\end{matrix}\right)
\left(\begin{matrix}
c_{n-1,1}\\
c_{0,1}\\
\vdots\\
c_{n-1,1}\\
c_{0,1}\\
\end{matrix}\right)
=
\left(\begin{matrix}
c_{n-1,1}\\
p_0\\
\vdots\\
p_{n-1}\\
c_{0,1}\\
\end{matrix}\right)

という形にすれば、行列部分はただの三重対角行列になるので、さらに計算が楽になります。

また、$n\ge5$の場合はメモリ的にも有利になります。
角つき三重対角行列はLU分解のためにメモリを$n\times n$要素分、頑張って削減しても$5\times n$要素分くらい食うのに対し、上下に伸ばした三重対角行列は$3\times (n+2)$要素分で済むのです。

LU分解については記事の最後の付録で解説します。

実装

実際に実装していきましょう。
言語はC#で、座標表現や各種演算にUnityのVector2クラス、Mathfクラスを借りています。
Unity特有の何かがあるわけではないので、適宜好きな言語、好きなベクトル表現・数学ライブラリに置き換えてください。

ベジェ制御点用構造体

ただの配列のラッパーです。計算結果の出力もこのインスタンスで。
配列サイズは最低限の$2n+1$ですが、ここまでの解説に合わせて[i,j]でアクセスできるようにしておきます。

public struct BezierControls
{
    //ベジェ制御点群
    //c_{0,0}, c_{0,1}, c_{1,0}, ..., c_{n-1,0}, c_{n-1,1}, c_{n-1,2}の順
    public Vector2[] Points { get; private set; }

    //セグメント数
    public int SegmentCount { get; private set; }

    //c_{i,j}
    public Vector2 this[int i, int j]
    {
        get => Points[2 * i + j];
        set => Points[2 * i + j] = value;
    }

    //コンストラクタ
    public BezierControls(int n)
    {
        SegmentCount = n;
        Points = new Vector2[2 * n + 1];
    }
}

計算空間の確保

ユーザ制御点が移動する度に描画を更新するわけなので、計算空間は事前に確保して使い回しましょう。
制御点が増減すると確保し直しになりますが、その辺りを考慮するとコードが煩雑になるのでここでは妥協。

public class CalcSpace
{
    internal int N { get; private set; }            //ユーザ制御点数
    internal float[] L { get; private set; }        //λ_i
    internal BezierControls C { get; private set; } //ベジェ制御点
    internal double[] T { get; private set; }       //t_i
    internal double[] A { get; private set; }       //Step4で使う行列

    public CalcSpace(int n)
    {
        N = n;
        L = new float[n];
        C = new BezierControls(n);
        T = new double[n];
        A = new double[(n+2) * 3];
    }
}

ユーザ制御点・ベジェ制御点の上下拡張

Step4の行列計算時にユーザ制御点ベクトルとベジェ制御点ベクトルを上下拡張しますが、その際のメモリ確保をなくしつつちゃんと配列っぽく扱えるようにするためのラッパー構造体です。
コンストラクタで上下拡張時の値の初期化もやっています。
本質部分ではないしC#の人じゃないとたぶん意味が分からないので適当に読み飛ばしてください。

struct ExtendedPlayerControls
{
    Vector2 top;
    Vector2[] ps;
    Vector2 bottom;

    public int Length => ps.Length + 2;
    public int BaseLength => ps.Length;

    public Vector2 this[int i]
    {
        get => i == 0 ? top : i == ps.Length + 1 ? bottom : ps[i - 1];
        set
        {
            if (i == 0) top = value;
            else if (i == ps.Length + 1) bottom = value;
            else ps[i - 1] = value;
        }
    }

    public ExtendedPlayerControls(Vector2[] ps, BezierControls cs)
    {
        top = cs[cs.SegmentCount-1,1];
        this.ps = ps;
        bottom = cs[0,1];
    }
}
struct ExtendedBezierControls
{
    Vector2 top;
    Vector2[] cs;
    Vector2 bottom;
    public int BaseLength { get; private set; }
    public int Length => BaseLength + 2;

    public Vector2 this[int i]
    {
        get => i == 0 ? top : i == BaseLength + 1 ? bottom : cs[i*2-1];
        set
        {
            if (i == 0) top = value;
            else if (i == BaseLength + 1) bottom = value;
            else cs[i*2-1] = value;
        }
    }

    public ExtendedBezierControls(BezierControls v)
    {
        BaseLength = v.SegmentCount;
        top = v[BaseLength- 1, 1];
        cs = v.Points;
        bottom = v[0, 1];
    }
}

メソッドルート

処理の根本はこんな感じでよいでしょう。

public static BezierControls CalcBezierControls(Vector2[] points, CalcSpace space, int iteration, bool isLoop)
{
    Step0(points, space.C, space.L, space.A, isLoop);
    for (int i = 0; i < iteration; i++)
    {
        Step1(space.C, space.L, isLoop);
        Step2(space.C, space.L);
        Step3(points, space.C, space.T);
        Step4(points, space.C, space.L, space.T, space.A, isLoop);
    }
    return space.C;
}
  • ユーザ制御点 points
  • 計算空間 space
  • イテレーション回数 iteration
  • 曲線をループさせるかどうか isLoop

を受け取り、Step0で初期化し、Step1~4でイテレーションを回し、最適化の結果を返します。

では、各Stepの実装をしましょう。
ループしない場合の対応も一緒にやっていきます。

Step0: 初期化

初期化内容はこうでした。

  • $\lambda_i=0.5$
  • $c_{i,1}=p_i$
  • $c_{i,2}=c_{i+1,0} = (1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1}$

非ループの場合、始端と終端はユーザ制御点であってほしいので、初期化時に固定してしまいましょう。

  • $\lambda_0=0$
  • $\lambda_{n-2}=1$
  • $\lambda_{n-1}=\rm{undefined}$

非ループの場合、終端→始端のカーブは必要なくなるので、ループの場合よりセグメントが2つ減ることに注意してください。その結果、$\lambda_{n-1}$は参照されなくなります。

セグメントが1つではなく2つ減るのは直感的ではありませんが、実際に見れば納得できるでしょう。
コメント 2020-02-04 122030.png
黄色・水色・ピンクの線が各セグメントのベジェ制御点を結んだものです。
非ループの場合、水色とピンクの線が潰れているのが分かるでしょうか。

ついでに、Step4で使う行列(を保存するためのメモリ)の両端部の初期化もしてしまいます:

A=\left(\begin{matrix}
0&1&0\\
\alpha_0&\beta_0&\gamma_0\\
&\vdots&\\
\alpha_{n-1}&\beta_{n-1}&\gamma_{n-1}\\
0&1&0\\
\end{matrix}\right)

非ループの場合、$c_{0,1}=p_0,\ c_{n-1,1}=p_{n-1}$となればよいので、

A=\left(\begin{matrix}
0&1&0\\
0&1&0\\
\alpha_1&\beta_1&\gamma_1\\
&\vdots&\\
\alpha_{n-2}&\beta_{n-2}&\gamma_{n-2}\\
0&1&0\\
0&1&0\\
\end{matrix}\right)

とします。

コードはこんな感じ。

static void Step0(Vector2[] ps, BezierControls cs, float[] lambdas, double[] A, bool isLoop)
{
    var n = ps.Length;

    //全てのλを0.5で初期化
    for (var i = 0; i < n; i++)
        lambdas[i] = 0.5f;

    //ループしない場合、最初と最後から2番目を0,1に変更(最後はそもそも使わない)
    if (!isLoop)
    {
        lambdas[0] = 0;
        lambdas[ps.Length - 2] = 1;
    }

    //中央のベジェ制御点を全てユーザ制御点で初期化
    for (var i = 0; i < n; i++)
        cs[i, 1] = ps[i];

    //他のベジェ制御点を初期化
    for (var i = 0; i < n; i++)
    {
        var next = (i + 1) % n;
        cs[next, 0] = cs[i, 2] = (1 - lambdas[i]) * cs[i, 1] + lambdas[i] * cs[next, 1];
    }

    //行列の端の値は固定
    A[0] = 0;
    A[1] = 1;
    A[2] = 0;
    A[A.Length - 1] = 0;
    A[A.Length - 2] = 1;
    A[A.Length - 3] = 0;
    if (!isLoop)
    {
        //非ループの場合はさらにもう一行ずつ固定
        A[3] = 0;
        A[4] = 1;
        A[5] = 0;
        A[A.Length - 4] = 0;
        A[A.Length - 5] = 1;
        A[A.Length - 6] = 0;
    }
}

Step1: λの算出

\lambda_i=\frac{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}}{\sqrt{|\triangle(c_{i,0},c_{i,1},c_{i+1,1})|}+\sqrt{|\triangle(c_{i,1},c_{i+1,1},c_{i+1,2})|}}

三角形の面積を求める解説は必要ないでしょう。外積の半分です。
非ループ時は始端と終端のλは更新しません。

static void Step1(BezierControls cs, float[] lambdas, bool isLoop)
{
    //三角形の面積を求める関数
    float TriArea(Vector2 p1, Vector2 p2, Vector2 p3)
    {
        p1 -= p3; p2 -= p3;
        return Mathf.Abs(p1.x * p2.y - p2.x * p1.y) / 2f;
    }

    var n = lambdas.Length;
    int begin = isLoop ? 0 : 1;
    int end = isLoop ? n : n - 2;
    for (var i = begin; i < end; i++)
    {
        var next = (i + 1) % n;
        var c = cs.Points;
        var t1 = TriArea(c[i*2], c[i*2+1], c[next*2+1]);
        var t2 = TriArea(c[i*2+1], c[next*2+1], c[next*2+ 2]);
        if (Mathf.Abs(t1 - t2) < 0.00001f)
            lambdas[i] = 0.5f;
        else
            lambdas[i] = (t1 - Mathf.Sqrt(t1 * t2)) / (t1 - t2);   
    }
}

Sqrt計算を減らすために一応有理化して、分母がほぼ0のときは計算させず0.5にしています。

Step2: ベジェ制御点(両端)の更新

c_{i,2}=c_{i+1,0}=(1-\lambda_i)c_{i,1}+\lambda_ic_{i+1,1}

やるだけです。
$\lambda_i$の方で非ループ対応はしているので、ここでは特に何もしません。

static void Step2(BezierControls cs, float[] lambdas)
{
    var n = lambdas.Length;
    for (var i = 0; i < n - 1; i++)
    {
        cs[i + 1, 0] = (1 - lambdas[i]) * cs[i, 1] + lambdas[i] * cs[i + 1, 1];
    }
    cs[0, 0] = cs[n - 1, 2] = (1 - lambdas[n - 1]) * cs[n - 1, 1] + lambdas[n - 1] * cs[0, 1];
}

BezierControlsの実体は最後以外の$c_{i,2}$を削った配列なので、最後以外は片方だけに代入しています。

Step3: 曲率極大点の算出

三次方程式:

\|c'_{i,2}\|^2t_i^3-3c'_{i,2}p'_it_i^2+(2p_i'+c_{i,2}')p_i't_i-\|p_i'\|^2=0\ \ \ (c_{i,2}'=c_{i,2}-c_{i,0},\ p_i'=p_i-c_{i,0})

を任意の方法で解きます。
$[0,1]$に実解があることが分かっているので、ここでは実装が簡単な二分探索で。
処理時間コストがバカ高かったのでカルダノの公式で組み直しました。

$ax^3+bx^2+cx+d=0$の$[0,1]$内の実解のみを返すカルダノの公式:

static double SolveCubicEquation(double a, double b, double c, double d)
{
    //負の値に対応した3乗根
    double Cbrt(double x) => Math.Sign(x) * Math.Pow(Math.Abs(x), 1.0 / 3);

    var A = b / a;
    var B = c / a;
    var C = d / a;
    var p = (B - A * A / 3) / 3;
    var q = (2.0 / 27 * A * A * A - A * B / 3 + C) / 2;
    var D = q * q + p * p * p;
    var Ad3 = A / 3;

    if (Math.Abs(D) < 1.0E-12)
    {
        return Cbrt(q) - Ad3;
    }
    else if (D > 0)
    {
        var sqrtD = Math.Sqrt(D);
        var u = Cbrt(-q + sqrtD);
        var v = Cbrt(-q - sqrtD);
        return u + v - Ad3;
    }
    else //D < 0
    {
        var tmp = 2 * Math.Sqrt(-p);
        var arg = new Complex(-q, Math.Sqrt(-D)).Euler.theta / 3;
        var pi2d3 = 2 * Math.PI / 3;
        var X1mAd3 = tmp * Math.Cos(arg) - Ad3;
        if (0 <= X1mAd3 && X1mAd3 <= 1) return X1mAd3;

        var X2mAd3 = tmp * Math.Cos(arg + pi2d3) - Ad3;
        if (0 <= X2mAd3 && X2mAd3 <= 1) return X2mAd3;

        var X3mAd3 = tmp * Math.Cos(arg + pi2d3 + pi2d3) - Ad3;
        if (0 <= X3mAd3 && X3mAd3 <= 1) return X3mAd3;

        return 0;
    }
}

参考:このページこのページ

これを使って、全ての$i$について三次方程式を解きます。

static void Step3(Vector2[] ps, BezierControls cs, double[] ts)
{
    for (int i = 0; i < ts.Length; i++)
    {
        var c2 = cs[i, 2] - cs[i, 0];
        var p = ps[i] - cs[i, 0];

        //隣接3制御点が重なっている場合、a,b,c,dが全て0になり破綻するので既定値を返す。
        if (c2 == Vector2.zero && p == Vector2.zero)
        {
            ts[i] = 0.5f;
            continue;
        }

        double a = c2.sqrMagnitude;
        double b = -3 * Vector2.Dot(c2, p);
        double c = Vector2.Dot(2 * p + c2, p);
        double d = -p.sqrMagnitude;

        ts[i] = (float)SolveCubicEquation(a, b, c, d);
    }
}

座標表現をfloatでしていても、公式はdoubleで実装しないと桁落ちで破綻します。

Step4: ベジェ制御点(中央)の更新

\left(\begin{matrix}
1&0&&&\\
\alpha_0&\beta_0&\gamma_0&&\\
&&\ddots&&\\
&&\alpha_{n-1}&\beta_{n-1}&\gamma_{n-1}\\
&&&0&1
\end{matrix}\right)
\left(\begin{matrix}
c_{n-1,1}\\
c_{0,1}\\
\vdots\\
c_{n-1,1}\\
c_{0,1}\\
\end{matrix}\right)
=
\left(\begin{matrix}
c_{n-1,1}\\
p_0\\
\vdots\\
p_{n-1}\\
c_{0,1}\\
\end{matrix}\right)

を解きます。ただし、

\begin{align}
\alpha_i&=(1-\lambda_{i-1})(1-t_i)^2\\
\beta_i&=\lambda_{i-1}(1-t_i)^2+(2-(1+\lambda_i)t_i)t_i\\
\gamma_i&=\lambda_it_i^2\\
\end{align}

です。
なお非ループの場合、$i=0,n-1$についてはStep0で初期化したように

\begin{align}
\alpha_0&=\alpha_{n-1}=0\\
\beta_0&=\beta_{n-1}=1\\
\gamma_0&=\gamma_{n-1}=0\\
\end{align}

で固定なので、上書きしないようにします。

まずは三重対角行列の連立方程式を解く関数を作っておきます。
三重対角行列のLU分解について、詳細は記事の最後に付録として載せてあります。

static void SolveTridiagonalEquation(double[] A, ExtendedBezierControls x, ExtendedPlayerControls b)
{
    var n = b.BaseLength;

    //LU分解
    {
        for (var i = 0; i < n + 1; i++)
        {
            A[(i + 1) * 3] /= A[i * 3 + 1];
            A[(i + 1) * 3 + 1] -= A[(i + 1) * 3] * A[i * 3 + 2];
        }
    }

    //Ly=b
    {
        //対角要素は全て1なので、最上行はそのまま
        x[0] = b[0];
        for (var i = 1; i < n + 1; i++)
        {
            //対角要素の左隣の要素を対応するx(計算済み)にかけて引く。
            x[i] = b[i] - (float)A[i * 3] * x[i - 1];
        }
    }

    //Ux=y
    {
        //最下行はただ割るだけ
        x[n+1] /= (float)A[(n+1)*3 + 1];
        for (var i = n; i >= 0; i--)
        {
            //対角要素の右隣の要素を対応するx(計算済み)にかけて引いて割る。
            x[i] = (x[i] - (float)A[i * 3 + 2] * x[i+1]) / (float)A[i * 3 + 1];
        }
    }
}

あとはAを組み立ててユーザ制御点・ベジェ制御点を上下拡張して実行するだけです。
が、一つ注意点として、Aはフルランクである必要があります。
ユーザ制御点が3つ以上連続して同じ位置にある場合($t_i=1\wedge t_{i+1}=0$)にランクが落ちるので、微調整をかけます。

static void Step4(Vector2[] ps, BezierControls cs, float[] lambdas, double[] ts, double[] A, bool isLoop)
{
    var n = ps.Length;

    //係数行列Aを構成(端の部分はStep0で初期化済)
    {
        for (int i = isLoop ? 0 : 1; i < (isLoop ? n : (n-1)); i++)
        {
            var ofs = (i+1) * 3;
            var next = (i + 1) % n;
            var prev = (i - 1 + n) % n;

            //ランクが下がってしまう場合微調整
            if (ts[i] == 1 && ts[next] == 0)
                ts[i] = 0.99999f;

            var tmp = (1 - ts[i]) * (1 - ts[i]);
            A[ofs] = (1 - lambdas[prev]) * tmp;
            A[ofs + 1] = lambdas[prev] * tmp + (2 - (1 + lambdas[i]) * ts[i]) * ts[i];
            A[ofs + 2] = lambdas[i] * ts[i] * ts[i];
        }
    }

    //入出力ベクトルを拡張
    var extendedPs = new ExtendedPlayerControls(ps,cs);
    var extendedCs = new ExtendedBezierControls(cs);

    //連立方程式を解く
    SolveTridiagonalEquation(A, extendedCs, extendedPs);
}

プロット

これでκ-Curvesのシステムは完成ですが、まだベジェ曲線の制御点が算出できただけなので、描画する必要があります。
実際に画面に映すのは各描画ライブラリにやってもらうとして、そのための点群を用意しなければなりません。

とは言っても、各セグメントについて、↓これにtを順番に突っ込めばいいだけです。

c_i(t)=(1-t)^2c_{i,0} + 2(1-t)tc_{i,1} + t^2c_{i,2}
static Vector2 PlotSingle(Vector2 c0, Vector2 c1, Vector2 c2, float t)
{
    return (1 - t) * (1 - t) * c0 + 2 * (1 - t) * t * c1 + t * t * c2;
}

計算スペースのときのように、プロット用のスペースも確保しておきましょう。
セグメント数は非ループ時は2つ少なくなることに注意。

public class PlotSpace
{
    public int N { get; private set; }
    public int StepPerSegment { get; private set; }
    public Vector2[] Plots { get; private set; }

    public PlotSpace(int n, int stepPerSegment, bool isLoop)
    {
        N = n;
        StepPerSegment = stepPerSegment;
        if (n < 3)
            Plots = new Vector2[n];
        else
            Plots = new Vector2[(isLoop ? n : (n - 2)) * stepPerSegment + 1];
    }
}

あとは、頂点数が2つ以下のときの例外処理を挟みつつ、プロッティングしていくだけ。

public static Vector2[] CalcPlots(Vector2[] points, CalcSpace calcSpace, PlotSpace plotSpace, int iteration, int stepPerSegment, bool isLoop)
{
    //ユーザ制御点数2以下のときは個別処理
    if (points.Length == 0)
    {
        return plotSpace.Plots;
    }
    if (points.Length == 1)
    {
        plotSpace.Plots[0] = points[0];
        return plotSpace.Plots;
    }
    if (points.Length == 2)
    {
        plotSpace.Plots[0] = points[0];
        plotSpace.Plots[1] = points[1];
        return plotSpace.Plots;
    }

    //ベジェ制御点を計算
    var cs = CalcBezierControls(points, calcSpace, iteration, isLoop);

    //各セグメントについて、指定されたステップ数で分割した点を計算
    int offset;
    int k;
    for (k = 0; k < points.Length - (isLoop ? 0 : 2); k++)
    {
        offset = k * stepPerSegment;
        var nextk = (k + 1) % points.Length;
        for (var i = 0; i < stepPerSegment; i++)
        {
            plotSpace.Plots[offset + i] = CalcPlotSingle(cs[nextk, 0], cs[nextk, 1], cs[nextk, 2], i / (float)stepPerSegment);
        }
    }
    var last = isLoop ? 0 : k;
    plotSpace.Plots[plotSpace.Plots.Length - 1] = CalcPlotSingle(cs[last, 0], cs[last, 1], cs[last, 2], 1);
    return plotSpace.Plots;
}

実行側

以上を全てKCurvesクラスに実装したとして、以下のようにすれば描画用の点群を取得できます。

//イテレーション回数
var iteration = 10;
//ループするかどうか
var isLoop = true;
//セグメントごとの分割数
var step = 20;

//ユーザ制御点を更新
Vector2[] points = /*更新処理*/;

//計算用空間確保(本来はキャッシュしておく)
var cSpace = new KCurves.CalcSpace(points.Length);
//プロット用空間確保(本来はキャッシュしておく)
var pSpace = new KCurves.PlotSpace(points.Length, step, isLoop);
//実行
var output = KCurves.CalcPlots(points, cSpace, pSpace, iteration, step, isLoop);

お疲れさまでした。

結果

Unity上で、1セグメントにつき20ステップで描画してみた結果です。
image.png

ベジェ制御点も表示してみるとこんな感じ。
image.png

同じ配置でループさせるとこうなります。
image.png

バグを修正(?)する

はい、まだ終わってません。
ここまでの実装で実際に描画してみて、ユーザ制御点をめちゃくちゃ動かしまくってみると分かりますが、特定の状況下において、適切な場所に収束しません
image.png
なんか、飛び出しています。よく見ると接続点の傾きも不連続になっています。
尖った領域かつユーザ制御点が近接している場合に起こりがちです。

この問題は、何故かよく分かりませんが、Step1の回数を抑えると抑制されます。
また傾きの連続性については、最後にStep2を一度実行することでとりあえず解消はされそうです。
なので、

public static BezierControls CalcBezierControls(Vector2[] points, CalcSpace space, int iteration, bool isLoop)
{
    Step0(points, space.C, space.L, space.A, isLoop);
    for (int i = 0; i < iteration; i++)
    {
        if (i < 3 || i < iteration / 2)
            Step1(space.C, space.L, isLoop);
        Step2(space.C, space.L);
        Step3(points, space.C, space.T);
        Step4(points, space.C, space.L, space.T, space.A, isLoop);
    }
    Step2(space.C, space.L);
    return space.C;
}

これで直ります。必要な最適化を削っていることになるので、どれくらいを境にするかはお好みで。
image.png
ほら直った。
うーんひどい。不具合の原因がちゃんと分かった方はぜひご連絡ください。

それでも飛び出ることはある

具体的には、極薄極小のセグメントの存在が問題のようです。
例えばこんな場合。
新規キャンバス1.png

これはもう何というか、曲線ツールで鋭角を描こうとしているのが悪いです。
超鋭角の部分を検知して、その点で切断して2つのκ-Curvesに分けるといいかもしれません。

おわりに

κ-Curvesは、一昨年に大学で出た発展課題の一つでした。
そこで存在を知ったわけですが、非常に便利なので普段のゲーム開発にも流用しています。
ゲームのランタイムで動かすにはちょっと重いかもですが、イテレーションを抑えれば使えないほどでもなく、
またエディタ拡張でパスを生成するときとかには大活躍です。
ぜひ取り入れてみてください。

ちなみに課題で提出したのはこれです:
https://k-curves.glitch.me/project/kadai/main.html
JavaScript実装、ループ非対応。キャンバスの初期値は某心ぴょんぴょんアニメのトレスです。

付録

三重対角行列のLU分解

まずLU分解とは、正方行列を下三角行列$L$と上三角行列$U$の積に分解する操作です。
$Ax=b$という連立方程式があるとき、$A=LU$とLU分解することで、$Ly=b$と$Ux=y$という2つの連立方程式に分離することができます。
三角行列の連立方程式は簡単に$O(n^2)$で解ける(前進代入・後退代入)ので、$A$がLU分解されていれば、ガウスの消去法を使った$O(n^3)$の解法より速くなります。
一般のLU分解は$O(n^3)$かかってしまうので、これは同じAを何度も利用する際にのみ有効な手段ですが、
$A$が三重対角行列の場合、LU分解もその後の計算も全て$O(n)$になります。

$L$の対角成分を1に固定しましょう。このとき、三重対角行列のLU分解$A=LU$の最初の様子は以下のように表せます。

\left(\begin{matrix}
a_{11}&a_{12}&&O\\
a_{21}&&&\\
&&A'&\\
O&&&
\end{matrix}\right)
=

\left(\begin{matrix}
1&&&O\\
l_{21}&&&\\
&&L'&\\
O&&&
\end{matrix}\right)

\left(\begin{matrix}
u_{11}&u_{12}&&O\\
&&&\\
&&U'&\\
O&&&
\end{matrix}\right)

成分を比較すると、

\begin{align}
a_{11}&=u_{11}\\
a_{12}&=u_{12}\\
a_{21}&=u_{11}l_{21}\\
A'&=L'U'+\left(\begin{matrix}
l_{21}u_{12}&\\
&O
\end{matrix}\right)
\end{align}

なので、

\begin{align}
u_{11}&=a_{11}\\
u_{12}&=a_{12}\\
l_{21}&=\frac{a_{21}}{a_{11}}\\
\end{align}

として

A'-\left(\begin{matrix}
l_{21}u_{12}&\\
&O
\end{matrix}\right)=L'U'

を再帰的にLU分解していけばよいことが分かります。
$O(1)$の$n$回ループなので$O(n)$です。

この計算では分解し終わった部分が後で必要になることはなく、さらに$L$の対角成分は全て1なので、$L$と$U$を重ね合わせることで$A$のメモリのみを使って分解できます。

\left(\begin{matrix}
a_{11}&a_{12}&&O\\
a_{21}&a_{22}&a_{23}&\\
&a_{32}&\ddots&\\
O&&&
\end{matrix}\right)
\rightarrow

\left(\begin{matrix}
u_{11}&u_{12}&&O\\
l_{21}&u_{22}&u_{23}&\\
&l_{32}&\ddots&\\
O&&&
\end{matrix}\right)

元が三重対角行列なので、$L,U$は共に各行2つ以下の要素しか持ちません。
そのため、前進代入・後退代入の計算量も$O(n)$となります。

角つき三重対角行列のLU分解

右上と左下に角がついている場合(行列サイズを拡張しない場合)も、少し複雑にはなりますが$O(n)$で分解できます。

\left(\begin{matrix}
a_{11}&a_{12}&O&a_{1n}\\
a_{21}&&&\\
O&&A'&\\
a_{n1}&&&
\end{matrix}\right)
=

\left(\begin{matrix}
1&&&O\\
l_{21}&&&\\
O&&L'&\\
l_{n1}&&&
\end{matrix}\right)

\left(\begin{matrix}
u_{11}&u_{12}&O&u_{1n}\\
&&&\\
&&U'&\\
O&&&
\end{matrix}\right)

成分を比較すると、

\begin{align}
u_{11}&=a_{11}\\
u_{12}&=a_{12}\\
l_{21}&=\frac{a_{21}}{a_{11}}\\
u_{1n}&=a_{1n}\\
l_{n1}&=\frac{a_{n1}}{a_{11}}\\
\end{align}

なので、

A'-\left(\begin{matrix}
\displaystyle\frac{a_{21}a_{12}}{a_{11}}&O&\displaystyle\frac{a_{21}a_{1n}}{a_{11}}\\
O&O&O\\
\displaystyle\frac{a_{n1}a_{12}}{a_{11}}&O&\displaystyle\frac{a_{n1}a_{1n}}{a_{11}}\\
\end{matrix}\right)=L'U'

となり、再帰的に分解できます。
ただし、$A'$のサイズが1のときはこの四隅は同じ位置を指すので、重複して引いてしまわないよう注意が必要です。

また、前進代入・後退代入も変わらず$O(n)$ではありますが、$L$は最下一行、$U$は最右一列が追加で埋まっています。
$L$も$U$も左からかけるので、この追加行・列の扱いは非対称的です。
$4\times 4$行列くらいの具体例で実際に手を動かしてみると実感できると思います。

具体例:

\left(\begin{matrix}
2&0&0&2\\
0&1&1&0\\
0&2&4&2\\
4&0&4&9\\
\end{matrix}\right)
\left(\begin{matrix}
1\\
2\\
3\\
4\\
\end{matrix}\right)
=
\left(\begin{matrix}
10\\
5\\
24\\
52\\
\end{matrix}\right)
\left(\begin{matrix}
2&0&0&2\\
0&1&1&0\\
0&2&4&2\\
4&0&4&9\\
\end{matrix}\right)
=
\left(\begin{matrix}
1&0&0&0\\
0&1&0&0\\
0&2&1&0\\
2&0&2&1\\
\end{matrix}\right)
\left(\begin{matrix}
2&0&0&2\\
0&1&1&0\\
0&0&2&2\\
0&0&0&1\\
\end{matrix}\right)

(参考)パフォーマンス計測結果

私の環境でのパフォーマンス計測結果です。

n iteration time(ms)
30 5 0.1232
15 10 0.1177
30 10 0.2296
60 10 0.4564
120 10 0.9101
1000 10 7.516
環境
OS Windows10 Home
CPU Intel Core i7-8700
RAM 16GB
GPU NVIDIA GeForce GTX 970
その他 Unity2019.3.0f6上で実行

時間は10000回の平均値(四捨五入)で、CalcBezierControls()の時間のみを計測しています。

はい。$O(n\times \rm{iteration})$です。イテレーションは実用上は5~10回くらいで充分なので、C#実装でこれならそこそこ実用的な速度が出るかなと。

一応計測用コード(要Unity):

[MenuItem("Tools/Test")]
static void _()
{
    int n = 30;
    int iter = 10;
    int loop = 10000;

    var path = new bool[n].Select(_ => new Vector2(Random.value, Random.value)).ToArray();
    var cSpace = new KCurves.CalcSpace(n);
    var sw = System.Diagnostics.Stopwatch.StartNew();
    for (int i = 0; i < loop; i++)
    {
        KCurves.CalcBezierControls(path, cSpace, iter, true);
    }
    Debug.Log((double)sw.ElapsedTicks / System.Diagnostics.Stopwatch.Frequency * 1000.0 / loop);
}

複素数構造体

カルダノの公式でちょっと使ってたの忘れてました。
何の変哲もない複素数structですが、一応載せておきます。

struct Complex : IEquatable<Complex>
{
    public double a;
    public double b;
    public Complex(double a, double b) => (this.a, this.b) = (a, b);
    public (double r, double theta) Euler => (Math.Sqrt(a * a + b * b), Math.Atan2(b, a));
    public static bool operator ==(Complex lhs, Complex rhs)
    {
        return lhs.a == rhs.a && lhs.b == rhs.b;
    }
    public static bool operator !=(Complex lhs, Complex rhs)
    {
        return lhs.a != rhs.a || lhs.b != rhs.b;
    }
    public override string ToString()
    {
        if (b < 0)
            return $"{a}{b}i";
        else
            return $"{a}+{b}i";
    }
    public bool Equals(Complex other)
    {
        return a == other.a && b == other.b;
    }
    public override bool Equals(object obj)
    {
        if (obj is Complex v)
        {
            return Equals(v);
        }
        return false;
    }
    public override int GetHashCode()
    {
        return a.GetHashCode() ^ b.GetHashCode();
    }
}

参考文献

元論文

Zhipei Yan, Stephen Schiller, Gregg Wilensky, Nathan Carr, and Scott Schaefer. 2017.
K-curves: interpolation at local maximum curvature.
ACM Trans. Graph. 36, 4, Article 129 (July 2017), 7 pages.
DOI:https://doi.org/10.1145/3072959.3073692
http://faculty.cs.tamu.edu/schaefer/research/kcurves.pdf

カルダノの公式

http://hooktail.sub.jp/algebra/CubicEquation/
https://onihusube.hatenablog.com/entry/2018/10/08/140426

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

UnityからビルドしてOculus Quest内でキューブを見られるようになるまで

Unity Hubのインストール

Download - Unity
Unity HubはUnityのバージョン管理用アプリケーションらしい
最新版のUnityをUnity Hubを通してインストールしておく

Oculus側の設定や手順

基本的に以下の記事に従っていればok
Unityで作ったアプリをOculus Questで動かす - Qiita

ただ、ちょこちょこ細かいところで詰まった部分があったので以下対処法メモ

AndroidへSwitch Platformする

Unityの File > Build Settings > Android へ Switch Platformする時、
モジュールはそのウィンドウ上で案内されているリンクからダウンロードするのではなく、
Unity Hubの インストール > 自分が利用中のUnityのバージョンの右上にある三点メニュー > モジュールを加える > Android Build Support を選択して追加する
(リンクからダウンロードしたモジュールはエラーが出てインストールできない)

また、 Android Build Supportの文字の左にある下三角を押して Android SDK & NDK Tools と Open JDK もインストールする

Unityの Platform > External Tools にJDKとAndroid SDK/NGKの設定欄があるのでエラーが出ていないか確認しておく

参考 :
* Android SDK, NDKをUnity Hubからインストールする方法|安西先生、ゲームが作りたいです
* 【Oculus Go×Unity】JDKの設定にハマった【mac】 - Qiita

Renderingの設定

Build Setting > Player Setting >Other Settings>Rendering>Graphics APIs から Vulkan を選択し「-」を押して外しておく

参考 :
* UnityでVRアプリを作ったら上下が逆転してしまった - Qiita

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

[Unity] モバイル VR の APK 出力を Docker で構築するための手順

はじめに

最近 Go/GearVR 向けに作っていた VR アプリを Quest にも対応させる機会がありました :sunglasses:

その際 Go/GearVR と Quest の APK をそれぞれ出力する時に、
Go/GearVR と Quest の設定を切り替える度に ApplicationID を変更したり、
外部サービスのコンフィグファイル差し替え作業等が必要になることが判明しました。。:pick:

更に改修の度に検証やリリース用として、Go/GearVR と Quest 用含め、
計6つも APK を出力することが必要になりました。。:persevere:

仕方なく最初は手作業で APK を 6つ出力していたのですが、
それだけで 20分近くも時間がかかるようになってしまいました。。 :sob:
(しかも手作業だと誤った APK を出力してしまう時もあり、そうなるとやり直し。。:x:

流石にこのままだとマズイと感じたため、まずは APK 出力を Docker で自動化しました :robot:
Docker を採用した理由は様々な CI 環境に乗せようと思った時に好都合だからです :whale:

最終的に 6つの APK 出力にかかる時間は 20分 -> 10分 で完了するようになり、
誤った内容の APK が出力されることも無くなりました :thumbsup:

本記事では APK 出力を Docker で自動化するために行った対策手順について書いていきます :pencil:

動作環境

  • Unity 2018.4.9f1
  • Docker 19.03.5

アプリケーションの設定値を GUI で変更出来るようにする

まずは自動化を進めるにあたって、
EditorWindow で GUI でアプリケーションの設定値が編集が出来るようにします。
GUI で設定した値は ScriptableObject で管理出来るようにします。

ScriptableObject でアプリケーションの設定値を管理可能にする

今回は下記をアプリケーションの設定値として定義します :arrow_down:

  • EnvironmentType (Enum): STAGING PRODUCTION RELEASE
  • DeviceType (Enum): GEAR_VR_AND_GO QUEST

EnvironmentType は実行環境 (検証 / 本番 / リリース) が指定できる項目となります。
DeviceType は実行端末 (GearVR or Go / Quest) が指定できる項目となります。

実行環境や実行端末に応じて API のリクエスト先変更したり、
クレデンシャルを変更したり出来るようになる想定で用意しました :raised_hand:

また、アプリケーションの設定値を保存する際は、
実行端末を表す DeviceType の値に応じて Oculus Platform の App ID も変更するようにします :raised_hand:

早速アプリケーション設定値を管理するための ScriptableObject を作成します :writing_hand:

Assets/Scripts/ApplicationSetting.cs
using System.IO;
using UnityEngine;
using UnityEditor;

// アプリケーションの設定値を管理するための ScriptableObject
public class ApplicationSetting : ScriptableObject
{
    // 実行端末 (GearVR or Go / Quest) の Enum
    public enum DeviceType
    {
        GEAR_VR_AND_GO, QUEST
    }

    // 実行環境 (検証 / 本番 / リリース) の Enum
    public enum EnvironmentType
    {
        STAGING, PRODUCTION, RELEASE
    }

    // 実行端末 (GearVR or Go / Quest) を設定するための変数
    // 値の変更は Editor 上からのみ許可する
    [SerializeField]
    private DeviceType _device;
    public DeviceType Device
    {
        get { return _device; }
#if UNITY_EDITOR
        set { _device = value; }
#endif
    }

    // 実行環境 (検証 / 本番 / リリース) を設定するための変数
    // 値の変更は Editor 上からのみ許可する
    [SerializeField]
    private EnvironmentType _environment;
    public EnvironmentType Environment
    {
        get { return _environment; }
#if UNITY_EDITOR
        set { _environment = value; }
#endif
    }

    // ==========================================================
    // Editor 内でしか利用しない定数や関数群
#if UNITY_EDITOR
    // アプリケーションの設定値の保存先
    const string ASSET_FILE_PATH = "Assets/Resources/ApplicationSetting.asset";

    // GearVR もしくは Oculus Go 用の App ID
    const string GEAR_VR_AND_GO_APPID = "1111111111111111";

    // Quest 用の App ID
    const string QUEST_APPID = "2222222222222222";

    public static ApplicationSetting ReadFromEditor()
    {
        return AssetDatabase.LoadAssetAtPath<ApplicationSetting>(ASSET_FILE_PATH);
    }

    // アプリケーションの設定値を保存するために使用する関数
    public void Save()
    {
        // アプリケーション設定を保存するファイルが存在しなければ新たに生成する
        WriteFileIfNotExists();

        // Device 変数の値を元に適切な AppID を Oculus Platform に設定する
        SetAppID();

        // Unity の Inspector からの設定変更を許可しない
        this.hideFlags = HideFlags.NotEditable;

        // ScriptableObject に変更があったことを記録する
        EditorUtility.SetDirty(this);

        // ScriptableObject の変更内容を保存する
        AssetDatabase.SaveAssets();

        // ScriptableObject をインポートし直す
        AssetDatabase.Refresh();
    }

    // アプリケーションの設定値を保存するためのファイルを生成する関数
    void WriteFileIfNotExists()
    {
        // 既にファイルが存在していれば処理を中断する
        if (File.Exists(ASSET_FILE_PATH)) return;

        // ASSET_FILE_PATH で指定された場所にファイルの実体を作成する
        string directory = Path.GetDirectoryName(ASSET_FILE_PATH);
        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
        AssetDatabase.CreateAsset(this, ASSET_FILE_PATH);
    }

    // Device 変数の値を元に Oculus Platform の App ID の値を設定するために使用する関数
    void SetAppID()
    {
        string appId = "";
        switch (this.Device)
        {
            case DeviceType.GEAR_VR_AND_GO:
                appId = GEAR_VR_AND_GO_APPID;
                break;
            case DeviceType.QUEST:
                appId = QUEST_APPID;
                break;
        }
        Oculus.Platform.PlatformSettings.MobileAppID = appId;
    }
#endif
}

アプリケーションの設定値を管理するスクリプトが出来たので、
次は EditorWindow から設定が変更出来るようにします :thumbsup:

EditorWindow を利用して GUI からアプリケーションの設定値を変更可能にする

作成した ScriptableObject の値を EditorWindow から変更出来るようにします :gear:

Assets/Editor/ApplicationSettingWindow.cs
using UnityEngine;
using UnityEditor;

// アプリケーションの設定値を編集できる画面を EditorWindow で作成する
public class ApplicationSettingWindow : EditorWindow
{
    // アプリケーションの設定値を管理するための変数
    private ApplicationSetting applicationSetting;

    // メニューの Window -> Application Setting からアクセス可能にする
    [MenuItem("Window/Application Setting")]
    public static void Create()
    {
        // アプリケーションの設定値を編集できる画面を作成する
        GetWindow<ApplicationSettingWindow>("Application Setting");
    }

    private void OnEnable()
    {
        // 画面が有効化され次第、アプリケーション設定値をファイルから読み込む
        // ファイルから設定値が読み込めなかった場合は null が設定される
        applicationSetting = ApplicationSetting.ReadFromEditor();
    }

    private void OnGUI()
    {
        // applicationSetting が正しく初期化されていなかったら、
        // 新しくインスタンスを生成して applicationSetting に代入する
        if (applicationSetting == null)
        {
            applicationSetting = ScriptableObject.CreateInstance<ApplicationSetting>();
        }

        // 画面に縦並びに実行環境設定用の選択リスト、実行端末設定用の選択リスト、Save ボタン (アプリケーション設定保存用ボタン) を配置する
        using (new GUILayout.VerticalScope())
        {
            // 実行環境設定用の選択リストの値が変更される度に実行環境の値を設定する
            applicationSetting.Environment =
                (ApplicationSetting.EnvironmentType)EditorGUILayout.EnumPopup("Environment", applicationSetting.Environment);

            // 実行端末設定用の選択リストの値が変更される度に実行端末の値を設定する
            applicationSetting.Device =
                (ApplicationSetting.DeviceType)EditorGUILayout.EnumPopup("Device", applicationSetting.Device);

            // Save ボタンをクリックすることで、アプリケーションの設定値を保存 / 更新する
            if (GUILayout.Button("Save"))
            {
                applicationSetting.Save();
            }
        }
    }
}

上記スクリプトを Assets/Editor/ApplicationSettingWindow.cs に配置すると、
Unity メニューの Window -> Application Setting から、
アプリケーション設定値の編集画面に遷移することが出来るようになっているはずです:sunny:

スクリーンショット 2020-02-02 16.01.56.png

また、編集画面に遷移後、EnvironmentDevice を適当な値に設定してから
Save ボタンをクリックすると Resources フォルダにアプリケーション設定値を保存するためのファイルが生成されます :white_check_mark:

アプリケーション設定値を保存するためのファイルが生成されたのを確認した後、
編集画面を開き直すと最後に更新した値が反映されていることが確認出来ます :thumbsup:

スクリーンショット 2020-02-02 16.12.59.png

更に Unity メニューの Oculus -> Platform -> Edit SettingsApplication ID を見ると、
設定した Device に応じて値が変化している様子が確認出来ます :eyeglasses:

スクリーンショット 2020-02-02 16.28.32.png

Unity バッチモードで APK 出力が出来るようにする

次は Docker で APK 出力が出来るようにするために、
Unity バッチモード経由で APK 出力出来るようにします :hammer_pick:

APK の出力はプログラム経由でも実行することが可能です :arrow_down:

Assets/Editor/ApkBuilder.cs
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System;
using System.IO;

public class ApkBuilder
{
    // 第一引数で指定したコマンドライン引数の値の 1つ後ろの値を取得するための関数
    // 例: "-device QUEST" というパラメータの QUEST を取得したい場合は key に "-device" 文字列を指定する。
    static string GetValueFromCommandLineArgs(string key) {
        string[] args = System.Environment.GetCommandLineArgs();
        for (int i = 0; i < args.Length; i++) {
            if (key == args[i])
                return args[i + 1];
        }
        return null;
    }

    public static void Build()
    {
        // Unity バッチモードで関数を実行する際は keystore の設定が無いので、
        // コマンドライン引数から各種情報について渡すようにする必要がある
        if (PlayerSettings.Android.keystoreName.Length == 0 ||
            PlayerSettings.Android.keystorePass.Length == 0 ||
            PlayerSettings.Android.keyaliasName.Length == 0 ||
            PlayerSettings.Android.keyaliasPass.Length == 0)
        {
            PlayerSettings.Android.keystoreName = GetValueFromCommandLineArgs("-keystoreName");
            PlayerSettings.Android.keyaliasName = GetValueFromCommandLineArgs("-keyaliasName");
            PlayerSettings.Android.keystorePass = GetValueFromCommandLineArgs("-keystorePass");
            PlayerSettings.Android.keyaliasPass = PlayerSettings.Android.keystorePass;

            if (PlayerSettings.Android.keystoreName == null ||
                PlayerSettings.Android.keyaliasName == null ||
                PlayerSettings.Android.keystorePass == null)
            {
                Debug.LogError("Please set android keystore settings.");
                return;
            }
        }

        // Build Settings で設定シーンを全てビルドに含める
        EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes;
        List<string> scenePathList = new List<string>();

        foreach (EditorBuildSettingsScene scene in scenes)
        {
            scenePathList.Add(scene.path);
        }

        var applicationSetting = ApplicationSetting.ReadFromEditor();

        string device = applicationSetting.Device.ToString().ToLower();
        string environment = applicationSetting.Environment.ToString().ToLower();

        BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions();
        buildPlayerOptions.scenes = scenePathList.ToArray();

        // プロジェクト直下の build フォルダに quest_production_20200202.apk のようなファイル名の APK を出力する
        DateTime currentDate = DateTime.Now;
        string dateString = $"{currentDate.Year}{currentDate.Month}{currentDate.Day.ToString("D2")}";

        string outputDirectory = "build";
        if (!Directory.Exists(outputDirectory))
        {
            Directory.CreateDirectory(outputDirectory);
        }

        buildPlayerOptions.locationPathName = $"{outputDirectory}/{device}_{environment}_{dateString}.apk";
        buildPlayerOptions.target = BuildTarget.Android;
        buildPlayerOptions.options = BuildOptions.None;

        applicationSetting.Save();

        BuildPipeline.BuildPlayer(buildPlayerOptions);
    }
}

Assets/Editor/ApkBuilder.cs スクリプト内の Build 関数は
キーストアが既に存在している前提の作りとなっているため、
まだキーストアを作っていない方は こちらの手順 に従って、
予めキーストアファイルとエイリアスの作成を行っておきましょう :raising_hand:

また上記の ApkBuilder.Build 関数は Unity バッチモードからも実行出来ますが、
Editor 上からも実行出来るようになっています。

Editor 上からも APK 出力が確認できるようになっていると、
本当に正確な APK 出力が行われるかデバッグを行う際に便利です :thumbsup:

そこで、早速 Editor 上からも実行できるように、
Assets/Editor/ApplicationSettingWindow.csBuild ボタンを配置して、
ボタンをクリックしたら APK 出力出来るようにしてみましょう :dash:

Assets/Editor/ApplicationSettingWindow.cs
using UnityEngine;
using UnityEditor;

// アプリケーションの設定値を編集できる画面を EditorWindow で作成する
public class ApplicationSettingWindow : EditorWindow
{
    // アプリケーションの設定値を管理するための変数
    private ApplicationSetting applicationSetting;

    // メニューの Window -> Application Setting からアクセス可能にする
    [MenuItem("Window/Application Setting")]
    public static void Create()
    {
        // アプリケーションの設定値を編集できる画面を作成する
        GetWindow<ApplicationSettingWindow>("Application Setting");
    }

    private void OnEnable()
    {
        // 画面が有効化され次第、アプリケーション設定値をファイルから読み込む
        // ファイルから設定値が読み込めなかった場合は null が設定される
        applicationSetting = ApplicationSetting.ReadFromEditor();
    }

    private void OnGUI()
    {
        // applicationSetting が正しく初期化されていなかったら、
        // 新しくインスタンスを生成して applicationSetting に代入する
        if (applicationSetting == null)
        {
            applicationSetting = ScriptableObject.CreateInstance<ApplicationSetting>();
        }

        // 画面に縦並びに実行環境設定用の選択リスト、実行端末設定用の選択リスト、Save ボタン (アプリケーション設定保存用ボタン) を配置する
        using (new GUILayout.VerticalScope())
        {
            // 実行環境設定用の選択リストの値が変更される度に実行環境の値を設定する
            applicationSetting.Environment =
                (ApplicationSetting.EnvironmentType)EditorGUILayout.EnumPopup("Environment", applicationSetting.Environment);

            // 実行端末設定用の選択リストの値が変更される度に実行端末の値を設定する
            applicationSetting.Device =
                (ApplicationSetting.DeviceType)EditorGUILayout.EnumPopup("Device", applicationSetting.Device);

            // Save ボタンをクリックすることで、アプリケーションの設定値を保存 / 更新する
            if (GUILayout.Button("Save"))
            {
                // Unity の Inspector からの設定変更を許可しない
                applicationSetting.hideFlags = HideFlags.NotEditable;

                // ScriptableObject に変更があったことを記録する
                EditorUtility.SetDirty(applicationSetting);

                applicationSetting.Save();
            }

            // Build ボタンをクリックし、ApkBuilder.Build 関数を実行することで、
            // 現在のアプリケーション設定内容を元に APK をプロジェクト直下の build フォルダに生成する
            if (GUILayout.Button("Build"))
            {
                ApkBuilder.Build();
            }
        }
    }
}

これで Unity メニューの Window -> Application Setting を開いた際に、
Build ボタンが画面の最下部に表示されるようになったはずです :thumbsup:

試しに Build ボタンをクリックして APK 出力を行ってみましょう :arrow_down:

スクリーンショット 2020-02-02 21.38.55.png
スクリーンショット 2020-02-02 21.40.18.png

下記のコマンドをターミナルで入力すると、
build フォルダ内に APK が再度出力されること確認出来ると思います :raised_hand:
(APK ファイルが上書きされて更新日時が新しくなっているはず :sparkles:

# Windows の場合
"C:\Program Files\Unity\Editor\Unity.exe" \
-quit -batchmode -projectPath ~/Desktop/CISample \
-executeMethod ApkBuilder.Build \
-keystoreName ~/Desktop/CISample.keystore -keyaliasName cisample \
-keystorePass CISample

# Mac の場合
/Applications/Unity/Hub/Editor/2018.4.9f1/Unity.app/Contents/MacOS/Unity \
-quit -batchmode -projectPath ~/Desktop/CISample \
-executeMethod ApkBuilder.Build \
-keystoreName ~/Desktop/CISample.keystore -keyaliasName cisample \
-keystorePass CISample

あとは Unity バッチモード経由で様々な設定の APK 出力が出来るように、
Assets/Editor/ApkBuilder.cs を改修していきます :crab:

Unity バッチモードで引数を元に APK 出力の設定が出来るようにする

具体的には Assets/Editor/ApkBuilder.cs に下記のような改修を行います :arrow_down:

Assets/Editor/ApkBuilder.cs
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System;
using System.IO;
using DeviceType = ApplicationSetting.DeviceType;
using EnvironmentType = ApplicationSetting.EnvironmentType;

public class ApkBuilder
{
    // 第一引数で指定したコマンドライン引数の値の 1つ後ろの値を取得するための関数
    // 例: "-device QUEST" というパラメータの QUEST を取得したい場合は key に "-device" 文字列を指定する。
    static string GetValueFromCommandLineArgs(string key) {
        string[] args = System.Environment.GetCommandLineArgs();
        for (int i = 0; i < args.Length; i++) {
            if (key == args[i])
                return args[i + 1];
        }
        return null;
    }

    public static void Build()
    {
        var defaultApplicationSetting = ApplicationSetting.ReadFromEditor();
        var applicationSetting = ApplicationSetting.ReadFromEditor();

        // Unity バッチモードで実行時にコマンドライン引数の情報を元に
        // Device と Environment の設定を変更出来るようにする
        string deviceStringArg = GetValueFromCommandLineArgs("-device");
        string environmentStringArg = GetValueFromCommandLineArgs("-environment");

        if (deviceStringArg != null && environmentStringArg != null)
        {
            applicationSetting.Device = (DeviceType)Enum.Parse(typeof(DeviceType), deviceStringArg);
            applicationSetting.Environment = (EnvironmentType)Enum.Parse(typeof(EnvironmentType), environmentStringArg);
        }

        // Unity バッチモードで関数を実行する際は keystore の設定が無いので、
        // コマンドライン引数から各種情報について渡すようにする必要がある
        if (PlayerSettings.Android.keystoreName.Length == 0 ||
            PlayerSettings.Android.keystorePass.Length == 0 ||
            PlayerSettings.Android.keyaliasName.Length == 0 ||
            PlayerSettings.Android.keyaliasPass.Length == 0)
        {
            PlayerSettings.Android.keystoreName = GetValueFromCommandLineArgs("-keystoreName");
            PlayerSettings.Android.keyaliasName = GetValueFromCommandLineArgs("-keyaliasName");
            PlayerSettings.Android.keystorePass = GetValueFromCommandLineArgs("-keystorePass");
            PlayerSettings.Android.keyaliasPass = PlayerSettings.Android.keystorePass;

            if (PlayerSettings.Android.keystoreName == null ||
                PlayerSettings.Android.keyaliasName == null ||
                PlayerSettings.Android.keystorePass == null)
            {
                Debug.LogError("Please set android keystore settings.");
                return;
            }
        }

        // Build Settings で設定シーンを全てビルドに含める
        EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes;
        List<string> scenePathList = new List<string>();

        foreach (EditorBuildSettingsScene scene in scenes)
        {
            scenePathList.Add(scene.path);
        }

        BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions();
        buildPlayerOptions.scenes = scenePathList.ToArray();

        // プロジェクト直下の build フォルダに quest_production_20200202.apk のようなファイル名の APK を出力する
        DateTime currentDate = DateTime.Now;
        string dateString = $"{currentDate.Year}{currentDate.Month}{currentDate.Day.ToString("D2")}";

        string outputDirectory = "build";
        if (!Directory.Exists(outputDirectory))
        {
            Directory.CreateDirectory(outputDirectory);
        }

        string device = applicationSetting.Device.ToString().ToLower();
        string environment = applicationSetting.Environment.ToString().ToLower();

        buildPlayerOptions.locationPathName = $"{outputDirectory}/{device}_{environment}_{dateString}.apk";
        buildPlayerOptions.target = BuildTarget.Android;
        buildPlayerOptions.options = BuildOptions.None;

        applicationSetting.Save();

        BuildPipeline.BuildPlayer(buildPlayerOptions);

        // Unity プロジェクトで元々設定していた内容にアプリケーション設定を戻す
        defaultApplicationSetting.Save();
    }
}

これで Unity バッチモードで ApkBuilder.Build 関数実行時に、
-device-environment を指定した際に、
明示的にアプリケーション設定を指定した状態で APK を出力出来るようになりました :laughing:

試しに Unity バッチモードで -device-environment も引数に指定して APK 出力を行います :hammer: :arrow_down:

/Applications/Unity/Hub/Editor/2018.4.9f1/Unity.app/Contents/MacOS/Unity \
-quit -batchmode -projectPath ~/Desktop/CISample \
-executeMethod ApkBuilder.Build \
-keystoreName ~/Desktop/CISample.keystore -keyaliasName cisample \
-keystorePass CISample \
-device GEAR_VR_AND_GO -environment RELEASE

すると -device-environment で指定した内容で APK が出力されているはずです :thumbsup:

スクリーンショット 2020-02-02 23.05.58.png

次は Docker コンテナで Unity バッチモードが実行できるようにします :whale:

Docker コンテナで Unity のバッチモード実行が出来るようにする

まずは Unity の Docker イメージを pull します :arrow_down:
私は 2018.4.9f1 を使用していたので、tag が 2018.4.9f1-android のイメージを pull しています :raised_hand:

docker pull gableroux/unity3d:2018.4.9f1-android

Docker イメージのダウンロードが完了した後は、
下記コマンドで Docker コンテナを立ち上げて対話モードを起動します :electric_plug:

# bash の場合
docker run -it --rm  -e "UNITY_USERNAME=<Unity ID のユーザ名>" \
-e "UNITY_PASSWORD=<Unity ID のパスワード>" -e "TEST_PLATFORM=linux" \
-e "WORKDIR=/root/project" -e "$(pwd):/root/project" \
gableroux/unity3d:2018.4.9f1-android bash

# fish の場合
eval docker run -it --rm  -e "UNITY_USERNAME=<Unity ID のユーザ名>" \
-e "UNITY_PASSWORD=<Unity ID のパスワード>" -e "TEST_PLATFORM=linux" \
-e "WORKDIR=/root/project" -e "(pwd):/root/project" \
gableroux/unity3d:2018.4.9f1-android bash

対話モードが正常に起動できたら下記コマンドで、
ライセンスのアクティベーションを要求するための xml を出力します :outbox_tray:

xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' \
/opt/Unity/Editor/Unity -logFile -batchmode \
-username "$UNITY_USERNAME" -password "$UNITY_PASSWORD"

# ...
# 下記の Posting という出力の後ろにある xml を、
# Unity_v<バージョン>.alf という名前でファイルに保存する
LICENSE SYSTEM [202022 9:57:44] Posting <?xml version="1.0" encoding="UTF-8"?><root><SystemInfo><IsoCode>en</IsoCode><UserName>(unset)</UserName><OperatingSystem>...</MachineBindings><UnityVersion Value="2018.4.9f1" /></License></root>
# ...

私は Unity のバージョン 2018.4.9f1 を使用しているので、
Unity_v2018.4.9f1.alf というファイル名で保存しました :floppy_disk:

その後、https://license.unity3d.com/manual へアクセスします :earth_americas:

するとライセンスのアクティベーションを要求するためにファイルをアップロードするよう促されるので、
先程保存した Unity_v2018.4.9f1.alf というファイルをアップロードします :arrow_up:
スクリーンショット 2020-02-03 0.17.57.png
アップロードして無事にアクティベーションの要求が成功すると、
Unity Plus or ProUnity Personal Edition のライセンス、
どちらをアクティベートするか聞かれるので選択して Next ボタンをクリックします :point_up_2:
スクリーンショット 2020-02-03 0.20.19.png
正常に認証が完了すれば、
Download license file ボタンが出てくるので、クリックしてライセンスファイルをダウンロードします。
ライセンスファイルは Unity_v2018.x.ulf という名前でダウンロードされます :white_check_mark:

これでようやく Docker コンテナ上で Unity バッチモードを実行する環境が整いました :thumbsup:
早速 Unity プロジェクトビルド用の Dockerfile を作成します :pencil:

# Unity プロジェクトのバージョンと合わせて Unity バージョンは 2018.4.9f1 を使用する
# また Android プラットフォーム向けのビルドが行えるイメージを引っ張ってくる
FROM gableroux/unity3d:2018.4.9f1-android
LABEL maintainer="Admin <admin@nikaera.com>"

# Unity ライセンスファイルやキーストア、プロジェクトファイルを Docker イメージに内包する
COPY ./Unity_v2018.x.ulf /root/.local/share/unity3d/Unity/Unity_lic.ulf
COPY ./CISample.keystore /root/CISample.keystore
COPY ./unity /root/unity

# APK 出力のための関数への引数として DEVICE と APP_ENV という環境変数で 
CMD /opt/Unity/Editor/Unity \
    -quit -batchmode -nographics -logFile -projectPath /root/unity \
    -executeMethod ApkBuilder.Build \
    -keystoreName /root/CISample.keystore -keyaliasName cisample \
    -keystorePass CISample \
    -device $DEVICE -environment $APP_ENV

また Docker 導入にあたってプロジェクトのフォルダ構成は下記のようになっております :arrow_down:

.
├── CISample.keystore # Android ビルド時に利用するキーストアファイル
├── Dockerfile        # Docker イメージをビルド際に使用するファイル
├── Unity_v2018.x.ulf # Unity のライセンスファイル
└── unity     # Unity プロジェクトフォルダを unity フォルダに移行する
    ├── Assembly-CSharp-Editor.csproj
    ├── Assembly-CSharp.csproj
    ├── Assets
    ├── CISample.sln
    ├── Library
    ├── Logs
    ├── Oculus.VR.Editor.csproj
    ├── Oculus.VR.Scripts.Editor.csproj
    ├── Oculus.VR.csproj
    ├── Packages
    ├── ProjectSettings
    ├── build
    └── obj

Dockerfile の内容を元にプロジェクトルートでターミナルから下記コマンドでイメージを作成します :hammer:

docker build -t nikaera/cisample .

正常にイメージ作成出来たら試しに実際に Docker コンテナで APK を出力してみます :outbox_tray:

# APK 出力フォルダをホストと共有しておくことで出力した APK が参照出来るようにする
# 環境変数 DEVICE に QUEST、環境変数 APP_ENV に RELEASE を指定することで
# Quest のリリース版 APK を出力する。
docker run -v ~/Desktop/build:/root/unity/build --rm \
--env DEVICE=QUEST --env APP_ENV=RELEASE nikaera/cisample

正常に APK 出力出来ればデスクトップの build フォルダ内に quest_release_<日付>.apk が出来ているはずです :thumbsup: :tada:

Docker コンテナで全ての APK 出力を行う

ここまできたら後はスクリプト等で一気に全パターンの APK を出力します:hammer:
まずは全 APK 出力を Docker コンテナで行うためのシェルスクリプトを作成します :arrow_down:

build.sh
#!/bin/bash

docker run -v $1:/root/unity/build --rm --env DEVICE=GEAR_VR_AND_GO --env APP_ENV=STAGING nikaera/cisample

docker run -v $1:/root/unity/build --rm --env DEVICE=GEAR_VR_AND_GO --env APP_ENV=PRODUCTION nikaera/cisample

docker run -v $1:/root/unity/build --rm --env DEVICE=GEAR_VR_AND_GO --env APP_ENV=RELEASE nikaera/cisample

docker run -v $1:/root/unity/build --rm --env DEVICE=QUEST --env APP_ENV=STAGING nikaera/cisample

docker run -v $1:/root/unity/build --rm --env DEVICE=QUEST --env APP_ENV=PRODUCTION nikaera/cisample

docker run -v $1:/root/unity/build --rm --env DEVICE=QUEST --env APP_ENV=RELEASE nikaera/cisample

シェルスクリプトを用意次第、下記コマンドで一気に APK を出力します :outbox_tray:

 bash build.sh ~/Desktop/build

すると全実行環境及び全実行端末の APK が、
デスクトップの build フォルダに生成されていること確認できるはずです :thumbsup: :thumbsup:

おわりに

今回は Docker コンテナで Unity バッチモードを動かして、
モバイル VR 向けの APK を一括で出力出来るようにしてみました。

Docker コンテナ上で Unity を動かすための
ライセンス発行処理周りの作業が面倒なので出来れば自動化したい。。:arrows_counterclockwise:

参考リンク

[Unity]KeyStore作成メモー
gableroux/unity3d
Unity の Android ビルドを CLI からおこなう
【Unity】GitHub Actions v2でUnity Test Runnerを走らせて、結果をSlackに報告する【入門】

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

【Unity】ZenjectとADX2を導入すると初期化エラーが起きる問題の対処法【ADX】【Zenject】

概要

いくつかのプラグインを組み合わせると発生する特有の問題。
今回はADX2とZenjectを使っていて起きた問題とその対処法を紹介します。

結論

解決方法は2択
1. ScriptExecutionOrderの値をいじってZenjectより早く実行させてしまう(脳筋)
2. ProjectContext 内にCriWare ErrorHandler とCriWareInitializerコンポーネントを配置(多分こちらがスマート)

前提

UnityのサウンドミドルウェアといえばADX2 とWwiseだと思います。(FMODくん…ごめんな)
UnityのDIコンテナといえば「Extenject Zenject *1 」ですね。

特にADX2と自分のアプリケーションの橋渡しとなる「オレオレAudioManager/SoundManager」。
このオレオレSoundManagerはアプリのどこからでもアクセス出来るようにSingletonパターン(俗にいうSingletonMonoBehaviour)で作ることが多いです。
Singletonパターンで作ると依存性や参照がいたるところにあって保守性や拡張性で問題が出てくる為、DIコンテナを導入して極力Singletonパターンをものを減らすのはよくあると思います。

そこで「プロジェクト全体でアクセスされる」要素であるオレオレSoundManagerをProjectContextでBindするのは至極当然の流れかと思います。

問題

ProjectContext以下でBindしたオレオレSoundManager。
このオレオレSoundManagerがADXを利用していて、ADXの初期化を行おうとすると以下のエラーが出ます。

スクリーンショット 2020-02-03 19.54.23.png

ざっくり説明すると「ADX2のプラグインが初期化されてません」ということです。
ちゃんとHierarchyには初期化のオブジェクトもありますが、このエラーは発生します。
スクリーンショット 2020-02-03 19.56.46.png

解決方法

ScriptExecutionOrder

そこでUnityのスクリプトの実行順であるScriptExecutionOrderを見てみると
初期設定は以下のようになっています。
スクリーンショット 2020-02-03 19.44.35.png

よくあるプラグイン同士の初期化順の取り合いによる問題です。
ADXの 初期化 に関しては別に早ければ早いほど良いので Zenject の初期化より早く 設定します。
ただ、通常動作に関してはそのままで良いので変更しないことをお勧めします。

結果、以下のように設定すると初期化の問題を解消できます。
スクリーンショット 2020-02-03 19.57.15.png

ProjectContext で頑張る

結局のところ初期化さえしてしまえばいいので、いっそのことProjectContextにCRIWAREの初期化コンポーネントを配置してしまう戦法です。
スクリーンショット 2020-02-03 19.56.38.png

最後に

外部のプラグインやミドルウェアを入れると開発効率が上がったり、車輪の再発明をしなくて良かったり恩恵は大きいです。
しかし、このようなプラグインの組み合わせ依存の問題は「やってみないとわからない」のが現実です。
このような現象に起きた時は「まずエラー文を正しく読む」ことが非常に重要です(初心者さんとかは英語読まないことが結構多いです)。
また、どうしても解決できない場合は、そのプラグインの提供者さんに質問やIssueをあげると良いかと思います。

参考

*1 Extenject は2020年1月現在、依然として裁判中のため、元であるZenjectを使うのが無難です(本家は全く更新されてませんが)
https://github.com/svermeulen/Extenject

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

UnityでFMOD failed to initialize the output device エラーが出た時

FMOD failed to initialize the output device : "Error initializing out put device. "(60)

image.png

FMOD failed to initialize the output device : "Error initializing out put device. "(60)

見かけないエラーが出ていたのでメモ。

スピーカー、ヘッドホンなどの、対応出来るサウンド出力デバイスがないのが原因です。

ヘッドホンを差すだけで解決します。

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