Quantcast
Channel: KLab Engineer Advent Calendarの記事 - Qiita

SpresenseのGPSがQZSSのL1Sに対応したらしいので使ってみる

$
0
0

Spresenseとは?

Spresenseとは、Sonyが発売した小型マイコンボードです。
https://developer.sony.com/ja/develop/spresense/

特徴は、

  • GPS機能搭載(アンテナもメインボードに搭載、複数の衛星タイプに対応)
  • ハイレゾオーディオ
  • マルチコアプロセッサで強い
  • 省電力・小型

といったところです。
開発はArduino互換と、独自の組み込みOSを使うのとの2とおりです。

とくにこの大きさで低消費電力で、簡単に扱えて
これだけ多機能なマイコンボードは少なく、色々と使えそうなボードです。
私は、今年8月のMaker Faire Tokyoで購入して、遊んでいます。

QZSSのL1Sとは

QZSSは準天頂衛星のことで、日本が打ち上げた「みちびき」シリーズのことを指します。
準天頂衛星「みちびき」は、GPSと互換の測位信号を、日本近辺でなるべく天頂に近い位置から配信することで、特に都市部などでの測位精度をあげることが大きな役割となっています。

しかし、それだけではGPSの理論精度に、より近い精度で測位が可能になるだけです。
実は準天頂衛星には、L1Sや、L6といった、GPSを補完するための信号を送る仕組みが搭載されています。

http://qzss.go.jp/overview/services/sv05_slas.html

今回注目するL1Sは、L1C/Aという通常のGPS・QZSSで使っている電波の周波数帯と同じ周波数帯で、電離層遅延の補正信号などを送信するものです。
そのため、これまでのGPS受信機と同じ無線受信機で、受信後のデータの処理さえ行ってあげれば利用することができ、既存のGPS受信機の設計の軽微な変更で利用可能になるいう利点があります。

Spresenseの最近のアップデートで、このL1S信号の処理ができるようになったということで、実際に使ってみました。

使ってみる

https://developer.sony.com/ja/develop/spresense/developer-tools/get-started-using-arduino-ide/set-up-the-arduino-ide
こちらを参考にして、まずはArduino開発環境を整えます。
Spresenseの開発キットに関しては、最新のものを利用してください。 

さて、GNSS(全球衛星測位システム、まぁつまりGPSとかのことをまとめてこういう言い方をします)のサンプルスケッチを見てみましょう。
https://github.com/sonydevworld/spresense-arduino-compatible/blob/master/Arduino15/packages/SPRESENSE/hardware/spresense/1.0.0/libraries/GNSS/examples/gnss/gnss.ino

129~130行目に利用する衛星の設定があります。みちびきのGPS互換信号とL1S信号を使うように設定しています。GPSは、デフォルトで使うようになっています。

    Gnss.select(QZ_L1CA);  // Michibiki complement
    Gnss.select(QZ_L1S);   // Michibiki augmentation(Valid only in Japan)

https://github.com/sonydevworld/spresense-arduino-compatible/blob/master/Arduino15/packages/SPRESENSE/hardware/spresense/1.0.0/libraries/GNSS/GNSS.h
また、こちらをみると、

enum SpSatelliteType {
    GPS       = (1U << 0),
    GLONASS   = (1U << 1),
    SBAS      = (1U << 2),
    QZ_L1CA   = (1U << 3),
    QZ_L1S    = (1U << 5),
    UNKNOWN   = 0,
};

とあり、GLONASSなどにも対応していることがわかります。
今回は、上記のサンプルコードを用いてGPS・QZSS・QZSS L1Sを組み合わせて測位を試してみました。

結果を見てみる

シリアルから取得した結果の地理座標部分だけをCSVにして、Google Mapsのマイマップ機能を使って地図上に表示します。

マイマップ機能で新規の地図を作成すると、CSVからのインポートができます、
GoogleDriveなどに保存したCSVファイルなどからインポートすることができます。

実際にやってみた結果が、以下のような図になります。

SpresenseGPS検証

これは、いくつかの場所で静止状態で測位を続けたときの座標群を表示していますが、
かなり大きなずれがあることがわかります。
六本木ヒルズの周辺なので、やはりビル群による電波の反射などがあり精度がでにくいのではないかと思いました。

まとめ

あまり芳しくない結果になってしまいましたが、やはりGPS的な仕組みにとって、都市部は難しいフィールドだということでしょうか。
ともあれ、ログを確認したところではちゃんとL1Sの信号も拾っていますし、設定を代えて試したところGLONASSも拾えていることがわかったので、
今後より本格的な検証をしていこうと思います。


ESCAPEキーを押した時に日本語入力を解除するようにすると捗る

$
0
0

この記事は KLab Advent Calendar 2018 の二日目のエントリです。

日本語入力の状態管理って面倒くさい

vim を使っていて、日本語入力をする場合、NORMALモードからiを押してINSERTモードにしたあと、半角全角キーなどを使って日本語入力をONにして入力をはじめる事になりますよね。
この状態からNORMALモードに戻る時には、当然ですが、まず日本語入力をOFFにしたあとにESCキーを押してINSERTモードから抜ける事になります。

INSERTモードに入った時は日本語入力を行うかどうかはわからないので明示的にONするのは納得いくのですが、INSERTモードを抜けた後に日本語入力を行う事はないので、INSERTモードを抜ける時に自動的に日本語入力をOFFにしても問題ないはずです。

というわけで設定してみましょう。

Windowsの場合

Windowsの場合はキー設定を変更する場合、のどかを使います。(2019-09現在、サイト閉鎖されてしまったようです)
のどかの設定はホームディレクトリ下に dot.nodoka というファイルを作ってそこに書き込みます。
ESCが押された時に日本語入力をOFFにするわけですが、半角全角キーだとトグルなので、&SetImeStatusを利用して以下のように書きます。

key IL-Esc = &Default &SetImeStatus(off)
key C-IL-LeftSquareBracket = &Default &SetImeStatus(off)

IL- はIMEがONになっている時を判定するモディファイヤで、その時にEscが押されると、通常のEscが押された時の動作( &Default )と同時にIMEをOff( &SetImeStatus(off) )にします。
C-[ を押した時にも同じ動作をしてほしいので、同じ設定を二行目に書いています。

Macの場合

Windowsで設定している事は当然Macでもやりたくなりますよね。Macでキー設定をカスタマイズする場合はKarabiner-Elementsを使います。

     "rules": [
                  {
                      "description": "C-[ to ESC and KANA UNLOCK",
                      "manipulators": [
                          {
                              "from": {
                                  "key_code": "open_bracket",
                                  "modifiers": {
                                      "mandatory": [
                                          "control"
                                      ]
                                  }
                              },
                              "to": [
                                  {
                                      "key_code": "escape"
                                  },
                                  {
                                      "key_code": "lang2"
                                  }
                              ],
                              "type": "basic"
                          }
                      ]
                  },
                  {
                      "description": "ESC to ESC and KANA UNLOCK",
                      "manipulators": [
                          {
                              "from": {
                                  "key_code": "escape"
                              },
                              "to": [
                                  {
                                      "key_code": "escape"
                                  },
                                  {
                                      "key_code": "lang2"
                                  }
                              ],
                              "type": "basic"
                          }
                      ]
                  }
              ]

ことあるごとにESCを連打していこう

Windowsの場合、日本語入力のON/OFFは半角全角キーによるトグル操作なので、今の日本語入力の状態を自分で把握しておかないといけないですが、とりあえずESCキーを連打すれば日本語入力がOFFになるというのは他のエディタを使っていても直感的で便利です。

ESC連打していろんなことからESCAPEしていきましょう。

年末が近づくと Python/C API を無駄に使いたくなるので準備

$
0
0

KLab Engineer Advent Calendar 2018 の 3 日目

これは?

年末になると Python マニュアルだけでなく CPython のソースを読んでみたり改変してみたり C でライブラリを書いてみたりして遊ぶのが自分の中で恒例となっていまして。今年もそうやって過ごすつもりなので毎年のように調べなおしている Python C/API の事柄のうち記憶がしっかりしているものを事前に書き出しておきます。そして本来自分用のものだけれども隠すものでもないと考え直したので見えるところにおいてみます。

C/C++ で CPython のモジュールを書くことができる、 C/C++ アプリに CPython を組み込むこともできる Python C/API 。これは役に立つのか? 先達によって書かれた Python ライブラリ、 Cython, ctypes など既存ツールが手厚く存在するので C/C++ と Python C/API で頑張らなくても大抵のケースでは事足りるよなぁ、というのが実際のところ。というわけで目的と手段の逆転、使ってみたかったから使うといった感じで。いや CPython の実装の理解には役に立っている、はず、たぶん、きっと。

0. 準備

とにかく C 言語を使える環境を用意する。必須ではないがせっかくなので少し手を伸ばして Python をソースからビルドしてみる。そのやりかたもマニュアルが用意されているので開発者・先駆者の方々に感謝しつつ実行。今回は Python 3.7.1 を使用する。

Windows なら Visual Stodio 2017 を Python development と Python native development tools 入りでインストールして PCBuild/build.bat を実行。 OS X や Linux ではマニュアルの通り前準備して ./configure, make, make install 。たとえば Ubuntu 18.04 なら

# パッケージの準備
$ sudo vim /etc/apt/sources.list
# deb-src http://archive.ubuntu.com/ubuntu/ bionic universe とか
# Japanese Team の日本語 Remix イメージなら
# deb-src http://jp.archive.ubuntu.com/ubuntu/ bionic universe と設定されている行のコメントを外す

$ sudo apt build-dep python3.7

# ソースの準備
# python.org をブラウザで見る、 wget, curl などなど
$ wget https://www.python.org/ftp/python/3.7.1/Python-3.7.1.tar.xz
$ tar --xz -xvf Python-3.7.1.tar.xz
# もしくは github から v3.7.1 を clone
$ git clone --depth 1 --single-branch -b v3.7.1 https://github.com/python/cpython.git

# build & install
$ ./configure --prefix=${HOME}/python371 --enable-optimizations
$ make
$ make install

# さっそく venv 。 ipython, pytest くらいは入れておく。
$ ~/python371/bin/python -m venv ~/py371
$ . ~/py371/bin/activate
$ pip install ipython pytest

以下のコードの動作確認はこの Ubuntu 18.04 にて行っている。一部だけだが Windows 10 1083, MacOS High Sierra でも試してみて無事だったので CPython が動く環境であれば動くはず。

1. C で空のモジュールを作る

Python なら 0 byte の .py ファイルを用意すればこれを import することができる。空でもモジュールには違いない。まずはこれを目指す。0 byte の spam.so という名のファイルを用意して import を試みると ModuleNotFoundError ではなく ImportError になることからもわかるように CPython は共有ライブラリを見つけるとこれをモジュールであると期待して読み込もうとする。

In [1]: import spam
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-1-bdb680daeb9f> in <module>
----> 1 import spam

ModuleNotFoundError: No module named 'spam'

In [2]: !touch spam.so

In [3]: import spam
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-3-bdb680daeb9f> in <module>
----> 1 import spam

ImportError: /home/fgshun/src/advent2018/spam.so: file too short

どのような共有ライブラリを作ればよいか? それは PyObject * PyInit_<モジュール名>(void) という関数を公開した共有ライブラリ。 PyObject * と直接書かずに PyMODINIT_FUNC マクロを使うのは環境の違いを吸収するため。たとえば Visual Studio では __declspec(dllexport) が追加で必要だったりするがこのマクロが対応してくれる。たとえば C ではなく C++ を使うと extern "C" が要るがこれも同様。

spam.c
#include <Python.h>

PyMODINIT_FUNC PyInit_spam(void) {
    return NULL;
}

この後 setup.py を用意。これができてしまえば pure python のライブラリと同じように python setup.py install もしくは pip install . でビルドしつつインストールできるようになる。 python setup.py develop もしくは pip install -e . でインストールしきらずに開発を続行できるのも同様。

setup.py
from setuptools import setup, Extension

extensions = [Extension('spam', sources=['spam.c'])]

setup(
    name='spam',
    ext_modules=extensions,
    )

あらためてビルド、 import 。 NULL を返したにもかかわらず例外がセットされていないゆえの SystemError 。

In [1]: import spam
---------------------------------------------------------------------------
SystemError                               Traceback (most recent call last)
<ipython-input-1-bdb680daeb9f> in <module>
----> 1 import spam

SystemError: initialization of spam failed without raising an exception

モジュールオブジェクトを PyModule_Create でつくるのがチュートリアルにある方法。でも今回はあえて PyModuleDef_Init多段階初期化でやってみる。関連する PEP 489 を読みつつ。ついでに Py_LIMITED_API を入れておく。これは非公開の API や構造体、構造体のメンバを隠してくれるもの。環境が許せば再ビルド不要なバイナリパッケージをつくることが可能となるが Windows ではやっぱり再ビルド必要なのとできることが減るのでよいことばかりではない、いやデメリットのほうが目立つ? ともかく今回は limited API 環境で。 PyModuleDef_Init が追加されたのは Python 3.5 からなので 0x03050000 を対応バージョンの下限に設定する。 もうひとつ、 PyArg_ParseTuple で長さの型に int を使ってしまっていたのを Py_ssize_t にする修正をいれる PY_SSIZE_T_CLEAN を入れておく。

これで空のモジュールが C で書けた。そして、 setup.py に tests_require=['pytest'] などを加えて py.test でユニットテストができるようにして。書き加えて遊ぶ準備は完了。

spam.c
#define Py_LIMITED_API 0x03050000
#define PY_SSIZE_T_CLEAN
#include <Python.h>


static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "spam",
};


PyMODINIT_FUNC PyInit_spam(void) {
    return PyModuleDef_Init(&spam_module);
}
setup.py
from setuptools import setup, Extension

extensions = [Extension('spam', sources=['spam.c'])]

setup(ext_modules=extensions)
setup.cfg
[metadata]
name = spam
version = 0.1.0

[options]
python_requires = >=3.5
setup_requires = pytest-runner
tests_requires = pytest

[aliases]
test=pytest
test_spam.py
import pytest

import spam


def test_import():
    assert spam.__name__ == 'spam'

2. モジュール構築

2.1. 前知識

Python/C API リファレンスマニュアル に必要なことは書いてあるので感謝しつつ読む。モジュールの完成例が見たくなったら標準ライブラリ。今回の limited API 環境でモジュールを書くにあたり直接参照できるのは xxlimited.c 。その他ももちろん参考になる。

「はじめに - オブジェクト、型および参照カウント」は必読。以下概要。

  • CPython におけるオブジェクトは C から見ると PyObject 構造体。
  • PyObject 構造体は参照カウントを持つ。これを増減するのはプログラマの責任。自動ではなされない。
    • 参照カウントが 0 になるとそのオブジェクトのメモリ解放関数が呼ばれる。メモリ開放処理が別のメモリ開放関数を呼び出すといった連鎖が起こることがあり、これによりメモリ解放の連鎖に巻き込まれたオブジェクトをまだ使えると誤解・誤操作することが起こりえる。どうしても消されたくないオブジェクトがあるときは事前に参照カウントを増やしておく。
    • Py_DECREF(PyObject *o) で減らし、 Py_INCREF(PyObject *o) で増やす。 NULL を渡すのは禁止。
    • Py_XDECREF(PyObject *o) で減らし、 Py_XINCREF(PyObject *o) で増やす。 X なしとの違いは NULL チェックをしてくれるところ。
    • Py_CLEAR(PyObject *o) で減らす。 Py_XDECREF との違いは変数を参照カウンタを減らす「前」に NULL にしてくれるところ。結果として Py_CLEAR 後に o は NULL となる。減らす前なのはメモリ解放処理が連鎖したときの事故に備えている。
    • PyObject * を返す API が成功したときの返り値の所有権を持っているかどうかは API 次第。ドキュメントに Return Value: New reference とあれば所有権あり、 Borrow reference とあれば所有権なし、借り物。日本語マニュアルでは前者を所有参照、後者を借用参照と訳している。
    • 所有参照は不要になったら参照カウントを減らさなければならない。借用参照はなにがあっても参照カウントを減らしてはならない。
    • 引数として渡された参照を盗むとされている API がごく少数存在する。将来なんらかのしくみで参照カウントが 1 減らされることが約束されるので減らし過ぎに注意。たとえば PyModule_AddObject でモジュールに追加されたオブジェクトの参照カウントはモジュールがメモリ解放されはじめたときに 1 減らされることとなる。当然ながら借り物をわたしてはならない、又貸し禁止。
  • PyObject * を返す API は失敗すると NULL を、 int を返す API は -1 を返す。
    • マニュアルに特記ある API 以外は。
    • 失敗したときにはなんらかの例外がセット済み。
    • 失敗していなくとも -1 が得られて区別がつかない場合がある API もある。こういったものに対しては PyErr_Occurred を併用する。 if (ret == -1 && PyErr_Occurred()) のようにする。
  • PyObject * を引数として受け取る API には NULL を渡しても良いものとだめなものがある。要マニュアル参照。
    • NULL を渡しても良い API の引数には失敗したとき NULL を返す API を直接書くことができる。たとえば PyModule_AddObject(module, "zero", PyLong_FronLong(0))

2.2. モジュールオブジェクトへのオブジェクトの追加

PyModuleDef の m_slots に PyModuleDef_Slot[] を登録し、 Py_mod_exec として関数を登録。関数には module オブジェクトが渡ってくるので編集する。成功したら 0 を、失敗したら module オブジェクトの参照カウントを減らして -1 を返す。 module オブジェクトへのオブジェクトの追加には専用の関数 が便利。モジュールオブジェクトもオブジェクトには違いないので汎用のオプジェクトプロトコルでの操作も可能。

static int spam_exec(PyObject *module) {
    if (PyModule_AddIntConstant(module, "one", 1)) { goto error; }
    if (PyModule_AddStringConstant(module, "s", "string")) { goto error; }
    if (PyModule_AddObject(module, "spam", Py_BuildValue("sss", "spam", "ham", "eggs"))) { goto error; }
    return 0;
error:
    Py_DECREF(module);
    return -1;
}


static PyModuleDef_Slot spam_slots[] = {
    {Py_mod_exec, spam_exec},
    {0, NULL}
};


static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "spam",
    .m_slots = spam_slots,
};

2.3. モジュールオブジェクトへの関数の追加

PyMethodDef[] を定義して PyModuleDef の m_methods に登録。もっとも簡単なのは次のようなもの。これで def spam(*args): return None 相当の関数ができる。PyMethodDef は名前、実装である C 関数への参照、フラグ、 docstring の 4 つのメンバからなる構造体。 (なお PyMethodDef はクラスをつくってメソッドを追加するときにも使う、登録先が異なるだけで作り方は似ている。そもモジュールの関数というものはモジュールクラスのインスタンスのメソッドでしかないので。)

C 関数の型は PyCFunction であり、その引数は PyObject * 2 つ。1つめにはモジュールオブジェクトが、 2 つめには引数を表す tuple が入ってくる。 (インスタンスメソッドならば 1 つめはインスタンスオブジェクト、クラスメソッドならばクラスオブジェクト、スタティックメソッドであれば NULL になるがこれは別の話) 。なんらかの処理をして PyObject * を返せばそれが Python 呼び出し側への戻り値となる。例外が設定された、もしくは設定したのであれば NULL を返さなくてはならない。

static PyObject *
spam_none(PyObject *self, PyObject *args) {
    // Py_INCREF(Py_None); return Py_None; 相当のマクロ
    // Py_None は Python からは None に見える PyObject へのポインタ
    Py_RETURN_NONE;
}


static PyMethodDef spam_methods[] = {
    {"none", spam_none,
     METH_VARARGS,
     "return None."},
    {NULL, NULL, 0, NULL}
};


static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "spam",
    .m_methods = spam_methods,
};

引数を解釈する

ここで第 2 引数 PyObject *args には tuple が入ってくるので PyArg_ParseTuple で処理する。第 2 引数のフォーマット文字列でこの関数がどのような引数なら受け付けるのかを指定する。フォーマット文字列に使える文字は PyObject * をそのまま借り受ける O の他、 C の int 型に変換を試みる i とか str, bytes, byte like object を Py_buffer に変換を試みる s* など多数用意されている。ただし limited API 環境だと buffer プロトコルが使用不可であり、 s*y* など可変 byte like object の抱えている生のメモリ領域に触れる手段は塞がれている。

実行時、フォーマットに一致しない引数が与えられた場合は PyArg_ParseTuple が例外をセットしつつ NULL を返してくるのでエラー処理をした後 NULL を返す。初手 PyArg_ParseTuple としたのであれば即 return NULL で問題ない。

static PyObject *
spam_echo(SpamObject *self, PyObject *args) {
    PyObject *o;

    if (!PyArg_ParseTuple(args, "O", &o)) {
        return NULL;
    }

    // 借り物なのでオウム返しするには所有権を持つ必要がある
    Py_INCREF(o);
    return o;
}

引数を一つも取らないのであれば PyArg_ParseTuple(args, "") を呼ぶか PyMethodDef のフラグとして METH_NOARGS を指定すればよい。引数が一つだけであるのであれば METH_O でも。詳しい説明およびその他のフラグは PyMethodDef にある。

static PyObject *
spam_echo(SpamObject *self, PyObject *arg) {
    Py_INCREF(arg);
    return arg;
}

spam_echo
static PyMethodDef spam_methods[] = {
    {"echo", spam_echo,
     METH_O},
    {NULL, NULL, 0, NULL}
}

初期値持ちのオプション引数を用意したいときはフォーマット文字列を | で区切る。これ以降はあってもなくてもよい引数として解釈され、与えられなかったときには対応する C 変数に変更は加えられないことになる。これで def add(i, j=2018): return i + j のような関数を作ることができる。

static PyObject *
spam_add(SpamObject *self, PyObject *args) {
    int i;
    int j = 2018;

    if (!PyArg_ParseTuple(args, "i|i", &i, &j)) {
        return NULL;
    }

    return PyLong_FromLong(i + j);
}

名前付き引数

いままでの関数の作り方には Pure python で関数を書いたときのと違いがある。キーワード引数に対応していないのだ。 add(1, 2) という呼び出し方はできても add(i=1, j=2) とすることはできない。これを可能にしたいのであれば。まず PyMethodDef のフラグに METH_VARARGS | METH_KEYWORDS を指定。次に PyCFunction の代わりに PyCFunctionWithKeywords を使う。 PyCFunction の 2 引数に加えて名前付き引数を保持した dict が加わる。これを PyArg_ParseTupleAndKeywords で処理する。最後に PyMethodDef には PyCFunction が必要なのでコンパイラに警告・エラーをだされないようキャストする。

static PyObject *
spam_add(SpamObject *self, PyObject *args, PyObject *kw) {
    int i;
    int j = 2018;
    static char *keywords[] = {"i", "j", NULL};

    if (!PyArg_ParseTupleAndKeywords(args, kw, "i|i", keywords, &i, &j)) {
        return NULL;
    }

    return PyLong_FromLong(i + j);
}

static PyMethodDef spam_methods[] = {
    {"add", (PyCFunction)spam_add,
     METH_VARARGS | METH_KEYWORDS},
    {NULL, NULL, 0, NULL}
};

2.4 モジュールオブジェクトに追加のメモリ領域をもたせる

PyModuleDef の m_size に確保したいメモリ領域のサイズを指定するとそれだけのメモリがモジュールオブジェクトごとに与えられる。アクセスするには PyModule_GetState を使う。 PyObject * を保持させることも可能だけれどもメモリリークしないようモジュールのメモリ解放処理であわせて開放されるよう準備しておく必要がある。 PEP 3121 に実装例あり。 PyModule_AddObject でモジュールの属性辞書に持たせるのと比べると辞書を介さずポインタ演算だけで所定のメモリにたどり着けるので加速はする。手間に見合うかどうかは用途による。

#define Py_LIMITED_API 0x03050000
#define PY_SSIZE_T_CLEAN
#include <Python.h>


typedef struct {
    int year;
    int month;
    int day;
} SpamState;


static PyObject *
spam_use_state_sample(PyObject *self, PyObject *args) {
    SpamState *s = PyModule_GetState(self);
    if (s == NULL) { return NULL; }

    return Py_BuildValue("iii", s->year, s->month, s->day);
}


static int spam_exec(PyObject *module) {
    SpamState *s = PyModule_GetState(module);
    if (s == NULL) {
        Py_DECREF(module);
        return -1;
    }
    s->year = 2018;
    s->month = 12;
    s->day = 3;
    return 0;
}


static PyModuleDef_Slot spam_slots[] = {
    {Py_mod_exec, spam_exec},
    {0, NULL}
};


static PyMethodDef spam_methods[] = {
    {"use_state_sample", spam_use_state_sample,
     METH_NOARGS},
    {NULL, NULL, 0, NULL}
};


static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "spam",
    .m_slots = spam_slots,
    .m_methods = spam_methods,
    .m_size = sizeof(SpamState),
};


PyMODINIT_FUNC PyInit_spam(void) {
    return PyModuleDef_Init(&spam_module);
}

3. Pure Python でよくあるあれこれ、 C だとどう書く?

Python だと数タイプで済むものが数行のコードになったり。

3.1. 基本の型のインスタンス作成

具象オブジェクトレイヤ にある型にはオブジェクトを得るための API が用意されているのでそれを用いる。もしくは Py_BuildValue を使う。後者は複数の基本型を組み合わせた tuple, list, dict を作るときに便利。

// 1
PyLong_FromLong(1);
// 2
Py_BuildValue("i", 2);
// ('spam', 'ham', 'eggs')
Py_BuildValue("sss", "spam", "ham", "eggs");
// {1: 'one', 2: 'two', 3: 'three'}
Py_BuildValue("{isisis}", 1, "one", 2, "two", 3, "three");

これに限った話ではないが。すべての API は失敗しうるので使用時には NULL が返ってきていないかのエラーチェックが必要。

    PyObject *one;
    if (!(one = PyLong_FromLong(1))) { return NULL; }

3.2. 演算子など

抽象オブジェクトレイヤ を参照。

// a.b
PyObject_GetAttrString(a, "b");

// a.b = c
PyObject_SetAttrString(a, "b", c);

// a[b]
PyObject_GetItem(a, b);

// -a
PyNumber_Negative(a);

// a + b
PyNumber_Add(a, b);

// a == b
PyObject_RichCompare(a, b, Py_EQ);
// Py_LT, Py_LE, Py_EQ, Py_NE, Py_GT, Py_GE が
// <, <=, ==, !=, >, >= に対応

// len(a)
PyObject_Size(a);

// isinstance(a, b)
PyObject_IsInstance(a, b);

// repr(a)
PyObject_Repr(a);

// a()
PyObject_CallFunctionObjArgs(a, NULL);

関数呼び出しは亜種がたくさんあるので PyObject_Call など似た名前の API を確認。

3.3. for 文、イテレータの操作

イテレータプロトコル の説明が簡潔かつ完璧すぎてこのコードを引用もといコピペですんでしまう。

PyObject *iterator = PyObject_GetIter(obj);
PyObject *item;

if (iterator == NULL) {
    /* propagate error */
}

while (item = PyIter_Next(iterator)) {
    /* do something with item */
    ...
    /* release reference when done */
    Py_DECREF(item);
}

Py_DECREF(iterator);

if (PyErr_Occurred()) {
    /* propagate error */
}
else {
    /* continue doing useful work */
}

3.4 組み込み関数へのアクセス

リフレクション 参照。 PyEval_GetBuiltins は借用参照を、 PyMapping_GetItemString や PyObject_GetItem は所有参照を返してくるので後始末の Py_DECREF は後者にだけかける。

PyObject *builtins;
if (!(builtins = PyEval_GetBuiltins())) { return NULL; }
PyObject *printfunc;
if (!(printfunc = PyMapping_GetItemString(builtins, "print"))) { return NULL; }

// 使用

Py_DECREF(printfunc);

3.5. import

sys モジュールは専用の PySys_GetObject が存在。

PySys_GetObject("version_info");

sys 以外は モジュールのインポート を参照。 PyImport_ImportModule で概ね事足りる。 PyImport_ImportModuleLevelObject の説明が組み込み関数 __import__ にリダイレクトされているのが興味深い。

PyImport_ImportModule("itertools");

3.6. モジュール変数へのアクセス

モジュール関数の第 1 引数 self はモジュールオブジェクト。なのでこれの属性をとるだけ。 PyObject_GetAttrString を使う。

static PyObject *
spam_spam(PyObject *self, PyObject *args) {
    return PyObject_GetAttrString(self, "spam");
}

3.7 例外のセット

def raise_error(): raise ValueError("spam") のようなものを作るには。例外の送出 を参照。一番手っ取り早いのは PyErr_SetString 。 C から組み込み例外は PyExc_ を頭につけた変数名で見える。一覧は標準例外を参照。

static PyObject *
spam_raise_error(PyObject *self, PyObject *args) {
    PyErr_SetString(PyExc_ValueError, "spam");
    return NULL;
}

4. クラスの作成

4.1. とにかく作ってみる

クラスの作り方もモジュール同様にいくつか方法がある。シングルトンな PyTypeObject を static に確保する方法はチュートリアルにある。今回は limited API 環境ゆえに PyTypeObject のメンバが隠されていてこの方法はとれないので、型定義 PyType_Spec から動的に作る PyType_FromSpec を使う方法を取る。詳しくは PEP 384 Type Objects を参照。 PyTypeObject のメンバは似た名前の slot id を用いて PyType_Slot として設定することで間接に設定できる。例えば Py_tp_iter を設定すれば tp_iter に iter(o) された時に呼び出される関数を登録できる。 PEP にできないと書かれている tp_dict, tp_mro, tp_cache, tp_subclasses, tp_weaklist, tp_print, tp_weaklistoffset, tp_dictoffset は limited API 環境では使用不可。

tp_dictoffset が使用不可ということは、これでつくられたインスタンスは __dict__ を持たず、デフォルトでは実行時に任意の属性を追加することができない。どうしても必要であれば xxlimited モジュールの Xxo クラスの Xxo_getattroXxo_setattr のように自力でデスクリプタを書く。もしくは type を 3 引数で呼び出してクラスオブジェクトを作る。ここまで来ると C で書く意義が怪しくなる。 C だけで書ききらず Python で作りかけのクラスを継承して完成させるほうが無難。

#define Py_LIMITED_API 0x03050000
#define PY_SSIZE_T_CLEAN
#include <Python.h>


typedef struct {
    PyObject_HEAD
} SpamObject;


static PyType_Slot SpamType_slots[] = {
    {0, 0},
};


static PyType_Spec SpamType_spec = {
    .name = "spam.Spam",
    .basicsize = sizeof(SpamObject),
    .flags = Py_TPFLAGS_DEFAULT,
    .slots = SpamType_slots,
};


static int spam_exec(PyObject *module) {
    PyObject *spam_type;

    spam_type = PyType_FromSpec(&SpamType_spec);
    if (!spam_type) {
        Py_DECREF(module);
        return -1;
    }
    if (PyModule_AddObject(module, "Spam", spam_type)) {
        Py_DECREF(spam_type);
        Py_DECREF(module);
        return -1;
    }

    return 0;
}


static PyModuleDef_Slot spam_slots[] = {
    {Py_mod_exec, spam_exec},
    {0, NULL}
};


static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    .m_name = "spam",
    .m_slots = spam_slots,
};


PyMODINIT_FUNC PyInit_spam(void) {
    return PyModuleDef_Init(&spam_module);
}

4.2 メソッドの追加

メソッドを追加するには PyMethodDef[] を tp_methods に設定する。モジュールに関数を書くのとほぼ同じ。第 1 引数 self がこのクラス由来であることは明らかなのでキャストする手間が面倒ならクラス用の構造体型で宣言してしまって構わない。ただし PyCFunction ではなくなってしまうので PyMethodDef に登録するときにキャストがいる。フラグに METH_CLASS を設定すればクラスメソッドを作ることができる。 __init__ は tp_method ではなく tp_init で設定する。

#include <structmember.h>

typedef struct {
    PyObject_HEAD
    long value;
} SpamObject;


int
Spam_init(SpamObject *self, PyObject *args) {
    long v;
    if (!PyArg_ParseTuple(args, "l", &v)) {
        return -1;
    }
    self->value = v;

    return 0;
}


static PyObject *
Spam_get_value(SpamObject *self, PyObject *args) {
    return PyLong_FromLong(self->value);
}


static PyObject *
Spam_set_value(SpamObject *self, PyObject *args) {
    if (Spam_init(self, args)) { return NULL; }

    Py_RETURN_NONE;
}


static PyMethodDef Spam_methods[] = {
    {"get_value", (PyCFunction)Spam_get_value,
     METH_NOARGS},
    {"set_value", (PyCFunction)Spam_set_value,
     METH_VARARGS},
    {NULL, NULL, 0, NULL}
};


static PyType_Slot SpamType_slots[] = {
    {Py_tp_methods, Spam_methods},
    {Py_tp_init, (initproc)Spam_init},
    {0, 0},
};

4.3 インスタンス属性の追加

属性を足すには tp_members を設定する。内容は PyMemberDef[] 。

PyMemberDef のために structmember.h ヘッダのインクルードがいる。忘れずに。

#include <structmember.h>


typedef struct {
    PyObject_HEAD
    long x;
    long y;
} SpamObject;


int
Spam_init(SpamObject *self, PyObject *args) {
    long x, y;
    if (!PyArg_ParseTuple(args, "ll", &x, &y)) {
        return -1;
    }
    self->x = x;
    self->y = y;

    return 0;
}


static PyMemberDef Spam_members[] = {
    {"x", T_LONG, offsetof(SpamObject, x), READONLY},
    {"y", T_LONG, offsetof(SpamObject, y), 0},
    {NULL}
};


static PyType_Slot SpamType_slots[] = {
    {Py_tp_init, (initproc)Spam_init},
    {Py_tp_members, Spam_members},
    {0, 0},
};

property のようになんらかの処理を入れるには tp_getsetPyGetsetDef を設定する。

static PyObject *
Spam_get_point(SpamObject *self, void *Py_UNUSED(ignored)) {
    return Py_BuildValue("ll", self->x, self->y);
}


int Spam_set_point(SpamObject *self, PyObject *arg, void *Py_UNUSED(ignored)) {
    long v[2];
    PyObject *o;

    for (int i = 0; i < 2; ++i) {
        if (!(o = PySequence_GetItem(arg, i))) {
            return -1;
        }
        if (!PyLong_Check(o)) {
            Py_DECREF(o);
            PyErr_SetString(PyExc_TypeError, "int required");
            return -1;
        }
        v[i] = PyLong_AsLong(o);
        Py_DECREF(o);
        if (v[i] == -1 && PyErr_Occurred()) {
            return -1;
        }
    }

    self->x = v[0];
    self->y = v[1];

    return 0;
}


static PyGetSetDef Spam_getset[] = {
    {"point", (getter)Spam_get_point, (setter)Spam_set_point},
    {NULL}
};


static PyType_Slot SpamType_slots[] = {
    {Py_tp_init, (initproc)Spam_init},
    {Py_tp_members, Spam_members},
    {Py_tp_getset, Spam_getset},
    {0, 0},
};

5. 終わり

とりあえずここまで。 async 関連など C から扱ったことがないものもまだまだあるので、年末にはこれらに触れてみたいと思っているところです。では、よい Python/C API ライフ(?)を。

いま席にいるかをお知らせするシステムを作った

$
0
0

仕事をしている中でとても気になるのが「いま話をしたい人が席にいるかどうか」だと思います。弊社では Slack を使って仕事のやり取りをしていますが、問いかけになかなか反応しないときに、反応できない状態なのか、そもそも席にいないのかわからないままもやもやすることってよくあるんじゃないかなと思います。そこで、「いま席にいるか」を問いかけると返事してくれるシステムを作りました。サーバー プログラミングのお勉強もかねてます。

Olkar

Olkar (おるか) っていいます。名前付けは適当です。
状態管理と Slack BOT を兼ね備えたサーバー システム、状態を通知するクライアント システムの 2 つをあわせたものです。

サーバー構成

サーバーは ASP.NET Core + SignalR + C# で作っていて、OS は Debian Stretch です。
構成図
クライアントとサーバーとの間で SignalR によるリアルタイム通信が実装されています。

SignalR での実装

何も考えずに ASP.NET Core Web アプリケーションとしてプロジェクト作りました
image.png
SignalR での実装に参考にしたのは Microsoft の公式ドキュメントですが、チュートリアルでは Web ページでも動作するための準備も含まれていて、今回はページとしてリアルタイムで何か見ることはしないので、unpkg での SignalR クライアント ライブラリの追加は省略します。

あとはしこしこと実装するだけです。
Hub には状態を変更するためのメソッドと、現在の状態を知るためのメソッドを用意しました。

OlkarHub.cs
public class OlkarHub : Hub
{
    public OlkarStatusModel Status { get; private set; }

    public OlkarHub(OlkarStatusModel model)
    {
        this.Status = model;
    }

    /// <summary>
    /// ステータスを更新します。
    /// </summary>
    /// <returns></returns>
    public async Task SetStatus(Status status)
    {
        this.Status.Status = status;

        await Clients.All.SendAsync("Status", status);
    }

    /// <summary>
    /// 現在のステータスを取得します。
    /// </summary>
    /// <returns></returns>
    public async Task GetStatus()
    {
        await Clients.Caller.SendAsync("Status", this.Status.Status);
    }
}

ここで OlkarStatusModel は状態を管理するクラスのことをさします。このままだと、接続しているインスタンスがいなくなると状態が破棄されてしまうので、DI として登録しておき、いつでも状態を呼び出せるようにしておきます。
登録は Startup.cs でできます。

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    ()
    services.AddMvcCore();
    // Singleton として登録しておくことで、プロセスが生きている間は状態が保持される
    services.AddSingleton<OlkarStatusModel>();
    ()
}

呼び出したいときは、コントローラーとなったクラス (今回は OlkarHub) のコンストラクター引数に、呼び出すクラスを書いておくとインスタンスを挿入してくれます。

SignalR を有効にするため、Startup.cs にいくつか追記します。

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    ()
    services.AddSignalR(config =>
    {
        // 明示的にしておくことで、勝手にタイムアウトしていくのを防ぐ
        config.HandshakeTimeout = TimeSpan.FromSeconds(15);
    });
    ()
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    ()
    // SignalR のルート
    app.UseSignalR(routes => routes.MapHub<OlkarHub>("/olkar"));
    ()
}

これで SignalR 側の実装はだいたい終わり。Nginx と連携したリバースプロキシの設定や、https 接続の有効化、実行の方法はだいたい Microsoft のドキュメントを見るとわかるので省略します。

Slack API の実装

めっちゃめんどくさかった
実装には この記事がいろいろ参考になりました

Slack に対してお伝えするためのルーティングは、「属性ルーティング」と呼ばれる仕組みで実装しています。

Slack が呼び出し先として認知させるためのベリファイを実装します。送られてきたデータのうち Challange を何らかの形で返すようにします。JSON のパースはおなじみ Json.NET です。

SlackController.cs
[Route("slack")]
public class SlackController : Controller
{
    [Route("request")]
    public async Task<IActionResult> SlackRequest([FromBody] object request)
    {
        if (!(request is JObject jObject))
        {
            return Forbid();
        }

        var type = jObject["type"];

        switch (type.ToString())
        {
            case "url_verification":
                var verify = jObject.ToObject<SlackVerifyHandshakeModel>();
                return Verify(verify);
            default:
                break;
        }

        return Content(request.ToString());
    }

    private IActionResult Verify(SlackVerifyHandshakeModel message)
    {
        // Content で HTTP 200 を返しつつ応答メッセージを返せる
        return Content(message?.Challenge ?? "NULL");
    }
SlackVerifyHandshakeModel.cs
public class SlackVerifyHandshakeModel
{
    [JsonProperty("token")]
    public string Token { get; set; }
    [JsonProperty("challenge")]
    public string Challenge { get; set; }
    [JsonProperty("type")]
    public string Type { get; set; }
}

request にやってくるデータが時と場合によってバラバラなので、JsonConvert.DeserializeObject<T>(string) ではなく JObject として拾って処理しました。中身が確定したらその都度変換しています。

あとは参考にした記事通り、追加したい Workspace に対して App を新規追加し、Event Subscriptions の Request URL にアドレスを記述して問題ないかを確認します。「Verified」が出れば OK
image.png
今後はこのアドレスに対していろいろとメッセージが降ってくるようになります。

さきほどの SlackRequest メソッドにたくさんのメッセージがくる中、typeevent_callback になっているものに対して処理を掛けます。
BOT に対してのメンションが取りたいので、Request URL を入れたページの下の方にある「Subscribe to Bot Events」で、メンションを受け取るための app_mention を有効にしておきます。DM でも何か欲しい場合は message.im も有効にしておくとよいでしょう。
image.png

event_callback としてやってきたデータには、event と呼ばれる階層ができています。そこの type を見て処理を書けば OK です。さきほど app_mention を有効にしたので、 "type": "app_mention" と書かれたデータがやってきます。message.im も有効であれば、"channel_type": "im" も飛んできます。

SlackController.cs
private async Task<IActionResult> EventCallback(JObject jsonObject)
{
    var eventJson = jsonObject["event"];
    var eventType = eventJson["type"];

    // 基本情報
    var user = eventJson["user"];
    var item = eventJson["item"];
    var channelType = eventJson["channel_type"]?.ToString() ?? string.Empty;

    switch (eventType.ToString())
    {
        case var d when d == "message" && channelType == "im":
        case "app_mention":
            Log($"<<- {eventJson}");
            await ParseMessageAsync(eventJson);
            break;
        default:
            break;
    }

    return Ok();
}

あとはパースして処理してやれば OK です。BOT の応答にも返事しないように、 "subtype" : "bot_message" は排除します。

SlackController.cs
private async Task ParseMessageAsync(JToken message)
{
    var subtype = message["subtype"];
    if (subtype?.ToString() == "bot_message")
    {
        return;
    }

    var messageId = message["client_msg_id"];

    var channel = message["channel"];
    var text = message["text"];
    var user = message["user"];

    // 前に発言したのと全く同じのがきていたら無視
    if (messageId != null)
    {
        Log("Client Message Id: " + messageId);

        if (messageId.ToString() == previousMessageId)
        {
            return;
        }

        previousMessageId = messageId.ToString();
    }

    await ExecuteCommandAsync(channel.ToString(), user.ToString(), text.ToString());
}

Slack へのメッセージの送信は chat.postMessage に POST すれば OK です。「OAuth & Permissions」で拾ってきた BOT 用 OAuth Token をパラメーターに入れておくことで BOT の投稿として処理されます。
ユーザーへの返事は <@USER0123> のように、ユーザー名ではなく専用のユーザー ID に置き換えますが、その辺は処理するときに受け取った情報でどうにかなるのであまり気にしなくていいでしょう。

SlackController.cs
private async Task SendMessageAsync(string channel, string message)
{
    var contentDict = new Dictionary<string, string>
    {
        { "token", OAuthToken },
        { "channel", channel },
        { "text", message }
    };
    var content = new FormUrlEncodedContent(contentDict);

    // フィールドに HttpClient http = new HttpClient(); がいます
    var result = await this.http.PostAsync("https://slack.com/api/chat.postMessage", content);
}

これであらかたおわったはず。

クライアントの実装

記事を書いてたらだんだん疲れました
常駐させておきたかったので WPF で実装し、通知領域に生きてもらうようにしています。
クライアントには SignalR をどこからか入れる必要があるので、Nuget で Microsoft.AspNetCore.SignalR.Client を探して入れておきます。Microsoft.AspNet.SignalR.Client もありますが、こっちは今回使いません。

今回は「PCがロック状態になったかどうかで在席・離席を管理」したかったので、ロック状態を検知できるようにします。
Microsoft.Win32.SystemEvents.SessionSwitch イベントでそれを拾うことができます。

private async void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e)
{
    if (e.Reason == SessionSwitchReason.SessionLock)
    {
        Console.WriteLine($"{DateTimeOffset.Now:yyyy/MM/dd HH:mm:ss} セッションがロックされました");
    }
    else if(e.Reason == SessionSwitchReason.SessionUnlock)
    {
        Console.WriteLine($"{DateTimeOffset.Now:yyyy/MM/dd HH:mm:ss} セッションが復帰しました");
    }
}

あとは Microsoft のドキュメントを見ながら Hub を実装すれば OK です。サーバーでは状態の更新のために SetStatus を準備したので、

await this.Hub.InvokeAsync("SetStatus", status);

というように実装しておきます。
状態のコールバックは Status として用意しているので、

this.Hub.On<Status>("Status", s => this.Status = s);

などと書いておけば OK です。 Status は列挙型で、状態を表す値を入れています。

こうなる

image.png

今後の展望

SignalR で実装しているので、ほかのデバイスとも連動して状態をリアルタイムで表示できるようにしたいです
今は PC がロックされたかどうかでしか見ていませんが、ほかの状態を検知して、完全自動化を目指したいです

お粗末様でした

既存アプリに新規機能と一緒にFirebaseAnalyticsを導入した際のお話

$
0
0

FirebaseAnalyticsについて

ざっくりと説明するとアプリ上等に埋め込んだイベントを集計してユーザーの行動を計測出来るFirebase上の機能です
詳細はマニュアルへ

利点

サーバ側実装が不要

基本的に実装はクライアント側だけでも完結します
ただし集計用データの設計は必要なためサーバー側のデータとまとめて集計する場合はデータ設計に注意

デフォルトで取ってくれる情報が多い

年齢、性別、端末情報等は特に何もしなくても取ってくれます
Firebaseの他の機能のログも取ってくれたりするので併用する場合は追加の実装をしなくてもデータが取れる場合があります

Firebaseの他の機能やGoogleのサービスと連携しやすい

上記にもある通りデフォルトで情報を取ってくれたりBigQueryとの連携が容易だったりします

視覚化しやすい

デモプロジェクトより

このように地域毎のアクティブユーザーが視覚化できたり
イベント数をグラフ化してくれたりします

欠点

コンソール上でユーザープロパティが25個までしか設定できない(2018/12/05 現在)

コンソール上で見れるユーザープロパティは25個まで
名前の変更も不可能なので注意
それ以上のユーザー分類をする場合はBigQueryを使う必要があるようです

ユーザー個人の情報はそのままだと見れない

一応BigQuery等と組み合わせれば見れたりするようです
FirebaseAnalyticsはあくまで全体を見るためのツールという位置づけの模様

導入理由

とあるUnity製アプリに新しく入れた機能の利用率や離脱率の計測をするために導入しました

導入方法

公式の導入マニュアルがわかりやすいためここでは割愛します

機能

機能一覧はこちら

今回の要件

今回は以下のものを計測したいという要件がありました

  • ON, OFF可能な機能なのでユーザーがどちらを選んでいるのか
  • ONにした場合ユーザーが機能のどの部分で離脱したか
  • 離脱した場合の可能な限りの詳細ログ

実装方法について

ON, OFF可能な機能なのでユーザーがどちらを選んでいるのか

ユーザー毎に状態を持つこととなるためUserPropertyでON, OFFの状態を持ちました

UserProperties.SetProperty("Enable", true);

ONにした場合ユーザーが機能のどの部分で離脱したか

各状態毎にイベントを飛ばすことで計測しました

FirebaseAnalytics.LogEvent("Phase1");

離脱タイミング等フローが決まっているものに関しては 目標達成プロセス を設定しておくと視覚化しやすいです

離脱した場合の可能な限りの詳細ログ

ユーザーの選択次第で途中で該当の機能を抜けることができたため、離脱時のログをイベントを飛ばす際に カスタムパラメータレポート として付与しました

FirebaseAnalytics.LogEvent(
    "Phase1Away",
    new Parameter("Away log"));

計測した結果何があったか

機能リリース当初はフロー調整の指標やバグの特定に役立ってくれました
実装が落ち着いた今でも特定地域でのみ起きやすい問題の追跡がしやすいため時々活躍します

最近のmacOSでは一瞬でファイルがコピーできるという話

$
0
0

この記事は KLab Engineer Advent Calendar 2018 の6日目のエントリです。

最近のmacOSでは新しいファイルシステムが採用されていて、ファイルコピーが一瞬でできますよ、性能改善やストレージの空き容量を増やすのに役立つかもしれませんよ、という話を紹介します。

最近のmacOSのファイルシステム:APFS

まず最近のMacのファイルシステムについて紹介します。2017年9月リリースのmacOS 10.13 (High Sierra) 以降、macOSでは標準のファイルシステムとしてAPFS (Apple File System) が採用されています。これはコピーオンライトファイルシステムというジャンルに属するもので、同じファイルを作成する際に実体を共有して、どちらか一方が更新された時に初めてファイルコピーを行うような仕組みを持つ、モダンなファイルシステムです。同様のファイルシステムとしてはZFSやBtrfsが挙げられます。

APFS上で実体を共有したファイルコピーを行う

そんな特徴を持ったAPFSですが、単にcpコマンドでファイルコピーをしても従来通りに別の実体を作ってしまい、APFSのメリットは生かせません。実はmac OS 10.13以降、標準のcpコマンドにAPFSのためのオプションが一つ追加されています。

$ man cp
(略)
     -c    copy files using clonefile(2)

cpコマンドに -c オプションをつけると clonefile システムコールを使ってコピーしてくれるんだそうです。これはまさに我々が求めている、実体を共有した形でのコピーです。

これを使ってコピーを行うと、ファイルのコピーは一瞬で終わります。約300MBのファイルをコピーしてみましょう。

$ /usr/bin/time cp -c pycharm-community-2018.3.dmg f1
        0.00 real         0.00 user         0.00 sys
$ /usr/bin/time cp -c pycharm-community-2018.3.dmg f2
        0.00 real         0.00 user         0.00 sys
$ /usr/bin/time cp pycharm-community-2018.3.dmg f3
        1.60 real         0.00 user         0.29 sys
$ /usr/bin/time cp pycharm-community-2018.3.dmg f4
        0.96 real         0.00 user         0.23 sys

-c オプション付きで2回、 -c オプションなしで2回、ファイルのコピーを行なってみました。前者は0.01秒未満で実行終了しているのに対し、後者は約1秒から1.5秒ほどかかっています。どうやら2桁くらい速度が違いそうですね1

また、df コマンドでディスクの空き容量を観察していると、 cp -cでは空き容量が1バイトも減りませんでした。コピーが速くてストレージ容量も消費しないということですから、いいことづくめですね。

cp -cの利用シーン

さて、この cp -c による速いコピーは何に使えるでしょうか。

まず、バイナリビルドやアセットビルドの際にファイルコピーがボトルネックになっているような部分があれば、コピーをcp -cに変更することで性能改善になる可能性があります。

また、UnityプロジェクトだとSwitch Platformを避けるため同一プロジェクトのコピーを複数持つようなことをされている方も多いかと思います。このような場合に、ワーキングコピー全体を cp -cr でコピーすればストレージにはずいぶん優しくなるはずです。

存在する重複ファイルを検出し、cp -cによって実体を共有することでディスク容量を削減することもできるでしょう。すでにそのような試みも行われています2

まとめ

  • 最近のmacOSではAPFSというファイルシステムが採用されている
  • APFSでは cp -c でファイルの実体を共有したコピーが実現できる
    • 実体を共有したファイルのどれか1つが変更されると、そのタイミングで初めてファイルコピーが行われる
  • cp -cを利用すれば性能改善やディスク空き容量増加などのメリットが得られるのでは

ハードリンクを使えば同様のディスク容量削減は実現できるのですが、ハードリンクだと編集する際に注意しないと全ファイルを書き換えてしまって事故につながる可能性があります。ファイルシステムの機能で「安全なハードリンク」を実現できるのは嬉しいですね。

APFSはまだ登場から歴史が浅いので、全面的に信用していいかどうか判断が難しいところかもしれません。とはいえ魅力的な機能を提供しているように思いますので、使えるところでは積極的に使っていきたいと個人的には考えています。


  1. 我が家の古いMacBookAirで実験しているので、読者の皆さんの手元ではもう少し違う結果になるかもしれません。 

  2. 例えば https://github.com/ranvel/clonefile-dedup 。これ自体は利用者も多くなさそうなので、もう少し定評のあるツールが clonefile(2) をサポートしてくれればそれが一番良さそうです。 

UnityのImage Effectでマンガ風の画面を作ってみる

$
0
0

この記事は KLab Engineer Advent Calendar 2018 7日目の記事です。
Advent Calendar は去年に続き参加する mizusawa-k です。よろしくお願いします。

image.png

※本記事はユニティちゃんのモデルを利用して執筆し、ユニティちゃんライセンス条項の元に提供しています
© Unity Technologies Japan/UCL

はじめに

今月頭に Tokyo Demo Fest が行われました。
KLabのエンジニアでも何名か参加していて5名も入賞するという素晴らしい成果が出ていますが、
参加してきた話を私自身聞いていて

何か面白そうだな。普段はビルドとかUI実装やってるけど、
自分もシェーダーとか使って絵作りしてみたいんだよね。

と思っていたところ、ちょうどAdvent Calendarに参加する予定がありいいきっかけだったので1
今回は普段使っているUnityで、シェーダー始めグラフィックス周辺の技術の深堀がてら、
ちょっと変わった絵をリアルタイムで出せるようにしたいと思いました。

なぜ、Image Effectでマンガ風の画面か

今回は本当に思いつきから始まったのですが、
「3D画面がマンガみたいな線とベタ、トーンだけの見た目で動いたら面白そう」
というところが取っ掛かりでした。

私自身、4年くらい前、前職でやっていた仕事で、
iOS端末上で動作するカメラ写真の線画抽出プログラムを OpenCV で作った経験があったのですが
その時に感じた
「(色彩、階調豊かな)見慣れた景色が白黒の線とベタ、トーンで置き換えられた時のインパクト」
がなかなか忘れがたく、面白いものがあったので、
今回は、同じようなことをUnityで実装するためにはどうしたら(手早く)作れるかなということを考えました。
マンガ風の画面を作りたいなと思ったのはそういう取っ掛かりです。

ImageEffectを使った実装にした理由はひとことで言えば、
「Unity 5.4で簡単に実装するのに使えそうだから
(Unity社からアセットがソースコードが見える状態で提供されていて、シェーダー初心者でも取っ付きやすそうだから)」

です。

参考までに、ImageEffectに決めた経緯はざっくり以下のようなかんじです。

  • 使用するUnityのバージョンは5.4系のみ(会社のPCで、業務の片手間でやるため)
  • 以前iOSで使った経験があるOpen CVを使うなら

    • Unity向けラッパーアセットが有料で出ているのでそれを使うかプラグインを書くかの二択2
    • 有料アセットはそこそこいいお値段するので今回はパスしておきたい
    • プラグインは実装とか確認が手間だと思うので今回はあまり使いたくない
  • というか、今回はそもそもカメラとかシェーダーとか、3D周りの勉強したい

  • 画面全体に効果をつけるならポストエフェクトというものを使うと良いらしい

  • Unityでは5.4までのポストエフェクトにはImage Effectを提供している

  • Unityが提供しているStandard Assetsの中にあるものでそのまま使えそうなImageEffectが結構ある

  • 結論:何か良さそうだからImageEffect使ってみる

マンガ風の画面とは

マンガ風の画面のイメージ

以下のような、マンガ風写真加工アプリで作れる画像のような画面を想定しています。

 → 

マンガ風写真加工アプリは以下のようなものがあるようです。

これらのアプリでは、写真を線画とベタ、トーンの画像に変換出来るほかに、
集中線を重ねてさらにマンガっぽく出来るようになっていたりします。なかなか面白いです。

※「マンガ風の画面のイメージ」で紹介した画像は、箇条書き一つ目の「漫画コミックカメラ」で作成しています。

マンガ風の画面の要素

画面の要素としては以下です。

  • 輪郭が細い線画で描かれている
  • 明るい部分が白で描かれている
  • 暗い部分が黒(ベタ)で描かれている
  • 明るい部分と暗い部分の中間の部分がトーンで描かれている

今回は時間の都合上、最後のトーンの部分は割愛し、
簡単に出来そうな白と黒(ベタ)だけの画面を作って行きたいと思います。

実際に作ってみる

前置きが少し長くなりましたが、実際に作っていきます。

開発&実行環境

  • Unity 5.4.2 p2
  • PC, Mac&Linux Standalone

マンガ風の画面を作るまでの流れ

今回は、ImageEffectを使って色々と手を動かしていった結果、
最終的に得たい絵を出すまでの流れが以下のようになりました。

1. 線画のみ抽出した絵をRenderTextureに書き出す
2. ベタのみ抽出した絵をRenderTextureに書き出す
3. 書き出した線画とベタのRenderTextureを一枚の絵としてまとめる
4. まとめた絵をメインカメラに映す

RenderTextureというのは、カメラに映っている絵を
画面外の画像として描き出しておくことができるUnityの機能です。
RenderTextureを使う事で、例えば毎フレームの描画処理を軽減したり、
ライブシーンでよく見られるモニター画像をリアルタイムに描き出すような
表現の幅を広げたりすることができます。

今回は簡単に絵を出してみたかったので、撮りたい絵の位置などを決めた後、
必要なRenderTextureごとにカメラを複製してヒエラルキ上に配置しました。

カメラに映っている絵をRenderTextureに描き出す方法

プロジェクト上にRenderTextureを新規作成してから、
描き出したいカメラをヒエラルキ上で選択して、カメラのインスペクタ上でターゲットにRenderTextureを設定します。

ベタ用カメラ(Beta Camera)のTargetTextureにベタ用RenderTexture(BetaRenderTexture)を設定
image.png

線画用カメラ(Line Camera)のTargetTextureに線画用RenderTexture(LineRenderTexture)を設定
image.png

カメラのターゲットにRenderTextureを設定した後は、
プロジェクト上でRenderTextureを選択した際にインスペクタ上に表示される情報に
カメラからRenderTextureに書きこまれた絵が表示されるようになります。

RenderTextureのインスペクタ
image.png

RenderTextureの設定では描き出すテクスチャのサイズを設定できますが、
今回は16:9でそれなりの解像度で画面に出したかったので 1440 * 768 というサイズ設定で統一させました。

カメラに映っている絵にImageEffectを適用する方法

カメラにImageEffectのコンポーネントをつけて、パラメータを調整するだけです。

UnityのStandard AssetsにはいろいろなImageEffectがありましたが、
今回私が使用したのは以下のImageEffectです。

今回利用した Unity StandardAsset の ImageEffect

イメージエフェクトの名前 使用用途
EdgeDetection 線画を抽出するイメージエフェクトとしてそのまま使用
Grayscale ベタのイメージエフェクトを作成する際に流用(そのまま使用はせず)
ContrastStretch ベタの調整用として

今回そのまま利用したのは EdgeDetection の ImageEffect のみですが、
この理由は線画抽出を手早く簡単に実現したかったためです。

ImageEffect のコンポーネントをつけたカメラは、たとえば線画なら以下のようになりました。

image.png

EdgeDetectionが複数ついているのは、片方だけだと髪の線画と顔周りの線画どちらかだけになってしまったからです。
両方同時に出すために、二つの線画抽出処理を実行させるようにしています。

カメラから得られた絵に対して線画のイメージエフェクトを適用した結果は以下です。

線画イメージエフェクトの適用結果
image.png

image.png

ベタのImageEffectの自作

ベタの絵を出すのにあたっては、
適切そうなImageEffectがStandardAssetsになかったので、自作を考えました。
とは言え、イチからイメージエフェクトやシェーダーを作るのもちょっと大変そうだったので、
流用してちょっと調整することが出来ないかと考えました。
StandardAssetsを眺めていたところ、ちょうどイメージエフェクトの中に
画面をグレイスケールに変換するというGrayScaleEffectというものがあったので
それを流用するようにしました。

基本的には影の部分など、特定の暗さの部分を陰のような形で出せればいいなと思ったので、
とりあえず輝度を見て、一定の範囲内の濃度なら指定された色として、範囲外なら透明にするようにしています。
指定色以外のピクセルを透明にするようにしたのは、後述する線画との合成するデカールシェーダーとの都合です。

ベタのImageEffectのコード(Unityのインスペクタ上で表示されるインターフェース部分)は以下です。

ThresholdImageEffect.cs
using System;
using UnityEngine;
using UnityStandardAssets.ImageEffects;

namespace ComicShader.ImageEffects
{
    /// <summary>
    /// 濃度の上限と下限を指定して、指定した色でベタ塗りするイメージエフェクトです
    /// 指定範囲外の濃度の部分は透明な色になります
    /// </summary>
    [ExecuteInEditMode]
    [AddComponentMenu("CommicShader/ImageEffects/ThresholdImageEffect")]
    public class ThresholdImageEffect : ImageEffectBase {

        /// <summary>
        /// ベタで塗る色(注:濃度しか見られていないので彩度のある色指定は未対応)
        /// </summary>
        public Color    color;

        /// <summary>
        /// 上限値
        /// </summary>
        [Range(0.0f,1.0f)]
        public float    upperThresholdValue;

        /// <summary>
        /// 下限値
        /// </summary>
        [Range(0.0f,1.0f)]
        public float    lowerThresholdValue;

        // Called by camera to apply image effect
        void OnRenderImage (RenderTexture source, RenderTexture destination) {
            material.SetColor("_Color", color);
            material.SetFloat("_ThresholdUpper", upperThresholdValue);
            material.SetFloat("_ThresholdLower", lowerThresholdValue);
            Graphics.Blit (source, destination, material);
        }
    }
}

このインターフェースでこのようなインスペクタが表示されます。

ベタのイメージエフェクトのインスペクタ表示
image.png

ベタのImageEffectのシェーダーコード(実際にテクスチャの色を弄ってる部分)は以下です。

Shader/ThresholdShader.shader
Shader "Shader/ThresholdShader" {
Properties {
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _RampTex ("Base (RGB)", 2D) = "grayscaleRamp" {}

    // ThresholdShader追加分
    _Color ("Color", Color) = (1,1,1,1)
    _ThresholdUpper ("ThresholdUpper", Range(0.0,1.0)) = 0.0
    _ThresholdLower ("ThresholdLower", Range(0.0,1.0)) = 0.0
}

SubShader {
    Pass {
        ZTest Always Cull Off ZWrite Off

CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag
#include "UnityCG.cginc"

uniform sampler2D _MainTex;
uniform sampler2D _RampTex;
//uniform half _RampOffset;     // 不要なのでコメントアウト
half4 _MainTex_ST;

// ThresholdShader追加分
uniform half _ThresholdUpper;
uniform half _ThresholdLower;
fixed4 _Color;


fixed4 frag (v2f_img i) : SV_Target
{
    fixed4 original = tex2D(_MainTex, UnityStereoScreenSpaceUVAdjust(i.uv, _MainTex_ST));
    fixed grayscale = Luminance(original.rgb);

    half2 remapC = 0;
    half2 remapA = 0;

    if (_ThresholdLower < grayscale && grayscale < _ThresholdUpper)
    {
        remapC = _Color;
        remapA = 1;
    }

    fixed4 output = tex2D(_RampTex, remapC);
    output.a = remapA;
    return output;
}
ENDCG

    }
}

Fallback off

}

カメラから得られた絵に対してベタのイメージエフェクトを適用した結果は以下です。

ベタイメージエフェクトの適用結果
image.png

image.png

線画とベタを合成する

線画とベタを合成する部分は、
テクスチャの上に汚れやシミなどのテクスチャを重ねる際などに使用する
DecalというシェーダーがUnityのプリインストールに入っているらしい記事を見かけたので、
Decalシェーダーを使用して実装しました。

実装にあたっては、
CommicMaterial という名前のマテリアルをプロジェクト上に新たに作成して、
作成したCommicMaterialにDecalシェーダーと線画&ベタのTextureをそれぞれ設定しています。

線画とベタをまとめるCommicMaterialのインスペクタ表示
image.png

今回は

  • Base に線画の RenderTexture(LineRenderTexture)
  • Decal にベタの RenderTexuture(BetaRenderTexuture)

といったかたちでそれぞれ設定しました。

合成した絵をメインカメラで表示する

メインカメラでの描画にあたっては、
あまりうまい方法ではない気がしましたが簡単に絵を出せる方法として
uGUIのCanvasを作り、CanvasにCommicMaterialを設定して、
メインカメラからCanvasのみを映す形にしました。

ちょっとわかりにくいですが、シーンはこんなかんじになっています。

シーンビューでCanvas, Cameraの配置を確認した表示
image.png

また、せっかくリアルタイムで動くシェーダーで描いているので、
最終的にメインカメラで描画される絵に動きをつけることを考えてみます。

キャラクター自身が身体を動かしたり表情を変えるようなアニメーションを付けるのも考えましたが、
今回はカメラの方をキャラクターを中心にくるくると回転移動させることにします。
(Canvasを映しているメインカメラではなく、線画とベタのRenderTextureを描き出しているカメラ二つの方)

キャラクターを中心に線画とベタそれぞれを描き出しているカメラを回転移動させるために、
線画とベタの ImageEffect がついたカメラを一つの GameObject の子としてまとめて、
二つのカメラをまとめた GameObject に以下のようなコンポーネントを付けます。

Revolver.cs
using UnityEngine;
using System.Collections;

/// <summary>
/// 設定したターゲットを中心として、Y軸で回転し続けるスクリプト
/// </summary>
public class Revolver : MonoBehaviour {

    /// <summary>
    /// 一秒で回転させる角度
    /// </summary>
    public  float angle = 50f;

    /// <summary>
    /// 回転の中心となるターゲット
    /// </summary>
    [SerializeField]
    Transform target;

    void Update () {
        Vector3 targetPos = target.transform.position;
        Vector3 axis = target.TransformDirection(Vector3.up);

        // ターゲットを中心にY軸で毎秒angle分だけ回転させる
        transform.RotateAround(targetPos, axis, angle * Time.deltaTime);
    }
}

上記のコンポーネントを付けると、以下のような形で回転する角度とターゲットが設定できるようになっています。

Revolver をコンポーネントとしてつけた GameObject のインスペクタ表示
image.png

回転する角度は実行時にも書き換えられるので、いったんデフォルト値のままとしますが、
ターゲットはユニティちゃんを設定しておきます。

今回作ったマンガ風画面の表示結果

作成したマンガ風画面の実行結果は以下のようになります。

image.png
image.png
image.png

Unityのエディタ上で動かしているシェーダーなので、
例えばベタのイメージエフェクトで閾値を変更すればベタの領域を広げる・狭くするということも出来ますし、
線画のイメージエフェクトでModeを変更すれば線画の微妙な出方を変更したり出来ます。

今後の改良点

今回はひとまず絵を出すことを目標として進めましたが、
今後より実用的なものにするためには

  • トーンのシェーダーを追加する
  • 線画、トーン、ベタ全てをマンガ風シェーダー(マテリアル)にまとめてパラメータでトーンやベタの出方を設定できるようにする
  • 実機でのシェーダーの容量や速度面での最適化する

といった改良点が考えられると思います。

また、絵作り的な面では、線画抽出のシェーダーを自作するというのも面白いかもしれません。
このあたりの改善は、次の 技術書典 の原稿あたりで出来たらいいな。と思います。3

おわりに

ここまで、駆け足ですが Unity の ImageEffect を使ってマンガ風の画面を出す方法を紹介していきました。
私自身はシェーダーはちょっとかじったレベル(シェーダープログラムをいくつか写経したくらい)で、
ImageEffect も RenderTextureも初めて触りましたし、
自分が作りたい絵を出すためにシェーダーを書くのは今回が初めてでしたが、
業務の傍らググりながら2, 3日程度の期間で何とか形にする事ができました。

こうして実際に動くもの、絵が出来てくると、
来年の Tokyo Demo Fest も参加考えてみようかなという気持ちになってきます。
Tokyo Demo Fest、ちらっと見たところ、4K以内のファイルで動画をレンダリングするような
ストイックな部門だけではないようです。

せっかく色がある画面をモノクロにまで情報落とすのも何だかもったいないかんじもありますが、
色鮮やかな映像が溢れている3Dコンテンツの中でモノクロマンガ的な絵が動いていると
それだけで少し目新しいかんじを受けられるのではと思います。
マンガ系のIPコンテンツで、ここぞというところの演出で使うのも面白いと思います。

今後の改良点にまとめた通り、現状はまだ最初の一歩というような内容ではありますが、
Unity 5 ~ 5.4のバージョンで開発しているアプリなどでは参考に出来る情報もあるかと思います。
自分の備忘録がてらつらつらまとめたところもあり
内容の割に少々長い記事になってしまったという感も否めないですが、
本記事が読者の皆様の Unity での3Dゲーム作り、絵作りの一助となればうれしいです。

明日の Advent Calendar は @hohean さんです。お楽しみに。


  1. 元々考えていたネタはRGBとかCMYKとかHDRとかとか、デジタルで色を扱う時に知っておくべき知識の再確認的な内容でした:) 

  2. OpenCV for Unity というラッパーアセットがアセットストアで公開されています 

  3. 実はKLabのエンジニア有志で技術書典に何度か参加しています。前回の技術書典5で参加した時の紹介記事はこちら。技術書典5で頒布した本では、私はHoudiniのプロシージャルモデリング機能を紹介しました 

Unityの各シーンをモックデータで再生してくれる君

$
0
0

この記事は KLab Engineer Advent Calendar 2018 の8日目のエントリです。

はい。前日まで何も準備できてなかったほっへんです。
Unity2018のこと書くとかいいながら今回はUnityでプロジェクト開発した際に作ってみて便利だったツールを紹介します

その名もUnityの各シーンをモックデータで再生してくれる君

(なげぇ

え?普通にシーンのPlayボタンを押せばよくね?って思ったあなた。甘いです!

https://github.com/luckin403/editor-play-starter
(サンプルもあるよ!

どういうケースで使えるの?

Unityでプロジェクト開発をする場合、当然APIサーバを用意する訳ですがサーバ側の実装が終わってなかったり、初期化処理やログイン等の処理が最初のシーンにしかないあまりに以下ような経験を方もいらっしゃると思います

  • 自分が作ったシーンの動作を確認するためにいちいち最初のシーンから再生する必要があるのがめんどい!!
  • 自分のシーンを作ったはいいが遷移元のシーンがまだ実装中なので確認できない...
  • サーバ側の実装はまだなのでモックデータで動かしたいけど、つなぎ込み時モック消したり、ビルドされたバイナリにモックデータ(デバッグ用コード)が含まれてほしくない...
  • シーンの初期化処理はAwake/Startで行っているので、それより前にモックデータを注入したい

EditorPlayStarterはそれらの悩みを解決してくれます

使い方

まずは各データやプラグインの初期化処理、ログインリクエスト処理等を以下の箇所に記載します

https://github.com/luckin403/editor-play-starter/blob/master/Assets/Product/Script/Editor/EditorPlayStarter.cs#L40-L55

EditorPlayStarter.cs
static void InitializeApp(Action loadMockDataScene)
{
    InitData();
    RequestLogin(loadMockDataScene);
}

static void InitData()
{
    // データを初期化したりー
}

static void RequestLogin(Action callback)
{
    // ログイン処理を行ったりー
    callback();
}

次に各シーンのモックデータの記述とシーンのロード処理を記述します
もしサーバの実装が完了している場合はここにHomeInfoを取得するために必要なリクエスト処理を記述します
ちなみに記述する場所はEditorスクリプトにあたるのでバイナリのビルド時には含まれません!(ログインの箇所ももちろん同じ

https://github.com/luckin403/editor-play-starter/blob/master/Assets/Product/Script/Editor/EditorPlayStarter.cs#L23-L34

EditorPlayStarter.cs
private static readonly Dictionary<string, Action> ReloadActionBySceneName = new Dictionary<string, Action>
{
    // ここにシーン名ごとのモックデータでのロード処理を書いてください
    {
        "Home",
        () =>
        {
            // モックデータ
            HomeSceneEntry.HofeInfo = new HomeInfo("プレイヤー名", "コメント", 99); // 今回は雑にstatic空間に...
            SceneManager.LoadSceneAsync("Home");
        }
    },
};

あとは↓のようなOnValidateを再生したいシーンの各HierarchyルートのGameObjectにアタッチします

https://github.com/luckin403/editor-play-starter/blob/master/Assets/Product/Script/BaseEntry.cs#L7-L13

void OnValidate()
{
    if (Application.isPlaying && UnityEditor.EditorPrefs.GetBool("MockReloadMode"))
    {
        gameObject.SetActive(false);
    }
}

あとはシーンを再生するだけ!

仕組み

EditorPlayStarterでは大きく2つのことをやっています

  1. UnityのPlayボタンを検知してシーンの再ロード処理を行う
  2. 再ロード直前に発火にするAwake/OnEnable/Startの暴発対策

1のPlayボタンイベントの検知についてはここを読んでもらえれば一目瞭然だと思いますので割愛

2のAwake暴発ですがこれは
- OnValidateシーンが実行状態〜Awakeが発火するまでの間に唯一発火する特殊なイベントであること
- OnValidateイベントはEditor上でしか発火しないこと
の2つの特性を使って、OnValidate時にGameObjectを非アクティブにすることでAwake暴発を防いでいます

使ってみて

プロジェクト初期開発時は各シーンが各プロジェクトメンバーによって並列で実装されるのでかなり便利だった印象です
またゲーム画面でありがちなインゲームのリザルトシーンは挙動を逐一確認するためにインゲームパートを終わらせなければいけないところをいろんなパターンのモックデータを用意することで素早く確認サイクルが回せた点も良かったと思います


日本人の9割が知らないQRコード

$
0
0

ここ数日QRコードを利用した某決済サービスが世間を騒がせていますが、いかがお過ごしでしょうか。

URLをはじめ様々な情報を伝えるために使われるようになったQRコードですが、
どのように情報が書き込まれているか知っている人は意外と少ないのではないでしょうか。

この記事では、QRコードやその他バーコードのライブラリ「zxing」をGoへ移植した過程で知った、
QRコードに関するあれこれを紹介したいと思います。

QRコードとは

QRコードの歴史については、QRコードの発明元でもあるデンソーウェーブのサイトをぜひご覧ください。

QRコードの規格は日本工業規格(JIS X 0510)だけでなく国際規格(ISO/IEC 18004)にもなっていますし、
デンソーウェーブのサイトにもあるように自由に利用できます。
そもそも主要な特許は出願から20年を過ぎて期限が切れています。

ひとつ気をつけないといけないのは、「QRコード」の商標権は現在もデンソーウェーブが持っていますので、
似ているけれど異なる2次元コードを「QRコード」と呼ぶのはご法度です。
この間違った呼び方を本当によく見かけるのでみなさん気をつけてください。

QRコードに見られる工夫

「QR」が「Quick Response」に由来しているように、素早い読み取りのための様々な工夫がみられます。

検出のための工夫

まずは3つの隅にある四角形、「位置検出パターン」です。
QRコードリーダーはまず、「黒1:白1:黒3:白1:黒1」という比率の四角形を3つ検出します。
見た目にもわかりやすいですし、素早く検出できます。

3つのパターンの位置関係から歪み補正をすることができるため、カメラが正面から少し傾いていても正確に読み取ることができます。
さらにドット数の多いQRコードでは、規定の位置に小さい四角の「位置合わせパターン」が置かれるので、
より正確な補正もできます。多少歪んだ画像でも問題なく読み取れるので、何度も撮りなおす必要がなくなります。

読み取りミスを減らすための工夫

読み取りミスを減らすことで、撮り直し回数を減らすことができます。

一般的に同じ色が長く連続していると、何個分なのかわかりにくくなり読み取りエラーが増えます。
また、黒1:白1:黒3:白1:黒1の位置検出パターンと同じ比率のパターンが現れてしまうと、位置検出を邪魔してしまいます。
QRコードではこのようなパターンを避けるために、規定の8種類のマスクから適したものを選んでXORすることになっています。
たまに最適でないマスクを選んでしまっているQRコードを見かけることもありますが、普通は問題なく読み取れます。

さらに、格納されるデータはエラー訂正符号(リード・ソロモン符号)が付加されています。
この符号は、一部分が削れたといったバーストエラーに強いという性質があり、他の2次元バーコードでもよく使われています。
QRコードでは、最大で30%程度欠損してもエラー訂正できるようになっています。

こういった工夫によって高速で確実な読み取りができるようになっています。

余談ですが、よく見かけるQRコードの中にロゴや文字が入ったものも問題なく読み取れるのは、このエラー訂正符号のおかげです。
逆に言えばその分エラー訂正能力を下げてしまうことになるので、あまりおすすめできない使い方です。

QRコードに入れられるデータ

QRコードには格納するデータの種類ごとに「モード」があり、モードを切り替えながらデータを格納するようになっています。
基本的にはテキストデータですが、どんなバイト列でも入れられる「8ビットバイトモード」があるため、原理的には何でも入れられます。

ここでは代表的なモードについて紹介したいと思います。

数字モード

1-9の数字文字列を格納するモードです。
3文字を10ビットで表現するので大変効率が良いです。
数字以外を入れるには他モードに切り替える必要があります。

英数字モード

数字(0-9)、英大文字(A-Z)、スペース、記号8種($%*+-./:)の45種類の文字を格納するモードです。
2文字ずつを11ビットで表現するので、なかなか効率が良いです。
残念ながらURLを表すには文字が足りません。

8ビットバイトモード

バイナリデータをそのまま格納するモードです。
JISやISOで規定されるデフォルトの文字コードはJIS8なのですが、守っていない実装を時々見かけます。
「拡張チャネル解釈モード」によって文字コードを指定することができるので、非ASCIIな文字を入れる場合は必ず指定すべきです。

URLを格納するには、この格納効率が最も悪いモードを使うしかありません。
もったいないですね。

漢字モード

QRコードが日本生まれということもあってか、Shift_JISの2バイト文字を格納する専用モードがあり、ISOにも含まれています。
13ビットで1文字を表現することができます。ちょっとだけ格納効率が良いです。

汉字モード

これはJISやISOには含まれていませんが、中国の国家規格(GB/T 18284)で規定されているモードです。
簡体字の文字コード(GB-2312)の2バイト文字を、漢字モードと同様に1文字13ビットで表現します。

この他にもハングル文字を格納するモードがあるらしいのですが、規格文書を見たことがないので詳細はわかりません。
情報をお持ちの方はご連絡ください。

おわりに

いかがでしたでしょうか。
QRコードの知られざる一面が垣間見れたのではないでしょうか。

こんなの知ってたよ、常識だよという方はぜひ、ZXingの移植作業を手伝ってください。
よろしくお願いします。

(画像出典:JIS X 0510)

シェーダー芸人になりたかった6か月前の自分に教えてあげたいリンク集

$
0
0

この記事は、KLab Engineer Advent Calendar 2018 10日目の記事です。

image.png

はじめに

今月頭の12/1から12/2にかけて、日本で唯一のデモパーティであるTokyo Demo Fest 2018が開催されました。

デモパーティをご存じではない方のために、公式サイトから引用します。

デモパーティは、コンピュータを用いたプログラミングとアートに 興味のある人々が日本中、世界中から一堂に会し、 デモ作品のコンペティション(コンポ)やセミナーなどを行います。 また、イベント開催中は集まった様々な人たちとの交流が深められます。

またデモについて詳しく知りたい方はこちらをご覧ください。

今回私は、このパーティのGLSL Graphics CompoというGLSLコードのみで映像を作って競うコンポTraveler 2という作品を応募して1位に選んで頂きました!


実際にブラウザ上で動作するデモや動画はこちらからご覧ください

この記事では、2018年6月から12月までの6か月間、GLSLのみでグラフィックを描画するテクニックを学びながらTraveler 2を完成させるまでに参考にさせて頂いた資料や作品を、出来る限り私が読み進めた順番にご紹介します!

対象の読者層は、一言で表現すると6ヶ月前の自分です。

  • 3Dグラフィックスの知識が多少ある。
  • シェーダーを書いたことはあるが、レイマーチングなどの所謂シェーダー芸は書いたことがない。
  • ShadertoyやGLSLSandboxの作品はよく見ているけど、謎技術すぎて理解できないし、自分では一生かけても作れないと思っている。

上記のいずれかに当てはまり、デモシーンやGLSL作品に興味がある方は(当てはまらない方も..!)、偉大な先人達が残してくれた作品や資料達を熟読してGLSL作品を一緒に作りましょう!

そもそも、GLSLってシェーダーってなんぞや!という方は、こちらから読むとよいかと思います。
GLSL で暖を取るための準備をしよう! GLSL お役立ちマニュアル

さらに踏み込んだ内容が知りたい!という方は、こちらでテクニックの解説をしているのでご覧ください!
Tokyo Demo Fest 2018 GLSL Graphics Compoで優勝した!作品の解説等

偉大な先人達の記事や資料(見た順)

※見出しをリンクにしています

The Art of Code

BigWIngs先生のyoutubeチャンネルです。

レイマーチングを学習して3Dを描画する前に2Dから学ぼうと考えて、Shadertoy Tutorialsの動画をいくつかやりました。

特に、ShaderToy LiveCoding - The Drive Homeがオススメです。

シェーダだけで世界を創る!three.jsによるレイマーチング

gam0022先生のすごくわかりやすいレイマーチングの資料です。

この資料でレイマーチングの仕組みを完全に理解しました。

wgld.orgのGLSLコンテンツ

wgld.orgdoxas先生のWebGLのあらゆるテクニックについて書かれたサイトです。

gam0022先生の資料でレイマーチングの仕組みを把握して、wgld.orgのレイマーチングチュートリアルで実践的な知識を学びました。

3Dの知識を既に持っていて、レイマーチングについて学びたい場合は、シェーダ内でレイを定義するから読み進めると良いと思います。(私もそうしました)

GLSLについてのメモ

edo_m18先生がGLSLのよく使われるビルトイン関数について、1ページでまとめているQiita記事です。

GLSLを書いた経験があまり無かったので、しょっちゅう参照していました。

特にstep関数の引数の順番をよく忘れてしまうので、その度に見ていました。

Distance Functions

iq先生によるプリミティブな距離関数や、テクニックの一覧です。

GLSLデモを作ってる人でも、距離関数についてはこのページに依存している人が多いのではないでしょうか?

今回作ったTraveler 2ボックス、球、トーラスの距離関数とsmoothminと呼ばれるテクニックをこのページからお借りしました。

距離関数のfold(折りたたみ)による形状設計

gam0022先生の折り畳みによる形状設計について書かれた記事です。

日本語でここまで丁寧に折り畳みについて解説してくれている記事はあまりないのではないでしょうか?

Traveler 2では後半のシーンの背景に、この記事に登場する回転foldというテクニックを使用しています。

Distance Estimated 3D Fractals (III): Folding Space

gam0022先生に教えてもらった全5パートある記事のうち、3つ目の記事です。

KIFS(Kaleidoscopic Iterated Function System)と呼ばれる、ループ内で折り畳みを繰り返すことにより、複雑なフラクタル形状を高速に描画する手法について書かれています。

一つ上で紹介した、距離関数のfold(折りたたみ)による形状設計と同時に読み進めました。

正規化Lambert

学生時代からサイトにお世話になっているPocol先生の正規化Lambertについて書かれた記事です。

Traveler 2はイカした感じのリアルな質感にしたかったので正規化された実装をお借りしました。

Blinn-Phong スペキュラの正規化について

hanecci先生の正規化Blinn-Phongについて書かれた記事です。

Lambertと同じく、リアルな質感にしたかったので正規化された実装をお借りしました。

楽しい!Unityシェーダー お絵描き入門!

3Dの記事が続きましたが、こちらの資料ではsetchi先生の2Dテクニックが沢山解説されています。

特にグリッド毎に動きを変えるテクニックは目から鱗でした。

Traveler 2では画面を覆うダートマスクと疑似パーティクルで、そのテクニックを使用しています。

2Dの小技 動くお絵かき

gaziya先生の2Dテクニックについて書かれたQiita記事です。

2D描画の際にsmoothstepを使用することでアンチエイリアスな形状を描画する手法や、clampとmixによるシーケンス制御を参考にしました。

条件分岐のためにstep関数を使う時の考え方をまとめてみた

Yuichiroh Arai先生のifをstepに置き換えるテクニックについて書かれたQiita記事です。

Traveler 2はシェーダーが巨大になりすぎて、制作の後半ではコンパイル時間が原因でChromeがクラッシュするということが多発していました...

そこでこちらの記事のテクニックを使ってifを使わないようにするとコンパイル時間を稼ぐことができました

その時のPRです。これを適用すると、コンパイル時間が20秒から12秒になりました!
https://github.com/kaneta1992/traveler/pull/15

レイマーチングで半歩差のつく小技集

今年の9月にGLSL Tech Night 2018という勉強会でkeim先生が登壇されたときの資料です。

Traveler 2では疑似パーティクル等のグロー表現にこちらの資料のテクニックをアイデアを頂きました。

また、資料にあるコード・リーディングはとても参考にさせて頂きました。必見です。

Perlin Noise (fBm) を使ったカメラ揺れエフェクト

Keijiro Takahashi先生のfbmをカメラの揺れに使用するテクニックについて書かれた資料です。

Traveler 2ではこのテクニックを参考に、fbmでカメラの手ブレ再現をしています。

手ブレを付けることで、無機質な映像が一気にリアルな映像になって感動したので、かなりおススメです。

偉大な先人達の作品(見た順)

※見出しをリンクにしています

The Drive Home

偉大な先人達の記事や資料の一番最初にも紹介した、The Art of Codeの中の人BigWIngs先生の作品です。

youtubeチャンネルにはこの作品をライブコーディングするチュートリアルもあるのでおススメです。

Traveler 2では、画面を覆うダートマスクの埃をこちらの作品を参考に作りました。

Monster

iq先生の作品です。

偉大な先人達の記事や資料でも紹介した、Distance Estimated 3D Fractals (III): Folding Spaceで登場するKIFSというテクニックを使って蠢く3Dオブジェクトを描画しています。

資料を見ながらこの作品を弄ったりして、KIFSがどういったものか理解することができました。

KIFS Menger

dom767先生のKIFSでメンガーのスポンジを描画する作品です。

Traveler 2ではこちらで登場する距離関数を改造して、背景に使用しています。

2nd stage BOSS by 0x4015

よっしん先生がTokyo Demo Fest 2016に4k intro作品として1位を獲得した作品です。

凄すぎる映像と音楽を見た後、改めてコード量を見ると、この中に本当にあの映像が収まっているのかと驚愕します...

Traveler 2では2DのIFSで模様を描くテクニックと、ピクセル毎に時間をオフセットしてモーションブラーを実現するテクニックを参考にしました。

[Type]

FMS-Cat先生がTokyo Demo Fest 2016にWebGL作品として2位を獲得した作品です。

エッジ検出や、文字列をレイマーチングするテクニックを参考にするためにコードを読みました。(コンパイル時間の問題で実装はできなかった...)

Traveler 2では、後半のかっこよすぎるグリッチをYoutube動画でコマ送りして参考にさせて頂きました。

RaymarchingOnUnity5

i-saint先生のUnityでレイマーチする作品です。

こちらの記事で紹介されていた、gif画像で模様が画面奥に伝っていくのがかっこよかったので参考にさせて頂きました。

http://i-saint.hatenablog.com/entry/2015/06/30/233000

Raymarching - Primitives

こちらもiq先生の作品です。

3Dの様々なオブジェクトがレイマーチで描画されています。

マテリアルの扱いについて右往左往していたのですが、こちらに登場する、距離関数の戻り値でマテリアルIDをやり取りして、最後にID毎のシェーディングをするという形に落ち着きました。

Optical Stream

私がTraveler 2をエントリーしたTokyo Demo Fest 2018のトップページにも採用されている、notargs先生の作品です。

噂によるとこれをライブコーディングで作ったというので驚きです...

Traveler 2では疑似パーティクル、マーチングループを複数に分ける、HSVを使うと3つのテクニック参考にしました。

Glitch transform

tdhooper先生のグリッチしながらオブジェクトを変形させている作品です。

これがかっこよすぎたのでアイデアを参考にさせて頂きました。

挙動を確認できるイージング関数一覧 - GLSLSandbox

GLSLSandboxなので作者が不明なのですが、イージング関数が沢山あります。

挙動もすぐに確認できるので、カメラワークに使用する好みのイージングをこちらからお借りしました。

さいごに

これからシェーダーで遊ぶ人に向けて、6ヵ月でシェーダー一般人がシェーダー芸人なった足跡を記事として残してみました。

シェーダー芸はとても楽しいので、この記事を紹介して友人にも布教していきたいと思っています!

この記事を最後まで見てくださって方々も、是非シェーダー芸でGLSL作品を作りましょう!そしてTokyo Demo Festに作品を持ち込みましょう!!

XRDC参加レポート

$
0
0

この記事はKLab Engineer Advent Calendar 2018の11日目の記事になります。

概要

VR/MR/ARに関する開発者会議です。

これまでGDCと呼ばれるゲーム系の開発者会議の一部(VRDC)として行われてましたが独立して開催されることになりました。

【公式サイト】
【Youtubeチャンネル】*執筆時2017までの一部動画が掲載されてました
場所は、アメリカサンフランシスコの
Westin St. Francis Hotel in Union Squareで開かれました。

社内でVRに興味ある人います?と声がかかり、手を上げた所、そのまま行く流れになりました。自分は、英語は中学レベルなのですがそれでも行けるチャンスがもらえてとても嬉しかったです。ちなみにGDCの方は一度も参加経験はないです。

開催期間は2日間で10/30-10/31に開かれました。
開催前日に入国して開催翌日に帰国しましたが時差の都合で5日かかりました。
自分が聴講したのはゲーム関係のVR系のものになります。

会場では、ゲームに限らず医療/教育/スポーツなど様々な業界の物がありました。

1日目

Designing Content for VR Arcades: Lessons Learned

VRアーケードをやる上でのお話をしていました。
どういったユーザーをターゲットにしているのかなど実際にアーケードで出したサービスを上げながらデータを挙げて話す内容でした。

'BRASS TACTICS': REINVENTING THE RTS FOR VR

製品情報(OculusRiftのみ)
実際のプレイ動画
VRで遊べるRTSゲームで遊びやすくするためのUI設計や操作性について解説していました。
まとめると以下の話をしていました。

  • 初期のプロトでは球体を回したりフィールドの動かしやすさを模索していた
  • 2つ目のプロトでターンテーブルを回す形に着地して調整を進める
  • しかしユーザーテストで中心に手をのばすのが大変などの課題が見つかる
  • 3つ目で調整を重ねてフィールドを動き回れる操作に修正
  • 最終的にユニットの選択やアップグレード部分なども同じ流れでアイデアを調整して完成

'Sprint Vector': Evolving VR for the Esports Scene

実際のプレイ動画
製品情報
RawDataを作っていた会社の新作で両腕を振ると走る動作ができ特殊能力を持ったキャラクターで走ってきそうゲームになります。
酔わないようにするために行った工夫やコースデザインについて解説していました。
まとめると以下のような話をしていました。

  • RawDataを製作中に移動の問題をどうにかできないかの課題があり、ユーザーテストを重ねて腕を腰で振る今の形のモーションに持っていくことができた。
  • ジャンプなどのプレイヤー操作も腕の動きを重要視して操作する形にした
  • コースデザインでは基本的に各コース3つのルートが有り、難易度が高いほどコース短縮が可能で落下した場合でも遠回りにコースに戻れる設計にしている
  • ショートカットユーザーはアイテムが少なく、遠回りしたユーザーはアイテムを活用してバランスを取っている

遊んだことあるのですがジャンプしなければ、腕を左右にふる動作があるだけでVR酔いは、かなり軽減されています。スケートの表面を滑るような感触に近いです。操作性としては腕を左右にバランスよく降るため、かなり疲れる物となっています。スポーツ性の高いゲームとなっていて遊んでいて楽しいので興味ある方はプレイしてみるといいかもです。

懇親会

各企業でハードウェア体験ができつつ開発者で交流が取れる感じのパーティが開かれていました。
お酒の提供もありましたが写真のようにカクテルもXRDC仕様になっておりました。
ノベルティの配布があり、タンブラーと帽子とシールを貰いました。

2日目

The Story of 'Beat Saber'

実際のプレイ動画
製品情報
ゲームが制作されるまでの過程や小話などを行う内容でした。
サウンドやデザインのコンセプトやリリース後の反響などを中心に話しておりました。
まとめると以下の事を話していました。

  • ビートセイバーの原型は、ChameleonRunというゲームの制作後にできていた
  • ゲームの外見デザインは、映画「トロン」のようなネオンのデザインをもとにしている
  • リリース後の売上として通常のVRはしばらくすると売上が下がるがBeatSaberの売上は、横ばいだった
  • BeatSaber専用のアーケードが作られた
  • 168の店舗/20カ国以上の場所でゲームのトーナメント行われた

日本でも遊んでる人が多そうなゲームですが自分が遊んだ感想ですとVRと音楽ゲームの操作性がうまくマッチしていて楽しい物となっています。

Powering a Cognitive Assistant for the Blind Using AR

盲目の方のサポートとしてMRデバイス(HoloLens)を使ったという学生による講演です。
空間認識を行い、音声で案内ガイダンスを出すという形でした。
実際に被験者の方に協力してもらい動画で成果を発表する感じになっておりました。

Lighting a Legend: Art Pipelines in 'Hold The World' with Sir David Attenborough

実際のプレイ動画

製品情報(OculusRiftのみ)
ロンドン自然博物館が体験できるVRツアーの制作事例になります。
イギリスの動物学者デイビッド・アッテンボローさんとVRで博物館のツアーをするといったものになります。
マイクロソフトと共同で行い、動物学者の方のモーションキャプチャー・博物館の展示品を3Dスキャンするなど物量と規模が感じられる内容でした。

Design Beyond the Screen: Mixed Reality at Osmo

Osmoと呼ばれる子供向けの教育コンテツについてのお話です。
iPadなどのタブレットデバイスと連携させて行えるデバイスになっており、遊ぶコンテンツに合わせて使う道具が変わる形になるようです。
UIで工夫した点や子供が混乱しないようにするには、どうするべきなのかの事例などを紹介していました。

総評

技術的な解説というよりは、それぞれのサービスでユーザーに向けてどういった取り込みをしているのかといった内容が多くみられました。
Oculus専用でリリースしているタイトルが2つありましたが逆にHTCViveの方で専用のリリースしているタイトルで講演等がなかったのは、不思議に感じました。
日本だと広まっていない方面での話やVRの制作過程などを聞くことができ参加できてとても良かったです。

会場の様子としては、朝に行くと廊下に朝食がずらっと並んでいたのが印象的でした。
只、ホテルでも食事サービスがあり、お腹いっぱい状態だったので食べられませんでしたが
食事をしながら見ても良いという形になっていたのは面白いなと思いました。

また、会場はホテルで行いましたが、ちょうどホテルのストライキがあり、すべての入り口がプラカードを持つ人々で巡回していたのは驚きました。
ちゃんと許可を得てやっているので危険なものでもないのですが日本と異なる文化に触れられた気がします。

GDC VaultにXRDCのセッション動画は上がっており、会員になっていれば一部セッション見れるようなので会員の方は確認してみると良いと思います。

Tokyo Demo Fest 2018のDemo Compo優勝作品の解説(グラフィック編)

変数の値で検索できるコンポーネント検索ツールを作った話

$
0
0

この記事は KLab Advent Calendar 2018 13日目の記事です。

はじめに

みなさんは既にリリースされた処理に手を入れた際に、影響範囲の洗い出しをどのように行っていますか?
ソースコード内で参照されているのであれば、IDEの参照検索機能で確認できますが
Prefabなどにアタッチされているコンポーネントを検索するのは少し手間です。

  1. 検索したいコンポーネントの.metaの中に定義してあるguidをコピー
  2. grep -inr [検索したいコンポーネントのguid]

でコンポーネントを参照している箇所の列挙だけはできるのですが、
実際の利用状況(非アクティブ化されているかなど)まではわかりません。

そこで今回は、プロジェクト内から利用箇所を取得したうえでコンポーネントの値設定状況で絞り込めるEditor拡張を作ってみました。

その過程で学びがあった事に関していくつか書き残しておこうかと思います。

作ったもの

  • 検索設定の編集
  • 検索結果の一覧表示
  • 選択オブジェクトの詳細表示

で構成されています。

入力された文字列からTypeを取得する

public static Type GetTypeFromAssembly(string TypeName)
{
    var type = Type.GetType(TypeName);

    if (type != null)
    {
        return type;
    }

    if (TypeName.Contains("."))
    {
        var assemblyName = TypeName.Substring(0, TypeName.IndexOf('.'));

        try
        {
            var assembly = Assembly.Load(assemblyName);
            if (assembly == null)
            {
                return null;
            }

            type = assembly.GetType(TypeName);
            if (type != null)
            {
                return type;
            }
        }
        catch (System.IO.FileNotFoundException)
        {
        }
    }

    var currentAssembly = Assembly.GetExecutingAssembly();
    var referencedAssemblies = currentAssembly.GetReferencedAssemblies();
    foreach (var assemblyName in referencedAssemblies)
    {
        var assembly = Assembly.Load(assemblyName);
        if (assembly != null)
        {
            type = assembly.GetType(TypeName);
            if (type != null)
            {
                return type;
            }
        }
    }

    return null;
}
  • Type.GetType(TypeName)
    • 自作クラスが取得できます
  • assembly.GetType(TypeName)
    • UnityEngine.UI.TextやUnityEditor.EditorWindow など他のアセンブリに定義されているクラスが取得できます。

変数値判定用の検索フィルターを表示する

set.PNG

フィルター要素の初期化
void InitFilterFieldInfo()
{
    if (Settings.Instance.Type == null)
    {
        return;
    }

    var type = Settings.Instance.Type;
    var flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;

    var propertys = type.GetProperties()
        .Where(p => p.CanWrite)
        .Select(p => new FilterFieldInfo(p.Name, p.PropertyType, null));

    var fields = type.GetFields(flags)
        .Where(f => f != null)
        .Where(f =>
        {
            var isValid = f.IsPublic;
            isValid |= f.GetCustomAttributes(typeof(SerializeField),true).Length > 0;
            return isValid;
        })
        .Select(f => new FilterFieldInfo(f.Name, f.FieldType, null));

    Settings.Instance.FilterParameters = propertys
        .Union(fields)
        .Where(x => !Settings.Instance.ignoreFilterField.Contains(x.FieldName))
        .ToArray();
}

検索対象に指定されているTypeのPropertyとpublicなFieldを取得し、
フィルター制御用のクラスに変換し保持します。

public,[SerializeField]なフィールドの取得
var flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;

var isValid = f.IsPublic;
isValid |= f.GetCustomAttributes(typeof(SerializeField),true).Length > 0;

BindingFlags.NonPublicを指定することで、引数なしGetFields()+privateなフィールドを取得できます。

そのうえで、SerializeField属性を持っているかの判定を行っています。


制御用class
public class FilterFieldInfo
{
    public string FieldName { get; private set; }

    public Type Type { get; private set; }

    public object Value { get; private set; }

    public bool IsActive { get; private set; }

    public void OnGUI()
    {
        EditorGUILayout.BeginHorizontal();
        IsActive = EditorGUILayout.Toggle(IsActive, GUILayout.Width(30));
        if (Type == typeof(int))
        {
            int val = Value == null ? 0 : (int)Value;
            Update(EditorGUILayout.IntField(FieldName, val));
        }   
        /// ~~~ 中略 ~~~  ///
        else if (Type.IsEnum)
        {
            object val = Value == null ? System.Enum.GetValues(Type).GetValue(0) : Value;
            Update(EditorGUILayout.EnumPopup(FieldName, (System.Enum)val));
        }
        else if (Type.IsClass)
        {
            Object val = Value == null ? null : (Object)Value;
            Update(EditorGUILayout.ObjectField(FieldName, val, Type, true));
        }
        else
        {
            Debug.LogWarning(string.Format("Type : {0}: {1}", Type, FieldName));
        }
        EditorGUILayout.Space();
        EditorGUILayout.EndHorizontal();
    }
}

項目表示に必要な情報と、検索条件にするかどうかのフラグを持たせています。
OnGUI内で型に応じた表示処理定義し、EditorWindowクラスから呼び出しています。

コンポーネントがアタッチされている全てのオブジェクトを検索する

Project内から全取得する
void CacheObjects()
{
    if (HasCache)
    {
        return;
    }

    var paths = GetAssetPaths();

    var assetPaths = paths
        .Where(p => Settings.Instance.Extensions.Contains(System.IO.Path.GetExtension(p)))
        .ToArray();
    var scenePaths = paths
        .Where(x => x.EndsWith(".unity"))
        .ToArray();

    for (int i = 0; i < scenePaths.Length; i++)
    {
        EditorSceneManager.OpenScene(scenePaths[i], OpenSceneMode.Additive);

        string str = string.Format("{0} : {1}/{2}", scenePaths[i], i + 1, scenePaths.Length);
        EditorUtility.DisplayProgressBar("Sceneの展開中", str, scenePaths.Length / Mathf.Max(1, i));
    }

    EditorUtility.ClearProgressBar();

    cacheArray = assetPaths.SelectMany(path => AssetDatabase.LoadAllAssetsAtPath(path))
        .Concat(UnityEngine.Resources.FindObjectsOfTypeAll<GameObject>())
        .ToArray();

    HasCache = true;
}

Prefabはpathを元にAssetDatabase.LoadAllAssetsAtPath(path)を行うことで取得できます。
しかし、Scene内のオブジェクトの情報を取得するためにはそのSceneを開かなければならず
1. .unityファイルのアセットパスの取得
2. EditorSceneManager.OpenScene(path, OpenSceneMode.Additive)でSceneを開く
3. UnityEngine.Resources.FindObjectsOfTypeAll<GameObject>()でオブジェクトを取得
といった手順をとる必要があります。

※OpenSceneMode.Additiveで開かなければ参照を保持できません

取得したインスタンスをGUI表示用クラスに変換する

GUI表示用class
public struct BehaviorFields
{
    public string Name;
    public string Tag;
    public bool Enabled;

    public BehaviorFields(Behaviour behaviour)
    {
        Name = behaviour.name;
        Tag = behaviour.tag;
        Enabled = behaviour.enabled;
    }

    public BehaviorFields(Component component)
    {
        Name = component.name;
        Tag = component.tag;
        Enabled = true;
    }
}

public ComponentData(UnityEngine.Object obj)
{
    this.Obj = obj;

    var b = obj as Behaviour;
    Behaviour = new BehaviorFields(b ?? (obj as Component));

    AssetPath = AssetDatabase.GetAssetOrScenePath(obj);
    this.IsSceneObject = AssetPath.EndsWith(".unity");

    // e.g. Text =>  Canvas\Button\Text
    TransformPath = GetTransformPath();
    AffiliationName = AssetPath.Split(new char[] { '/' }).LastOrDefault() ?? "";

    var color = ColorUtility.ToHtmlStringRGB(Settings.Instance.ResultTextColor);
    label = string.Format("[Name] <color=#{0}>{1}</color> | [AssetPath] <color=#{0}>{2}</color>", color, ObjectName, AssetPath);

    Settings.OnUpdateSetting += name => { guiCacheExpired = true; };
}

こちらのクラスは検索結果の数だけ生成されるため、大規模なプロジェクトの場合検索実行時・表示時に負荷をかける原因になっていました。
負荷を軽減するために行った対応は以下の通りです。
1. 文字列の結合処理を更新処理内では行わないようにする
2. Colorをnewしないようにする(予め定義しておく)
3. 表示判定を行うのは表示条件が変わった時だけにする(判定結果のキャッシュ)

これらを行うことで処理速度が15分から10秒ほどまで改善されました。

フィルターで指定された値を持っているかを判定する(表示判定)

フィルターの値と、オブジェクトに設定されている値の一致判定
bool CheckConditionsSatisfied()
{
    // ーーーー略ーーーー

    foreach (FilterFieldInfo filter in Settings.Instance.FilterParameters)
    {
        SerializedObject so = new SerializedObject(Obj);
        var property = so.FindProperty(filter.FieldName);
        if (property != null)
        {
            if (property.type == "int")
            {
                return (int)filter.Value == property.intValue;
            }
            // ーーーー略ーーーー
            else if (property.type == "Enum")
            {
                return (int)filter.Value == property.enumValueIndex;
            }
            else
            {
                if (filter.Value == null && property.objectReferenceValue == null)
                {
                    return true;
                }
                else if (filter.Value == null || property.objectReferenceValue == null)
                {
                    continue;
                }

                var obj = Convert.ChangeType(filter.Value, filter.Type);
                var obj2 = Convert.ChangeType(property.objectReferenceValue, filter.Type);
                return obj.GetHashCode() == obj2.GetHashCode();
            }
        }
    }

    return false;
}

SerializedPropertyのValueが型毎に違う変数に入っているので辛い感じになっています。
一致判定を==でやってしまっていますが、範囲判定に変えた方が良かったかもしれません。
参照型の判定は、Convert.ChangeType(value, type) で変換した後にHashの比較で行っています。

おわりに

普段Editor拡張でコンポーネントの情報を取得するときは
SerializedObjectやSerializedPropertyを使用してよろしくやることが多いので、
型情報からGUI表示するのは手間がかかりました。

今回の実装で初めて触るAPIも多くあったので、もっと色々触って精進してきたいと思います。

参考

Unity のエディタ拡張で FoldOut をかっこよくするのをやってみた
UnityBulkConverter
Type.GetType(string) does not work in Unity
リフレクションのBindingFlagsは一旦これだけ覚えておこう

CEDEC+KYUSHU2018で話足りなかったテストの話

$
0
0

この記事は KLab Advent Calendar 2018 14日目の記事です。

こんにちはhamasan05です。
先日CEDEC+KYUSHU2018に
大規模モバイルオンラインゲームを支えるソフトウェアアーキテクチャ開発とその使用例
というタイトルで登壇してきました。
(資料はただ今公開準備中なので公開次第URL差し替えます)

セッションを準備するにあたってたくさん準備をしたのですが
泣く泣くカットしたことがたくさんありました。
今回はその中で有用なプログラムネタがあったので紹介します。
「自動テストの拡大」に関する部分です。

セッションの概要

新しいアーキテクチャの採用によりサーバクライアント間のテストを自動化することに成功したのがセッションでのお話

cedec1.png
cedec2.png
cedec3.png

実際にどうやっているのか

今回紹介する内容ではクライアント側はUnity(言語はC#)、サーバ側はPythonを利用しています。
ドライバはPytestを利用していて通信の置き換えはPython for .NETを利用しています。
Python for .NETの使い方についてはすでに記事があるため割愛します。

今回は実際にテストに利用する際に気をつけるべき点として
3つのポイントを紹介させていただきます。

  1. クライアントロジックはピュアなC#で記述する
  2. サーバロジックの時間のかかる処理はまとめてパッチを当てておく
  3. クライアントロジックとサーバロジックを通したテストは一番最後にやる

クライアントロジックはピュアなC#で記述する

ロジック部分はピュアなC#で記述してmonoでコンパイル可能かつ動作できるようにしておきます。
これはテストがどんな環境でも動くようにするためです。
UnityEngineに依存した部分やOSに依存した部分があると動作する環境が限られてしまいます。
(作業者のPCなどでは動くが、サーバではUnityやAndroid SDK、Xcodeなどインストールしないと動かない等)
環境を選ばずにテストできることによって、テスト用に高速なマシンを選択したり、並列処理をしたりと柔軟性が拡大します。

上記の観点からクライアントロジックの一部はテスト用に差し替えられるように設計上隔離しておくと良いです。
UnityEnginge等に依存する処理は隔離した上でテストに影響を与えない処理に差し替えたダミーを準備しておきます。

また、通信を行う処理についても物理的に隔離しておく必要があります。
こちらは単純なダミーではなくPython側の処理をコールする処理で上書きをしておきます。
これらのダミーのクラス群とプロダクトに含まれるべきロジック部分をコンパイルしてDLLを作成してテストを行います。

サーバロジックの時間のかかる処理はまとめてパッチを当てておく

テストで大幅に時間がかかる箇所については、Pytestの共通の初期処理で予めパッチを当てておきます。
大幅に時間が掛かるものの代表例としてはネットワーク関連になります。
DBや他のサービスへのアクセスが有る場合はサーバ側でも共通で固定値を返すようなダミーを作って
パッチを当てておくと良いです。

クライアントロジックとサーバロジックを通したテストは一番最後にやる

当たり前の話ではあるのですがC#とPythonのテストをそれぞれきっちりやってから最後に通しのテストをしたほうが良いです。

このテストは一定成約があり問題があったときに原因特定がやや難しいです。
デバッガが使えなかったりスタックトレースが情報不足だったりします。
(問題があった場合はデバッグライトを使って駆使して特定しているのが現状です)
できるだけ品質を上げた状態でテストすることが望ましいです。

最後に

CEDEC+KYUSHU2018での私達のセッションに参加していただいた皆様、この記事を読んでくれた皆様に心より感謝します。

Unityで.NET 3.5 Equivalentなのに使えちゃうC#6.0の記法

$
0
0

KLab Engineer Advent Calendar 2018 の15日目の記事です

環境

Unity5.5以降
.NET 3.5 Equivalent

上記の環境ならプロジェクト設定は通常C#4.0のはずですが、ごく一部の新しい記法が使えます。

実は使えるC#6.0の記法

・getter のみの自動プロパティ、構造体の自動プロパティ初期化

public class Hoge 
{
    // getter のみの自動プロパティ
    public int Hogehoge{ get; }

    public Hoge(int hoge){
        Hogehoge = hoge;
    }
}

public struct Fuga 
{
    public int Fugafuga{ get; private set; }

    public Fuga(int fuga){
        // 構造体の自動プロパティ初期化
        Fugafuga = fuga;
    }
}

これはC#6.0からの記法でreadonlyなプロパティが定義できます。
ただ、C#6.0のプロパティ初期化子は使えないためかならずコンストラクタで初期化する必要があります。

public class Hoge
{
    // これはエラーになる
    public int Hogehoge{ get; } = 10;
}

・列挙型の基底型

enum Hogehoge : System.Int32
{
    Hoge, Fuga,
}

この記法自分はほとんど書いたことがなかったんですが、参考資料によると、

C# 5.0までは、この基底型の指定は「sbyte、 byte、 short、 ushort、 int、 uint、 long、 ulong、 char のいずれか」 という仕様になっていました。 つまり、同じintを指しているはずの、System.Int32という書き方は受け付けられませんでした。
これが、C# 6では受け付けられるようになりました。

らしいです。

・変数の「意味不変」ルール

class Hoge
{
    double hoge;

    void Fuga(bool b)
    {
        hoge = 1.0;
        if (b)
        {
            int hoge; // C# 5.0まではエラーに
            hoge = 1;
        }
    }
}

こちらの仕様変更も参考資料によると、

上記コードのように、ifの外ではフィールドhogeを使っていて、ifの中では同名の変数hogeを定義して使うということすらエラーにしていました。
ところが、C# 6では、この前半のような判定は、大変な割にメリットが少ないということで、判定しない(エラーにならない)よう変更されました。

とのことです。

・オーバーロード解決の改善

int Hoge(Func<Func<int>> f) { return f()(); }
int Hoge(Func<Func<int?>> f) { return f()() ?? 0; }
float Hoge(Func<Func<float>> f) { return f()(); }

void Main(){
    // C#5.0まではこのように型を明示する必要があったが
    Hoge((Func<Func<int>>)(() => ()=> 10));
    // C#6.0では正常にオーバーロードされる
    Hoge(() => ()=> 10);
    Hoge(() => ()=> null);
    Hoge(() => ()=> 1.5f);
}

C# 5.0まではFunc<Func<int>>というような、入れ子になったジェネリックはオーバーロードが正常に行われなかったんですが、それがC#6.0で改善されました。

・拡張メソッドでコレクション初期化子

class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

static class PointExtensions
{
    public static void Add(this List<Point> list, int x, int y)
    {
        list.Add(new Point { X = x, Y = y });
    }
}

class Hoge
{
    void Main()
    {
        var points = new List<Point>
        {
            // PointExtensions.Add が呼ばれる
            { 1, 2 },
            { 4, 6 },
            { 0, 3 },
        };
    }
}

コレクション初期化子自体はC#3.0からの機能ですがAdd関数が通常のメソッドでないと動作しませんでした。
これがC#6.0から拡張メソッドの実装でも動作するようになりました。

・「確実な初期化」の判定改善

int x;
if (false && x == 3) // C# 5.0まではエラーに
{
    x = x + 1; // ここはC# 5.0まででもOK
}

参考資料によると

C#は、未初期化領域の問題を避けるため、「変数は確実に初期化してからでないと値を読み出せない」という仕様になっています。この「確実な初期化」(definite assignment)がされたかどうかの判定は、ある程度コードの流れを追って判定してくれます。例えばifやswitchで分岐がある場合でも、すべての分岐先で初期化してあれば「確実な初期化」済みと見なされます。
また、絶対に通らない場所は判定外です。 例えば、if (false) { }の中(絶対にこの中は通らない)では、未初期化変数を読みだしていてもエラーにはなりません(どうせ通らないので問題ない)。
上記のコードは&&の性質(左側が偽だったら右側は評価しない)上、「絶対に通らない場所なので判定外」としてもいいはずですが、C# 5.0まではエラーになっていました。C# 6ではエラーになりません。

とのことです。

まとめ

C#6.0には他にもいろいろ更新がありますが、
ここまで紹介した記法のみUnity5.5以降の.NET 3.5 Equivalentでも動作します!
ただ、IDEにはエラー構文として検知されてしまう場合もあるので自己責任でご使用ください。

おまけ

当時Unity5.5でコンパイラが更新されたときに大きく取り上げられたのは
Listをforeachしたときに発生していたGCが改善されたとか、
C#5.0 foreach仕様変更 (参考資料)とかがありましたが、
C#6.0が一部使えたことを自分は最近まで知りませんでした。

Unity2018.3にはもうC#7.2が使えるようですが、色んな事情によりUnityのバージョンアップができなかったり、.NET 4.x Equivalentに更新できなかったりしてもC#6.0が(一部だけ)実は使えたので、できたら活用していきたいです。

参考

C#5.0の新機能
C#6.0の新機能


GoでGoogle Calendar APIをいじって会議室を探す話

$
0
0

この記事は KLab Advent Calendar 2018 16日目の記事です。

はじめに

最近、業務でミーティングを開催する側になることが増えたのですが、日中は会議室が空いていないことが多く、特に参加メンバーが多い場合に予定のすり合わせで苦労することが増えました。

KLabでは会議室や備品などもGoogle Calendarのリソースとして登録されており、ブラウザ上から予約を行うのですが、
明日までに同僚10人が参加する1時間のミーティングを組まないといけない!
といった状況で、会議室が空いている時間と同僚10人のカレンダーをにらめっこするのはかなり骨が折れます。

そこで、予定のすり合わせをもっと簡単に行えないかと思い、最近社内でツール作成によく使われるGoの勉強がてら、Google Calendar APIをいじってみました。

GoでGoogle Calendar APIを叩く

Go Quickstart を見つつサンプル通りにやっていくだけで簡単に自分のカレンダーを取得できました。
(途中、Goのバージョンが古いと怒られたので1.9から1.11.2にあげました1

取得した予定を出力するとこのようになります。(伏せ字で見づらくてすみません)
goQuickstart.png

リソース(会議室)の予定を取得する

calendar-gen.go
func (r *EventsService) List(calendarId string) *EventsListCall {
    c := &EventsListCall{s: r.s, urlParams_: make(gensupport.URLParams)}
    c.calendarId = calendarId
    return c
}

google-api-go-client
リソース(会議室)の予定を取得するには、会議室のcalendarIdが必要です。
カレンダーIDはブラウザ上のGoogle Calendarから取得することができます。
まずは 同僚のカレンダーを追加 から必要なリソースを追加します。
colleague.png

追加したカレンダーを選択し、[設定]->[カレンダーの統合] と進むと
カレンダーIDが記載してあります。
calendarid.png
これで会議室の予定も取得できるようになりました。

予定の表現の仕方

自分と会議室の予定が取得できたら、それらから「双方の予定が空いている時間」を探します。
今回、上手いやり方が思いつかなかったので、ビット演算でやることにしました。

会議室の予約のほとんどは30分単位で行われているので、1日を30分ごとのブロックに分割し、予定がある = 1 予定がない = 0というビットで表現しました。

つまり、右から1ビット目が「0:00〜0:30」の予定を、2ビット目が「0:30〜1:00」の予定を、21ビット目が「10:00〜10:30」の予定を表しているという状態です。
1日は24時間なので、30分毎に分割しても48ブロックであり、64bitあれば余裕で表現することができます

例えば、12月16日の
- 午前1時〜2時
- 午前10時〜10時半
- 午後15時〜16時半
- 午後20時〜24時
に予定が入っていた場合、

00 00 00 00 00 00 00 00              // 使用しない左16bit
11 11 11 11 00 00 00 01 11 00 00 00  // 左端は23:30、右端は12:00を表す
00 01 00 00 00 00 00 00 00 00 11 00  // 左端は11:30、右端は0:00を表す

つまり12月16日の予定を64ビット表記にすると
0000000000000000111111110000000111000000000100000000000000001100

と表すことができます。

自分と会議室の予定を比較

まず、Calendar APIから取得できる日付はただの文字列なので、扱いやすいようにtime.Time型にパースします

format := "2006-01-02T15:04:05-07:00"
startTime, err := time.Parse(format, item.Start.Datetime)

次に、日付とその日の予定を表すビット配列でmapを作り、
取得した予定のstartTime endTimeを元にビットを立てていきます。

calendar.go
m := map[string]uint64{}
for _, item := range events.Items {
    /*
      時刻のパースなど、省略
    */

    // 予定の開始時刻のビット位置を計算
    startTimeBit := uint64(startTime.Hour() * 2)
    if startTime.Minute() >= 30 {
        startTimeBit++
    }
    // 予定の終了時刻のビット位置を計算
    endTimeBit := uint64(endTime.Hour() * 2)
    if endTime.Minute() == 0 {
        endTimeBit--
    } else if endTime.Minute() > 30 {
        endTimeBit++
    }
    // 予定の時刻にビットを立てる
    date := item.Start.Date()  // 例: "2018-12-17"
    for i := startTimeBit; i <= endTimeBit; i++ {
        m[date] |= 1 << i
    }

実際に取得できた自分および会議室の予定のmapはこのようになります。2

sample
// 自分の予定
mySchedules := map[string]uint64{
    "2018-12-17": 0000000000000000111111110011100000100110110011111111111111111111,
    "2018-12-18": 0000000000000000111111111111101111111111110011111111111111111111,
}
// 会議室の予定
mtgRoomSchedules := map[string]uint64{
    "2018-12-17": 0000000000000000000000000011111111111111000000000000000000000000,
    "2018-12-18": 0000000000000000000000110101111111111011110000000000000000000000,
}

最後に、自分と会議室の予定を比較して両方が空いている時間を探します。
ビットが立っている位置が予定がある時刻なので、論理和を取って0となる位置(=どちらも予定がない時刻)を探します。

calendar.go
for key, _ := range mySchedules {
    // 論理和をとって自分と会議室両方が空いている時間を算出
    schedule := mySchedules[key] | mtgRoomSchedules
    // bitを時刻にデコード
    fmt.Printf("-------%v-------\n", key)
    for i := uint(0); i < 48; i++ {
        if schedule&(1<<i) != 1<<i {
            hour := i / 2
            minute := i % 2 * 30
            fmt.Printf("%v時 %v分 is OK!\n", hour, minute)
        }
    }
}

出力した結果がこちらです。

-------2018-12-17-------
19時 0分 is OK!
19時 30分 is OK!
-------2018-12-18-------
10時 0分 is OK!
10時 30分 is OK!

12月17日 19時〜20時 12月18日 10時〜11時 であれば
自分、会議室ともに空いていることがわかりました。

今後の展望など

今回は自分と会議室1つだけの予定を比較しましたが、他の会議室すべてと比較したり、同僚のカレンダーIDから予定を取得することで、冒頭の
明日までに同僚10人が参加する1時間のミーティングを組まないといけない!
といった状況でも、簡単に空いている時間を探すことができます。

また、今回は時間がありませんでしたが、予定の取得だけでなく登録もできるので、提示された選択肢を選ぶだけでカレンダー登録まで行えるようにしたいです。

ゆくゆくはSlackbotにすることで、誰もが簡単に(ブラウザでGoogle Calendarとにらめっこすることなく)ミーティングを設定できるようになればと思います。


  1. 1年に1回程しかgoを触っていないのがバレます。 

  2. 早朝や深夜に会議室を使うことはまず無いので、最初からこの時間のビットは省けば32bitでも十分足りることに途中で気付きました。 

iOSの「ショートカット」アプリとXamarinアプリを連携させてみた話

$
0
0

この記事は KLab Engineer Advent Calendar 2018 の17日目の記事です。

概要

iOSの自動化ツールである「ショートカット」アプリと、x-callback-urlを使った連携をXamarinで実装してみました。

x-callback-url のページにはInterAppCommunication(以下IAC)というフレームワーク用意されていますが、これに似たものをXamarin+C#に移植して実装します。

x-callbackも「ショートカット」アプリもiOS用のものなので、iOSユーザが対象です。

ショートカットとは

簡単に説明すると、自動化マクロをビジュアルプログラミングできるiOSアプリです。スクリプトの共有もできます。
昔は「workflow」と呼ばれていたようですが、名称を「ショートカット」に変えたようです。とても検索しづらい。

変数や繰り返しなど、基本的なプログラミング機能を備えているほか、「カレンダー」などの標準のiOSアプリだけでなく、「Twitter」「Evernote」などへの操作も標準で用意されています。
SSH経由でスクリプトを実行したり、「Pythonista」(有料アプリ)と連携してPythonコードを実行することもできるようなので、なかなか夢が広がります。

x-callback-urlとは

URLスキームを利用したiOSアプリ間の連携を行うためのプロトコルです。
アプリAがアプリBを起動し、アプリBの処理が終わったらアプリAに処理が戻る、といった事ができます。URLのクエリパラメータとして引数を受け渡しができるので、細かい連携も可能です。

それなりに昔からあるので、いろいろなアプリが対応しているようです

URLは以下の書式で記述します

<scheme>://<host>/<action>?<x-callback parameter>&<action parameter>
  • scheme: 起動したいアプリのURLスキーム
  • host: x-callback-url
  • action: 実行したい内容。アプリの実装による。
  • x-callback parameter: x-callback用の引数。
  • action parameter: actionに渡す引数。アプリの実装による。接頭辞にx-は付けないほうが良い。

x-callback用の引数は以下のものがあります。

  • x-success: 成功した時に起動するURL
  • x-cancel: キャンセルした時に起動するURL
  • x-error: エラーの時に起動するURL
  • x-source: 呼び出したアプリ

実装

使用環境

  • macOS Mojave 10.14.2
  • Xcode 10.1(10B61)
  • Visual Studio Community 2017 for Mac 7.7(build 1868)
  • iOS 12.1.1(16C50)
  • ショートカット 2.1.2

プロジェクトの作成

x-callback-urlでのみ使うので、なるべくシンプルにiOS>アプリ>単一ビューアプリで作ります。

チーム設定やプロビジョニングは慣れないとハマるポイントですが、この記事では割愛します。
おもちゃを作って試す程度ならこのあたりの記事が参考になります。
【Xcode】AppleDeveloperProgramに登録不要!!実機ビルドする方法
Xamarin StudioでiOSの無料実機テストするまでのメモ書き

URLスキームで起動できるようにする

URLスキームで起動できるようにするためにはInfo.plistを変更する必要があります。
以下のように、詳細設定>URLを追加を選択してURLSchemeを設定します。
今回はxcallbackurlで起動できるようにしました。
urlScheme.png

とりあえず雑にコールバックさせてみる

ショートカット側

  1. 「テキスト」でxcallbackurl://x-callback-url/testを設定する。
  2. XコールバックのURLを開くに渡す。

ショートカットの設定

「ショートカット」アプリがx-callbackのパラメータを埋めてくれるので、実際には以下のように呼び出されます。

xcallbackurl://x-callback-url/test?x-cancel=shortcuts-production://x-callback-url/ic-cancel/82FF00AF-11AF-4A86-A94C-E7DB7EB69950&x-error=...

ここからx-sucessを取り出して実行してみます

AppDelegate.cs
using System;
using System.Web;
// 略

namespace xcallbackurl
{
    [Register("AppDelegate")]
    public class AppDelegate : UIApplicationDelegate
    {
        // 略

        [Export("application:openURL:options:")]
        public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
        {
            var uri = new Uri(url.AbsoluteString);
            var query = HttpUtility.ParseQueryString(uri.Query);
            var xSuccess = query.Get("x-success");
            if (!string.IsNullOrEmpty(xSuccess))
            {
                app.OpenUrl(new NSUrl(xSuccess + "?hoge=fuga"), new NSDictionary(), null);
            }
            return true;
        }
    }
}

まずURLスキームで起動された場合のURLからクエリを取り出します。
次に x-success の返答URLに hoge=fuga というパラメータを追加してコールバックを返します。

ちなみに初期状態では System.Web がなかったので、 System.Web.Services のパッケージを追加しました。 プロジェクト>参照の編集 から追加できます。

実行してみる

実行結果

実行すると一度作成したアプリが起動し、すぐに「ショートカット」がアクティブになります。戻り値も受け取れました。

もう少し扱いやすくする

提供されているObjective-CのフレームワークInter-App Communication(IAC)を参考にC#に移植してみます。「ショートカット」アプリから呼び出せるようになれば十分遊べるので、URLスキームで起動する側ことにしました。

実装のためのインタフェースと管理クラスを作る

挙動を実装するためのインタフェースとして IXCAganet を作ります。
さらに、インタフェースをまとめるクラスをSingletonで作っておきます。

IXCAgent.cs
public delegate void XCSuccess(Dictionary<string, string> parameters);
public delegate void XCCancel();
public delegate void XCError(string errorMessage, string errorCode, string errorDomain);
public interface IXCAgent
{
    bool Supports(string action);

    void Perform(string action, Dictionary<string, string> parameters,
        XCSuccess onSuccess, XCCancel onCancel, XCError onError);
}
XCManager.cs
public class XCManager
{
    public static XCManager Instance { get; } = new XCManager();
    private XCManager(){}

    HashSet<IXCAgent> agents = new HashSet<IXCAgent>();

    public void AddAgent(IXCAgent agent)
    {
        agents.Add(agent);
    }

    public void ClearAgegnts()
    {
        agents.Clear();
    }
}

URLをパースする

XCManager.cs
void ParseUrl(NSUrl url,
    out string action,
    out string xSuccess, out string xError, out string xCancel,
    out Dictionary<string, string> parameters)
{
    var query = HttpUtility.ParseQueryString(url.Query);

    xSuccess = query.Get("x-success");
    xError = query.Get("x-error");
    xCancel = query.Get("x-cancel");

    action = url.Path.TrimStart('/');

    parameters = new Dictionary<string, string>();
    foreach(var key in query.AllKeys)
    {
        var k = HttpUtility.UrlDecode(key);
        if (!k.StartsWith("x-", StringComparison.Ordinal))
        {
            parameters[k] = HttpUtility.UrlDecode(query.Get(k));
        }
    }
}

x-callback用のパラメータとアクション用のパラメータを分解しています。
簡略化のため、使う予定のない x-source は取得していません。
機能実装で利用する parameters はC#で扱いやすいDictionary
で処理します。
URLスキーム自体は同じキーを複数登録できてますが、混乱の元なのでキーが単一になるように制限してみました。

クエリパラメータを含んだNSUrlを生成する関数

XCManager.cs
NSUrl CreateUrl(string xcbUrl, Dictionary<string, string> parameters)
{
    if (parameters == null || parameters.Count == 0)
    {
        return new NSUrl(xcbUrl);
    }

    var url = new StringBuilder(xcbUrl);
    var separate = '?';
    foreach(var parameter in parameters)
    {
        url.Append(separate);
        url.Append(HttpUtility.UrlEncode(Uri.EscapeUriString(parameter.Key)));
        url.Append('=');
        url.Append(HttpUtility.UrlEncode(Uri.EscapeUriString(parameter.Value)));
        separate = '&';
    }
    return new NSUrl(url.ToString());
}

NSUrl CreateErrorUrl(string xcbUrl, string message, string code, string domain)
{
    var parameters = new Dictionary<string, string>()
    {
        { "errorMessage", message },
        { "error-Code", code },
        { "errorDomain", domain }
    };
    return CreateUrl(xcbUrl, parameters);
}

DictionaryにまとめたクエリパラメータをURLに変換する処理です。
エラー時のクエリパラメータは、参考にしているIACの実装に合わせたキーを設定するようにしました。

URLエンコードは HttpUtility.UrlEncode(urlString) を単純に呼ぶだけでは駄目でした。
HttpUtility.UrlEncode はスペースを + に変換しますが、ショートカットは %20 になる想定で、エンコード方法に差があるのが原因のようです。
HttpUtility.UrlEncode(Uri.EscapeUriString(urlString)) とした所、ショートカット側で正しくデコードできる形式になりました。

おそらくこんな面倒な事をしなくても簡単な方法はありそうですが、自分の検索能力では見つからず。。。力不足を実感します。

用意したパーツをつなぎ合わせてIXCAgentを処理する

XCManager.cs
public void ProcessAgents(UIApplication app, NSUrl url)
{
    if (url.Host != "x-callback-url")
    {
        return;
    }

    ParseUrl(url,
        out string action,
        out string xSuccess, out string xError, out string xCancel,
        out Dictionary<string, string> parameters);
    var onSuccess = dict =>
        app.OpenUrl(CreateUrl(xSuccess, dict), new NSDictionary(), null);
    var onCancel = () =>
        app.OpenUrl(new NSUrl(xCancel), new NSDictionary(), null);
    var onError = (message, code, domain) =>
        app.OpenUrl(CreateErrorUrl(xError, message, code, domain), new NSDictionary(), null);

    try
    {
        var supportedAgent agents.FirstOrDefault(agent => agent(action));
        if (supportedAgent == null)
        {
            var callbackUrl = CreateErrorUrl(xError, "NotFoundAction", "1", "Client");
            app.OpenUrl(callbackUrl, new NSDictionary(), null);
            return;
        }
        supportedAgent(action, parameters, onSuccess, onCancel, onError);
    } catch(Exception e) {
        var callbackUrl = CreateErrorUrl(xError, e.Message, "1", "XCManager");
        app.OpenUrl(callbackUrl, new NSDictionary(), null);
    }
}

大まかに以下の処理をしています。

  1. URLをパースする
  2. コールバック用のdelegateを生成する
  3. 登録された呼び出したいactionをサポートしているAgentを見つける
  4. Agentが見つかれば処理。なければエラー。

Agent中ではどんな実装をしているのか分からないので、雑にtry-catchで囲んでおきます。
エラーを握りつぶしているので若干怖いですが、ここでは規格通りに呼び出されたx-callbackなら確実に完了するような実装に倒しました。

足し算をするだけのactionを作る

まず書式を考えます。

入力例: add?lhs=2&rhs=4.2
結果例: result=6.2

ついでに文字列のエンコーディングを確認するため、出力用の文字を指定できるように拡張しておきます。

入力例: add?lhs=2&rhs=4.2&text=result
結果例: result=result%3D6.2

これを実装すると以下のような感じになります。
入力ミスに気づきやすいように、actionの引数を処理できない時は専用のエラーを出すようにしました。

AddAgent.cs
class AddAgent : IXCAgent
{
    public bool Supports(string action)
    {
        return action == "add";
    }

    public void Perform(string action, Dictionary<string, string> parameters,
        XCSuccess onSuccess, XCCancel onCancel, XCError onError)
    {
        string result;
        try
        {
            var lhs = Double.Parse(parameters["lhs"]);
            var rhs = Double.Parse(parameters["rhs"]);
            var sum = lhs + rhs;
            result = parameters["text"] + "=" + result.ToString();
        } catch (Exception e) {
            onError("ParameterError", "1", "AddAgent");
            return;
        }

        var callbackParams = new Dictionary<string, string>
        {
            { "result", result }
        };
        onSuccess(callbackParams);
    }
}

actionの処理を登録/登録解除する

AppDelegate.cs
        public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
        {
            XCManager.Instance.AddAgent(new AddAgent());
            return true;
        }

        public override void OnResignActivation(UIApplication application)
        {
        }

        public override void DidEnterBackground(UIApplication application)
        {
            XCManager.Instance.ClearAgegnts();
        }

        public override void WillEnterForeground(UIApplication application)
        {
            XCManager.Instance.AddAgent(new AddAgent());
        }

        public override void OnActivated(UIApplication application)
        {
        }

        public override void WillTerminate(UIApplication application)
        {
            XCManager.Instance.ClearAgegnts();
        }

        [Export("application:openURL:options:")]
        public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
        {
            XCManager.Instance.ProcessAgents(app, url);
            return true;
        }

非アクティブ中にメモリが開放されたりするので、アクティブになった時にactionを登録し直しておきます。
念の為、多重登録にならないように非アクティブになる時には登録を解除しておきます。

こちらの記事を参考にさせていただきました。
AppDelegateのメソッドが呼ばれるタイミングと実装すべき内容(iOS11)

呼び出してみる

「ショートカット」アプリの設定

以下のように組みました

shortcut02-1/2 shortcut02-2/2

実行してみる

shortcut02-result

& もエンコードで壊れることなく、コールバックを実行できました。

IACを参考に移植してみた所感

エラー処理

Objective-Cの NSError が元々持っているためだと思いますが、errorCodeerrorDomain といった情報を持っているのは便利そうでした。
IACではもう少しうまくエラー処理をしていて、利用者側が意識しなくても、それなりにわかりやすいドメインが選ばれるようになってました。

x-cancel

IACでは IACDelegateOnSuccessOnFailure しか用意されていません。
Successがboolで成功かキャンセルを判断するようになっています。
この辺りの意図はあまりつかめませんでした。

キャンセルでは戻り値用の辞書を渡さないので、この記事の実装では使わない引数は受け取らずに済むように、明確に関数を分けました。

実行効率

この記事の実装では、利用可能なAgentを毎回取得していますが、IACではactionをキーとした辞書から処理をO(1)で呼び出しているようでした。

その他

CanOpenURL

UIApplication.CanOpenURLという関数があります。指定したSchemeのアプリがインストールできるか調べるための関数です。
いかにも UIApplication.OpenURL を実行する前に呼んだほうが良さそうに見えますが、x-callbackでの用途なら 呼ばないのが正解 のようです。

というのも、iOS9以降では何もしないと 必ずfalseが返ります
正しく判定するには、Info.plistに LSApplicationQueriesSchemes の配列項目を作り、調べたいschemeを列挙しておく必要があります。

x-callbackはどんなアプリから呼び出されるのか分からないので、予め列挙しておくことができません。
どうしてもURL開けたかどうか確認したいなら UIApplication.OpenURL のcallbackで判定すると良さそうです。

ややこしいですが、多分アプリがインストールされているのか判断できてしまうので、プライバシーの点で問題になったのでしょう。

複数の戻り値を返す場合

この記事では戻り値として1つのクエリパラメータを渡すものしか実装しませんでしたが、複数のクエリパラメータを渡した場合は「ショートカット」アプリでの扱い方が以下のように違います。

1つのクエリパラメータが返った時は、キーを捨てた値だけが文字列として次の処理に渡されます。
複数のクエリパラメータが返った時は、すべてのキーを含んだ辞書として扱います。
辞書はJsonに変換したり、標準機能でキーを指定して取り出すこともできます。

まとめ

個人で楽しむ分には困らないくらいには自由に拡張できるようになりました。

これ以外にも、IACのバインディングライブラリを作成してXamarinから呼び出せるようにしたり、「Pythonista」アプリを購入してPythonで拡張したり、標準機能では出来ない拡張機能を呼び出す方法はいろいろあります。

振り返ると、数ある方法の中でも茨の道を進んだ気もしますが、こういうライブラリは偉い人の知恵が詰まっているので、車輪の再発明とはいえ適度に勉強になる題材でした。
自分なりの工夫もいくつか入れてみましたが「いやいや!元の方が良いよ!」などのツッコミもお待ちしております。

最後まで読んで頂き、ありがとうございました。

参考

iOSアプリ間連携の実装に x-callback-url を使う
x-callback-urlを使ってみた
【Swift】特定のアプリがインストールされているかどうかで処理を分ける 2017完全版 - URL SchemesとLSApplicationQueriesSchemes -
How do I replace all the spaces with %20 in C#?

AWS環境 で Teleport クラスタの構築

$
0
0

この記事は KLab Advent Calendar 2018 18日目 の記事です。

はじめに

みなさんはサーバーへのアクセス管理をどのように実施していますか?
SSHの鍵ファイルによるアクセス制限では、サーバー数やメンバー数の規模が大きくなるに連れて、公開鍵の設定など非常にコストがかかってきてしまうかと思います。
そこで今回は Teleport を用いたサーバーアクセスと、その AWS 環境での構築方法をご紹介します。

※ : 実際に運用する際にはAnsibleなどで自動化することになるかと思いますが、この記事ではどういう設定を入れていくことになるのか備忘録も兼ねて一つづつ説明していきます。

Teleport By GRAVIRATIONAL とは

『Gravitational Teleportは、SSHまたはKubernetes API経由でLinuxサーバーのクラスタへのアクセスを管理するためのゲートウェイです。従来のOpenSSHの代わりに使用することを目的としています。』
https://gravitational.com/teleport/docs/intro/

Teleportを利用すると、認証サーバでアカウントを登録するだけでアクセス開始時に各サーバへのセッション鍵が自動で配布されてアクセス可能になるため、アクセス管理が非常に簡単になります。
これは IDパスワードワンタイムパスワード を用いたサーバーアクセスのデモです。
ログイン&セッション操作
具体的には 「ログイン > ユーザ名を指定してサーバログイン > ファイル編集 > pythonインストール」 を行っています。

このようにクラスタへログインするだけで一元的にアクセス先のサーバーへログインし、コンソール操作が可能になります。
さらに、以下のように記録された操作のログを確認することが可能になります。
ログ再生
上記の例ではWebからのアクセスでしたが、ssh同様コンソールからのアクセスや、コンソールでのログチェックも可能になります。

Teleportのアーキテクチャ

Teleportではアクセス対象のサーバー(Nodeサーバー)を含めて3種類のサービスが動くサーバーで役割を分担してクラスタを構築します。
アーキテクチャ
https://gravitational.com/teleport/docs/architecture

  • Authサーバ: 認証情報の保存やクラスタ情報、ログ管理など中心的なサービスを行います。
  • Proxyサーバ: 外部からのアクセスを中継するサーバです。ログイン情報を受け取ってAuthサーバへ認証問い合わせをしたり、Nodeサーバへセッションをつなげたりします。
  • Nodeサーバ: 実際に管理対象となる個別のサーバです。

また、今回の構築ではオープンソースの無料版を用いますが、有償版では以下の2点が強化されることでさらに便利になります。

  • ロールベースでのユーザー権限及びアクセスサーバ制御
  • シングルサインオンでGoogleやGitHubなど外部認証システムによるアカウント管理

Teleportについては先日のこちらの記事も参考になります。

構築手順

これからAWS環境での基本的なクラスタ構築方法について解説していきます。 
今回、認証サーバでのデータ保存についてはDynamoDBS3 を用います。これにより、認証情報のバックアップとともに、複数の認証サーバ同士を連携させることで認証サーバの冗長化を狙った構築を行います。

※: 手順において、VPCやセキュリティグループの設定方法などのAWSの操作方法については割愛します。

ソフトウェアバージョン

想定環境としては以下のソフトウェアバージョンを利用します
EC2 : Ubuntu18.04 (ami-06e7b9c5e0c4dd014)
Teleport : v3.0.1

セキュリティグループの作成

Teleportでは各サービスでいくつかのポートを利用します。クラスタの各サーバーへ割り当てるためのセキュリティグループを作成してください。
以下の表は、デフォルト設定で利用するポート番号を示しています。

サービス ソース Port 説明
認証 プロキシ、ノード 3025 認証サービスへプロキシサービスやノードサービスがアクセスするPort
プロキシ クライアント 3023 プロキシサービスでクライアントからの操作セッションを受け取るPort。ここでの通信をノードサービスへ転送します
プロキシ その他クラスタ(割愛) 3024 プロキシサービスが他のTeleportクラスタとアクセスするPort(今回は使用しません)
プロキシ クライアント 3080 プロキシサービスがClientのWebアクセス待受をするPort
ノード プロキシ 3022 プロキシサービスから転送されたクライアントからのセッションをノードサービスが受け取るPort

基本設定では上記の通りですが、この後説明する個別サービス設定で変更することも可能です。
その他、構築時の操作用SSHアクセス用セキュリティグループも必要に応じて設定してください。

認証サーバ用IAMロール作成

今回の構築では認証サーバで管理するデータをDynamoDBとS3へ保存します。
アクセス権限設定のためにEC2へのIAMロールを付与するため、以下の設定をしたIAMロールを作成してください。

poricy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllDynamoActionsOnTeleportAuth",
            "Effect": "Allow",
            "Action": "dynamodb:*",
            "Resource": [
                "arn:aws:dynamodb:ap-northeast-2:ACCOUNT:table/STATE_TABLE",
                "arn:aws:dynamodb:ap-northeast-2:ACCOUNT:table/EVENTS_TABLE"
            ]
        },
        {
            "Sid": "AllS3ActionsOnTeleportAuth",
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::LOGS_BUCKET",
                "arn:aws:s3:::LOGS_BUCKET/*"
            ]
        }
    ]
}

以下4点は適宜置き換えをお願いします

変数名 説明
ACCOUNT AWSのアカウントID
STATE_TABLE クラスタ状態を保存するDynamoDBのテーブル名
EVENTS_TABLE ログインやセッション情報などイベントを保存するDynamoDBのテーブル名
LOGS_BUCKET 操作の各録画データを保存するバケット名

https://gravitational.com/teleport/docs/admin-guide/#using-dynamodb

認証サーバ構築

ここまでで作成した認証サーバー用のセキュリティグループとIAMロールを付与して、認証サーバ用のEC2インスタンスを起動してください。

起動したら、まずはTeleportのコマンドをインストールします。

インストール手順.
$ wget https://get.gravitational.com/teleport-v3.0.1-linux-amd64-bin.tar.gz
$ tar xvf teleport-v3.0.1-linux-amd64-bin.tar.gz
$ cd teleport/
$ sudo ./install

この手順により、以下の3つのファイルが /usr/local/bin/ にインストールされます。クライアントと各サーバで利用するコマンドが違いますが、この場合はすべてのコマンドがインストールされます。コマンドをpathに配置するだけなので、必要なコマンドのみを別の場所に配置しても構いません。

コマンド 概要 対象マシン
teleport 各ノードで可動するサービスのコマンド 認証、プロキシ、ノード
tctl 認証サーバでアカウントやノードを追加するときに用いるコマンド 認証
tsh クライアントからTeleportクラスタへアクセスするコマンド クライアント

次に設定ファイルを作成します。 Teleportはサービスを起動する際のオプションや設定ファイルに記述した内容に従って、 認証、プロキシ、ノード それぞれの機能を同時に実行することができます。
teleport configure を実行することで、1台で全機能を実行するサンプルの設定ファイルを確認できます。デフォルトでは設定ファイルは /etc/teleport.yaml に配置します。
認証サービス用の設定ファイルの例は以下になります。

/etc/teleport.yaml
teleport:
  nodename: auth-1
  # data_dir: /var/lib/teleport # オンプレなど認証サーバに直接保存する場合はこちら。多重化はNFSなどで。
  pid_file: /var/run/teleport.pid
  log:
    output: stderr
    severity: INFO

  storage: # 今回はクラスター情報などをDynamoDBに保存します
    type: dynamodb
    region: REGION
    table_name: STATE_TABLE
    audit_events_uri: dynamodb://EVENTS_TABLE
    audit_sessions_uri: s3://LOGS_BUCKET

auth_service:
  enabled: "yes" # 認証サーバの機能を有効化
  cluster_name: "main" # 任意のクラスタ名
  authentication:
    type: local
    second_factor: otp # ワンタイムパスワードによる2段階認証。有償版ではその他の認証も可能
  listen_addr: 0.0.0.0:3025 # 自分のLocal IPにするとtctlでの127.0.0.1を受け付けなくなる
  session_recording: "node" # ログをnodeから転送する。特殊な場合を除いてproxyからの設定は推奨されません。
  public_addr: [ "SELF_URL:3025" ] # ノードなどからどういう名前でアクセスされるか

ssh_service:
  enabled: "no"

proxy_service:
  enabled: "no"

ssh_service の設定がノード、 proxy_service の設定がプロキシモードのときに必要になる設定箇所です。 enabled の項目がデフォルトで yes になっているため明示的に無効化します。

https://gravitational.com/teleport/docs/admin-guide/#configuration-file

設定ファイルができたので、サービスとして起動するためにsystemdの設定を行います。

/lib/systemd/system/teleport.service
[Unit]
Description=Teleport SSH Service
After=network.target

[Service]
Type=simple
Restart=on-failure
ExecStart=/usr/local/bin/teleport start --config=/etc/teleport.yaml
ExecReload=/bin/kill -HUP $MAINPID
PIDFile=/var/run/teleport.pid

[Install]
WantedBy=multi-user.target

teleport start --config=/etc/teleport.yaml で、設定ファイルを指定して起動するよう設定しています。あとはサービスの起動 sudo systemctl start teleport.service をするだけで認証サーバの構築は完了です。インスタンス作成時にIAMロールで権限付与しているため、DynamoDBの各テーブルやS3バケットはTeleport認証サービスの初回起動時に自動的に作成されます。
また、今回は認証サーバを複数台で構築するため同じ設定でもう1台認証サーバを作成しました。

プロキシサーバ構築

次にプロキシサーバを構築します。認証サーバと同様にインスタンスを作成してください。違う点は、IAMロールが不要なのと外部アクセスを受け付けるセキュリティグループの違いです。

インスタンスを作成したらSSL証明書を取得します。public IPにドメインを設定して証明書を取得してください。(証明書取得方法については割愛します。私は Let's Encrypt を利用しました。
証明書を取得したら、認証サーバ同様にTeleportのインストールを行い以下の設定ファイルを作成してください。

/etc/teleport.yaml
teleport:
  nodename: proxy
  data_dir: /var/lib/teleport
  pid_file: /var/run/teleport.pid
  auth_servers: # 認証サーバのIPやURLを設定してください
  - AUTH_SERVER_1:3025
  - AUTH_SERVER_2:3025
  connection_limits:
    max_connections: 1000
    max_users: 250
  log:
    output: stderr
    severity: INFO

auth_service:
  enabled: "no"

ssh_service:
  enabled: "no"

proxy_service:
  enabled: "yes"
  listen_addr: 0.0.0.0:3023 # ターミナルのセッション通信するときにアクセスを受けるアドレスです
  web_listen_addr: 0.0.0.0:3080 # Webの認証ページでアクセスを受けるアドレスです
  # tunnel_listen_addr: 0.0.0.0:3024 # クラスタ連携用のアドレスです。今回は不要なので設定しません
  # 証明書のパスを設定してください。これはLet's Encryptの例です
  https_key_file: /etc/letsencrypt/live/proxy.example.com/privkey.pem
  https_cert_file: /etc/letsencrypt/live/proxy.example.com/fullchain.pem

上記のように、認証サーバ同様にプロキシサービスのみ起動するようにして、証明書のパスを指定した設定ファイルを作成します。
https://gravitational.com/blog/letsencrypt-teleport-ssh/

設定ファイルができたら、 認証サーバ でプロキシサーバ追加のための招待トークンを発行します。

認証サーバでプロキシ追加リクエスト.
$ sudo tctl nodes add --roles proxy --ttl=5m
The invite token: XXXXXXX_INVITE_TOKEN_XXXXXXX
This token will expire in 5 minutes

トークンをコピーし、プロキシサーバで招待トークンを付与してTeleportサービスを起動します。

プロキシサーバでクラスタ参加起動.
$ sudo teleport start --token=XXXXXXX_INVITE_TOKEN_XXXXXXX --config=/etc/teleport.yaml

接続が確立したら、一度プロセスを終了して構いません。接続のための認証情報をプロキシサーバのローカルに保存するため、次回以降の起動ではトークンは必要ありません。
なので、あらためてTeleportサービスを起動します。認証サーバ同様にsystemdの設定ファイルを作成してサービス起動してください。
これでプロキシサーバの構築は完了です。

ノードサーバ構築

クライアントからアクセスするノードサーバを構築します。
証明書設定が不要な以外は、基本的にプロキシサーバと同様の手順です。
設定ファイルとクラスタへのノード追加のコマンドは以下のようになります。

/etc/teleport.yaml
teleport:
  nodename: node-1
  data_dir: /var/lib/teleport
  pid_file: /var/run/teleport.pid
  auth_servers: # 認証サーバのIPやURLを設定してください
  - AUTH_SERVER_1:3025
  - AUTH_SERVER_2:3025
  log:
    output: stderr
    severity: INFO

auth_service:
  enabled: "no"

ssh_service:
  enabled: "yes"
  listen_addr: 0.0.0.0:3022
  public_addr: NODE_1:3022 # プロキシからセッションを作るときにアクセスするアドレス
  commands: # ノードに自由にラベルを付与することができます
  - name: hostname # ラベルキー名
    command: [/bin/hostname] # 値取得コマンド
    period: 1m0s # 更新間隔
  - name: arch
    command: [/bin/uname, -p]
    period: 1h0m0s

proxy_service:
  enabled: "no"

管理対象となるノードサーバは多数になることが想定されるため、ラベルを付与することができます。
今回は、ホスト名とアーキテクチャをラベルに設定しました。必要に応じて環境変数などを設定してください。
https://gravitational.com/teleport/docs/admin-guide/#labeling-nodes

認証サーバでノードサーバ追加リクエスト.
$ sudo tctl nodes add --roles node --ttl=5m
The invite token: XXXXXXX_INVITE_TOKEN_XXXXXXX
This token will expire in 5 minutes
ノードサーバでクラスタ参加起動.
$ sudo teleport start --token=XXXXXXX_INVITE_TOKEN_XXXXXXX --config=/etc/teleport.yaml

これらの設定が完了したら、サービス登録してノードサーバの設定は完了です。

ユーザー登録

クラスタの構築が済んだら、ログインするユーザーを登録します。Teleportを利用するユーザ名と、そのユーザでアクセス可能なOSユーザ名を指定して、ユーザー登録コマンド tctl users add ユーザ名 OSユーザ名リスト を実行します。

ユーザー追加コマンド.
$ sudo tctl users add hoge ubuntu,hoge
Signup token has been created and is valid for 1 hours. Share this URL with the user:
https://proxy.example.com:3080/web/newuser/XXXXXXXXXXXXXXXXXXXXXXXX

表示されたアドレス(プロキシアドレス箇所は置き換えが必要になります)へブラウザでアクセスすると次のようなページが表示されます。パスワードの登録とQRコードを読み込んでワンタイムパスワードの設定を行ってください。

ユーザー追加.png

必要事項を入力して、 Sign up でユーザー追加完了です。
https://gravitational.com/teleport/docs/admin-guide/#adding-and-deleting-users

お疲れ様でした。
これで無償版でのTeleportクラスタの構築、及びユーザー設定完了です。
https://proxy.example.com:3080/web/login へアクセスしてログインしてみてください。
冒頭の紹介のようにノードを選択して操作したりセッションの記録が確認できます。

展望

構築マニュアルによると、ClientとProxyサーバの間にELBを配置してProxyサーバを多重化したり、Proxy, NodeとAuthサーバとの間にELBを配置することも可能なようです。こういったロードバランサを仲介させる構築をすることで、よりインスタンスの入れ替えをしやすい構築ができると思います。
今回の検証中、CLBをプロキシの前に利用することでSSL証明書をACMの証明書にできるのではないかと試行錯誤しましたが、うまくセッションを維持できず稼働させることができませんでした。また今度チャレンジしてみたいと思います。

おわりに

AWS環境におけるTeleportクラスタの構築方法をご紹介しました。
OpenSSHと比較してのセキュリティ的信頼度など、評価しなければならない事は多々あると思いますがアカウント管理やログ管理など便利そうなツールではないでしょうか?

さらに有償版では、認証にGoogleアカウントなど外部の認証システムを利用することや、ユーザーでログの表示など操作可能な権限や、ログイン可能なサーバーグループなどの制限も可能になるとのことです。
試す機会があればぜひまた記事にしてご紹介していきたいです。
https://gravitational.com/teleport/docs/enterprise/

サーバーへのアクセス方法など、企業としてどのようにやっているかあまり宣伝するようなものではないと思うのですが、ぜひ皆さんのところでも利用してみたお話などコメントいただければと思います。

最後までお読みいただきありがとうございました。

Stardard MIDI Fileを解析して音ゲーの譜面を作る話

$
0
0

この記事は、KLab Engineer Advent Calendar 2018 19日目の記事です。

Standard MIDI File とは

一言で表すなら、「どの音符をどのタイミングで鳴らすか」等の演奏に関する全ての情報が入っているファイルです。
これだけ聞くと音ゲーの譜面みたいですね!その通りです!

この記事ではMIDIファイルから音ゲーに使えそうな譜面データに変換するプログラムを書きつつ解説します。
レーンに沿って流れてくるタイプの音ゲーの譜面を作るのにとても有効な手法です。

譜面用構造体の定義

MIDI解析の前準備としてノーツイベントとBPMイベントの構造体を定義します。
実際の譜面データになるものです。

public enum NoteType
{
  Normal,      // 通常ノーツ
  LongStart,   // ロング開始
  LongEnd,     // ロング終端
}

public struct NoteData
{
  public int eventTime;  // ノーツタイミング(ms)
  public int laneIndex;  // レーン番号
  public NoteType type   // ノーツの種類
}

public struct TempoData
{
  public int eventTime;  // BPM変化のタイミング(ms)
  public float bpm;      // BPM値
  public float tick      // tick値
}

MIDIファイル構成

データ構造ですが、大きく分けて
・ヘッダチャンク
・トラックチャンク
の2部で構成されています。

ヘッダチャンク

ヘッダチャンク バイト数 補足
チャンクID 4byte チャンクID "MThd" で固定
データ長 4byte 続くチャンクのデータ長 "6" で固定
フォーマット 2byte フォーマット
トラック数 2byte 後のトラックチャンクの個数
分能値 2byte タイムベース 大体480

トラックチャンク

トラックチャンク バイト数 補足
チャンクID 4byte チャンクID  "MTrk" で固定
データ長 4byte データ部のサイズ
データ部 可変 メインのデータ部分
ここにノートイベント等の情報が格納

こちらを構造体にします

チャンクデータ用構造体
/// <summary>
/// ヘッダーチャンク情報を格納する構造体
/// </summary>
public struct HeaderChunkData
{
    public byte[] chunkID;      // チャンクのIDを示す(4byte)
    public int dataLength;      // チャンクのデータ長(4byte)
    public short format;        // MIDIファイルフォーマット(2byte)
    public short tracks;        // トラック数(2byte)
    public short division;      // タイムベース MIDI独自の時間の最小単位をtickと呼び、4分音符あたりのtick数がタイムベース 大体480(2byte)
};

/// <summary>
/// トラックチャンク情報を格納する構造体
/// </summary>
public struct TrackChunkData
{
    public byte[] chunkID;      // チャンクのIDを示す(4byte)
    public int dataLength;      // チャンクのデータ長(4byte)
    public byte[] data;         // 演奏情報が入っているデータ
};

チャンク解析

ここからは、実際の解析コードを交えて解説していきます。

MIDILoader.cs
    public List<NoteData> noteList = new List<NoteData>();
    public List<TempoData> tempoList = new List<TempoData>();

    public void LoadMIDI(string fileName)
    {
            // リスト初期化
            noteList.Clear();
            tempoList.Clear();

            using (var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read))
            using (var reader = new BinaryReader(stream))
            {

MIDIファイルはバイナリデータのため、バイナリで読み込んでいきます。

ヘッダチャンク解析

まずはヘッダチャンクの解析から。

                /* ヘッダチャンク侵入 */
                var headerChunk = new HeaderChunkData();

                // チャンクID
                headerChunk.chunkID = reader.ReadBytes(4);

                // 自分のPCがリトルエンディアンならバイト順を逆に
                if (BitConverter.IsLittleEndian)
                {
                    // ヘッダ部のデータ長(値は6固定)
                    var byteArray = reader.ReadBytes(4);
                    Array.Reverse(byteArray);
                    headerChunk.dataLength = BitConverter.ToInt32(byteArray, 0);
                    // フォーマット(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.format = BitConverter.ToInt16(byteArray, 0);
                    // トラック数(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.tracks = BitConverter.ToInt16(byteArray, 0);
                    // タイムベース(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.division = BitConverter.ToInt16(byteArray, 0);
                }
                else
                {
                    // ヘッダ部のデータ長(値は6固定)
                    headerChunk.dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
                    // フォーマット(2byte)
                    headerChunk.format = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                    // トラック数(2byte)
                    headerChunk.tracks = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                    // タイムベース(2byte)
                    headerChunk.division = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                }

MIDIファイルはビッグエンディアン方式でデータが格納されているため、CPUに応じてバイトオーダー変換を行う必要があります
C#では BitConverter.IsLittleEndian を用いてエンディアンの判定を行います。

トラックチャンク解析

次はトラックチャンクの解析です

                /* トラックチャンク侵入 */
                trackChunks = new TrackChunkData[headerChunk.tracks];

                // トラック数ぶん
                for (int i = 0; i < headerChunk.tracks; i++)
                {
                    // チャンクID
                    trackChunks[i].chunkID = reader.ReadBytes(4);

                    // 自分のPCがリトルエンディアンなら変換する
                    if (BitConverter.IsLittleEndian)
                    {
                        // トラックのデータ長読み込み(値は6固定)
                        var byteArray = reader.ReadBytes(4);
                        Array.Reverse(byteArray);
                        trackChunks[i].dataLength = BitConverter.ToInt32(byteArray, 0);
                    }
                    else
                    {
                        trackChunks[i].dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
                    }

                    // データ部読み込み
                    trackChunks[i].data = reader.ReadBytes(trackChunks[i].dataLength);

                    // データ部解析
                    TrackDataAnalysis(trackChunks[i].data, headerChunk);
                }

補足
チャンクIDやデータ部をエンディアン変換しないのは1バイトずつで表現されているため。

トラックデータ部解析

MIDI解析のメインです。ここからノーツや速度変化のイベントを抽出します。

データ部の構成は
前回のイベントからの経過時間(ms) デルタタイム
次に イベントデータ
この2つが繰り返し格納されています。

こちらもコードを交えながら解説していきたいと思います。

前準備
    /// <summary>
    /// トラックデータ解析
    /// </summary>
    public void TrackDataAnalysis(byte[] data, HeaderChuckData headerChunk)
    {
            uint currentTime;                    // デルタタイムを足していく、つまり現在の時間(ms)(ノーツやソフランのイベントタイムはこれを使う)
            byte statusByte;                     // ステータスバイト
            bool[] longFlags = new bool[128];    // ロングノーツ用フラグ

            // データ分
            for (int i = 0; i < data.Length;)
            {

デルタタイム部解析

デルタタイムは 可変長数値表現 を用いてデータ格納されています。

  • 1バイトをビット単位に分解し、上位1ビットと下位7ビットに分ける
  • 下位7ビットを数値として表現
  • 上位1ビットが
    • 1の場合、次の1バイトも可変長数値として連結し、同じように処理する
    • 0の場合、終了

ここはコードを見たほうが分かりやすいかもしれません。

可変長数値表現をint型に変換する
                // デルタタイム格納用
                uint deltaTime = 0;

                while (true)
                {
                    var tmp = data[i++];

                    // 下位7bitを格納
                    deltaTime |= tmp & (uint)0x7f;

                    // 最上位1bitが0ならデータ終了
                    if ((tmp & 0x80) == 0) break;

                    // 次の下位7bit用にビット移動
                    deltaTime = deltaTime << 7;
                }
                // 現在の時間にデルタタイムを足す
                currentTime += deltaTime;

イベント部解析

構造は単純なのですが、イベントの種類が無駄に多い。

何のイベントかを表す ステータスバイト
次に イベントに沿ったデータ
の2点構成。

まずはステータスバイトから

ステータスバイトを保存
                /* ランニングステータスチェック */
                if (data[i] < 0x80)
                {
                    // ランニングステータス適応(前回のステータスバイトを使いまわす)
                }
                else
                {
                    // ステータスバイト保存
                    statusByte = data[i++];
                }

ここで急にでてきた ランニングステータス ですが、
名前の割に難しいことはなく、「前回のイベントと同じだから使いまわしてね!」という意味です。
変数を外に出したのはこの使いまわしの為です。

そして、ステータスバイトの値から何のイベントかを特定しデータを解析します。
ステータスバイトのイベント採番や、データの情報はこちらのサイトで詳細に解説されているのでガッツリ参考にさせていただきました。

ステータスバイトからイベントを特定し、譜面に使えそうなイベントならデータ解析
                // ステータスバイト後のデータ保存用
                byte dataByte0, dataByte1, dataByte2, dataByte3;

                /* MIDIイベント(ステータスバイト0x80-0xEF) */
                if (statusByte >= 0x80 && statusByte <= 0xef)
                {
                    switch (statusByte & 0xf0)
                    {
                        /* チャンネルメッセージ */

                        case 0x80:  // ノートオフ
                            // どのキーが離されたか
                            dataByte0 = data[i++];
                            // ベロシティ値
                            dataByte1 = data[i++];

                            // 前のレーンがロングノーツなら
                            if (longFlags[dataByte0])
                            {
                                // ロング終点ノート情報生成
                                var note = new NoteData();
                                note.eventTime = (int)currentTime;
                                note.laneIndex = (int)dataByte0;
                                note.type = NoteType.LongEnd;

                                // リストにつっこむ
                                noteList.Add(note);

                                // ロングノーツフラグ解除
                                longFlags[note.laneIndex] = false;
                            }
                            break;
                        case 0x90:  // ノートオン(ノートオフが呼ばれるまでは押しっぱなし扱い)
                            // どのキーが押されたか
                            dataByte0 = data[i++];
                            // ベロシティ値という名の音の強さ。ノートオフメッセージの代わりにここで0を送ってくるタイプもある
                            dataByte1 = data[i++];

                            {
                                // ノート情報生成
                                var note = new NoteData();
                                note.eventTime = (int)currentTime;
                                note.laneIndex = (int)dataByte0;
                                note.type = NoteType.Normal;
                                // 独自でやっている。ベロシティ値が最大のときのみロングの始点とする
                                if (dataByte1 == 127)
                                {
                                   note.type = NoteType.LongStart;
                                   // ロングノーツフラグセット
                                   longFlags[note.laneIndex] = true;
                                }
                                // ノートオフイベントではなく、ベロシティ値0をノートオフとして保存する形式もあるので対応
                                if (dataByte1 == 0)
                                {
                                    // 同じレーンで前回がロングノーツ始点なら
                                    if (longFlags[note.laneIndex])
                                    {
                                        note.type = NoteType.LongEnd;
                                        // ロングノーツフラグ解除
                                        longFlags[note.laneIndex] = false;
                                    }
                                }

                                // リストにつっこむ
                                noteList.Add(note);
                            }
                            break;
                        case 0xa0:  // ポリフォニック キープレッシャー(鍵盤楽器で、キーを押した状態でさらに押し込んだ際に、その圧力に応じて送信される)
                            i += 2; // 使わないのでスルー
                            break;
                        case 0xb0:  // コントロールチェンジ(音量、音質など様々な要素を制御するための命令)
                            // コントロールする番号
                            dataByte0 = data[i++];
                            // 設定する値
                            dataByte1 = data[i++];

                            // ※0x00-0x77までがコントロールチェンジで、それ以上はチャンネルモードメッセージとして処理する
                            if (dataByte0 < 0x78)
                            {
                                // コントロールチェンジ
                            }
                            else
                            {
                                // チャンネルモードメッセージは一律データバイトを2つ使用している
                                // チャンネルモードメッセージ
                                switch (dataByte0)
                                {
                                    case 0x78:  // オールサウンドオフ
                                        // 該当するチャンネルの発音中の音を直ちに消音する。後述のオールノートオフより強制力が強い。
                                        break;
                                    case 0x79:  // リセットオールコントローラ
                                        // 該当するチャンネルの全種類のコントロール値を初期化する。
                                        break;
                                    case 0x7a:  // ローカルコントロール
                                        // オフ:鍵盤を弾くとMIDIメッセージは送信されるがピアノ自体から音は出ない
                                        // オン:鍵盤を弾くと音源から音が出る(基本こっち)
                                        break;
                                    case 0x7b:  // オールノートオフ
                                        // 該当するチャンネルの発音中の音すべてに対してノートオフ命令を出す
                                        break;
                                    /* MIDIモード設定 */
                                    // オムニのオン・オフとモノ・ポリモードを組み合わせて4種類のモードがある
                                    case 0x7c:  // オムニモードオフ
                                        break;
                                    case 0x7d:  // オムニモードオン
                                        break;
                                    case 0x7e:  // モノモードオン
                                        break;
                                    case 0x7f:  // モノモードオン
                                        break;
                                }
                            }
                            break;

                        case 0xc0:  // プログラムチェンジ(音色を変える命令)
                            i += 1;
                            break;

                        case 0xd0:  // チャンネルプレッシャー(概ねポリフォニック キープレッシャーと同じだが、違いはそのチャンネルの全ノートナンバーに対して有効となる)
                            i += 1;
                            break;

                        case 0xe0:  // ピッチベンド(ウォェーンウェューンの表現で使う)
                            i += 2;
                            // ボルテのつまみみたいなのを実装する場合、ここの値が役立つかも
                            break;
                    }
                }

                /* システムエクスクルーシブ (SysEx) イベント*/
                else if(statusByte == 0x70 || statusByte == 0x7f)
                {
                    byte dataLength = data[i++];
                    i += dataLength;
                }

                /* メタイベント*/
                else if(statusByte == 0xff)
                {
                    // メタイベントの番号
                    byte metaEventID = data[i++];
                    // データ長
                    byte dataLength = data[i++];

                    switch (metaEventID)
                    {
                        case 0x00:  // シーケンスメッセージ
                            i += dataLength;
                            break;
                        case 0x01:  // テキストイベント
                            i += dataLength;
                            break;
                        case 0x02:  // 著作権表示
                            i += dataLength;
                            break;
                        case 0x03:  // シーケンス/トラック名
                            i += dataLength;
                            break;
                        case 0x04:  // 楽器名
                            i += dataLength;
                            break;
                        case 0x05:  // 歌詞
                            i += dataLength;
                            break;
                        case 0x06:  // マーカー
                            i += dataLength;
                            break;
                        case 0x07:  // キューポイント
                            i += dataLength;
                            break;
                        case 0x20:  // MIDIチャンネルプリフィクス
                            i += dataLength;
                            break;
                        case 0x21:  // MIDIポートプリフィックス
                            i += dataLength;
                            break;
                        case 0x2f:  // トラック終了
                            i += dataLength;
                            // ここでループを抜けても良い
                            break;
                        case 0x51:  // テンポ変更
                            {
                                // テンポ変更情報リストに格納する
                                var tempoData = new TempoData();
                                tempoData.eventTime = (int)currentTime;

                                // 4分音符の長さをマイクロ秒単位で格納されている
                                uint tempo = 0;
                                tempo |= data[i++];
                                tempo <<= 8;
                                tempo |= data[i++];
                                tempo <<= 8;
                                tempo |= data[i++];

                                // BPM割り出し
                                tempoData.bpm = 60000000 / (float)tempo;

                                // 小数点第1で切り捨て処理(10にすると第一位、100にすると第2位まで切り捨てられる)
                                tempoData.bpm = Mathf.Floor(tempoData.bpm * 10) / 10;

                                // tick値割り出し
                                tempoData.tick = (60 / tempoData.bpm / headerChunk.division * 1000);

                                // リストにつっこむ
                                tempoList.Add(tempoData);
                            }
                            break;
                        case 0x54:  // SMTPEオフセット
                            i += dataLength;
                            break;
                        case 0x58:  // 拍子
                            i += dataLength;
                            // 小節線を表示させるなら使えるかも
                            break;
                        case 0x59:  // 調号
                            i += dataLength;
                            break;
                        case 0x7f:  // シーケンサ固有メタイベント
                            i += dataLength;
                            break;
                    }
                }
            }

これでノーツイベントとBPM変更イベントを格納し、音ゲーの基本的な譜面データが揃いました。
あともう一歩です。

イベントタイム再計算

MIDIファイルのイベントデルタタイムは全て最初に設定されたテンポから計算されているため、
各イベント時間をその時に設定されている状態のテンポで計算しなおす必要があります。

MIDILoader.cs
    void ModificationEventTimes()
    {
        // 一時格納用(計算前の時間を保持したいため)
        var tempTempoList = new List<TempoData>(tempoList);

        // テンポイベント時間修正
        for (int i = 1; i < tempoList.Count; i++)
        {
            TempoData tempo = tempoList[i];

            int timeDifference = tempTempoList[i].eventTime - tempTempoList[i - 1].eventTime;
            tempo.eventTime = (int)(timeDifference * tempoList[i - 1].tick) + tempoList[i - 1].eventTime;

            tempoList[i] = tempo;
        }

        // ノーツイベント時間修正
        for (int i = 0; i < noteList.Count; i++)
        {
            for (int j = tempoList.Count - 1; j >= 0; j--)
            {
                if (noteList[i].eventTime >= tempTempoList[j].eventTime)
                {
                    NoteData note = noteList[i];

                    int timeDifference = noteList[i].eventTime - tempTempoList[j].eventTime;
                    note.eventTime = (int)(timeDifference * tempTempoList[j].tick) + tempoList[j].eventTime;   // 計算後のテンポ変更イベント時間+そこからの自分の時間
                    noteList[i] = note;
                    break;
                }
            }
        }
    }

以上でMIDIファイルから譜面データ( noteList , tempoList )を作るコードは終了となります。

本当は譜面データを元にUnity上で動かすまで書きたかったのですが、あまりに長くなってしまう(あと間に合わなかった…)ので今回はここで閉めさせていただきます…ゴメンナサイ

終わりに

めちゃ長くなってしまいましたが、最後までお読みいただきありがとうございました。
この記事が音ゲーを作ってみたいと思う人への手助けになれば私は幸せです。

参考

スタンダードMIDIファイル
The MIDI File Format
MIDIファイル解析ソースコードの紹介
C言語でMIDI(SMF)データを読んでみる!
JavaScriptで可変長数値表現を使う

React で Google Apps Scriptを書く!

$
0
0

Google Apps Script で React を使った開発をしたいと思います。

Google Apps ScriptはGoogle DocsなどGoogleのサービスの拡張やアドオンをJavaScriptで書ける仕組みです。Apps Script は ちょっとした便利ツールの作成には大変よいのですが、本格的に開発を進める場合、素のJavaScriptを書くのはイヤなので、Reactなどのモダンなライブラリを使用したくなります。そこで、モダンなJSのアーキテクチャーにのっとって、Apps Scriptを書いてみたいと思います。
類例の試みはいろいろあるのですが、Reactを導入する実践的なサンプルをあまり見たことがないので、ここでは簡単なサンプルを提供し、内容を解説してみたいと思います。

なお、コードはすべて以下のリポジトリにあげてあります。
https://github.com/takada-at/gas-react

サーバー・クライアントを整理

少し複雑なのですが、Google Apps Script で動くコードは、「サーバー用のコード」と「クライアント用のコード」にわかれます。ここで「サーバー用のコード」と言っているのは、ドキュメントオープン時やメニューでのコマンド選択時に実行される .gs拡張子のついたスクリプトのことです。「Google Apps Script」と言ったときに通常イメージされるのはこれですね。

また、サーバー用のコードでは、Apps Script用の特殊なJavaScript関数を呼び出すことができます。大半はDocsやSpreadsheetの操作用のAPIです。

一方、Apps Scriptでは、サイドメニューやダイアログといったUIを、HTML+JavaScriptで書くことができます。ここでは、このUI用のJavaScriptコードを「クライアント用のコード」と呼びます。クライアント用のコードは基本的に通常のJavaScriptですが、リモートプロシージャコールのような形で、サーバー用のコードの関数を実行できる仕組みが用意されています。

ReactはもちろんUI用のライブラリなので、クライアント側で使用します。言語としてはES2015(ES6)を採用し、サーバー側もクライアント側もwebpack(+babel-loader)を使ってJavaScriptに変換することにします。クライアントに関しては、React用の言語であるJSXも併用します。

言語 変換
サーバー ES2015 webpackで変換
クライアント ES2015 + JSX webpackで変換

サンプルの中身

サンプルとして、Spreadsheetの拡張を用意しました。メニュー(「アドオン」「GasReact」)から「Show Sidebar」を選ぶとサイドバーを表示します。サイドバー内の「Load」をクリックすると、シートのいずれかの列の値を読みとり、UIに表示します。

スクリーンショット 2018-12-19 16.56.04.png
スクリーンショット 2018-12-19 16.57.07.png
スクリーンショット 2018-12-19 16.55.51.png

サーバー用コード

サーバー用のコードはこちらです。中身はシンプルで、メールアドレスを取得する関数と、列を指定してシートの中身を読む関数をAPIとして提供している他、メニューの表示などの設定をしているだけです。

クライアント用コード

クライアント用のソースコードはこちらです。React + Redux(+Redux Saga)を使用しています。分量はそこそこありますが、大半はUIと、サーバー側との通信のためのコードなので、詳しく解説しません。

あとで説明するように、サーバー側の関数をリモートプロシージャコールする箇所は、コードを自動生成しています。

webpackの設定

webpackの設定はこんな感じです。Apps Script上で動かすために、gas-webpack-plugines3ify-webpack-pluginというプラグインを使用します。gas-webpack-pluginは、Apps Script環境用にグローバールな関数の設定を可能にするプラグイン、es3ify-webpack-pluginは、古いJS環境向けの変換用のプラグインです。

# プラグインの設定
  plugins: [
    new GasPlugin(),
    new Es3ifyPlugin(),
    new webpack.EnvironmentPlugin(['NODE_ENV'])
  ],
# エントリポイントの設定。クライアント用コード、サーバー用コードがそれぞれ1ファイルにバンドルされる。
  entry: {
    server: './Server/index.js',
    client: './Client/client.jsx'
  },
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, './build')
  },

クライアント用コードの変換

Apps Script固有の事情として、HTML内で使用するクライアント側コードは、拡張子を.htmlにし、<script>タグでマークアップし、HTML断片として作成する必要があります。今回のプロジェクトでは、webpackでクライアント用のバンドルJSファイルを作成したあと、この変換をかますスクリプトを別途作成しました。Apps Scriptへのアップロードも、こちらのスクリプトの中でやっています。

# クライアント用コード
# scriptタグでマークアップするとともに、遅延ロードさせている。
<script>
window.addEventListener('load', function() {
  ${content}
}) 
</script>

また、サーバーのAPIをリモートプロシージャコールする部分は、お決まりの処理になるので、スクリプトで自動生成できるようにしています。今回のような簡易的プロジェクトだと大げさですが、大規模開発になった場合はおそらくこの種の仕組みがあると便利でしょう。

webpack-dev-serverでCORS回避する

$
0
0

とある社内ツールのSPA移行チャレンジをしていたときの話

ローカル環境下でAPIサーバーとwebpack-dev-serverを動かしつつ開発すると、portの違いでCORSリクエストに対応しなくてはなりません。

今回本番でCORS使うことは考えていなったので、開発環境だけ対応するのもめんどくさいです。
逆に商用のシステムなんかでCDN経由で配布してCORS前提なら開発環境も合わせたほうがいいと思います。
実際の回避方法ですが、APIサーバー側から静的ファイルを返すようにすれば回避できます。しかし、オートリフレッシュなどの便利機能がwebpack-dev-serverには入っているのでこっちを利用したいところです。
そこで、回避手段を探していたところwebpack-dev-serverにproxy機能があったのでこれでなんとかします。

webpack.config.jsにこんなふうに書きます

devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 3000,
    proxy: {
        '/api': {
            target: 'http://localhost:5000', // local api server
            pathRewrite: {'^/api' : ''} // rewrite
        }
    }
},

これでwebpack-dev-server/api以下へのリクエストをapiサーバ(localhost:5000)へプロキシしてくれます。
パスもリライトしてくれるので、APIサーバー側は変更しなくていいです。

cors.png
これが
no_cors.png
こうなります。

便利!!

カットシーン実装で知っておくべきTips

$
0
0

この記事は、KLab Engineer Advent Calendar 2018 22日目の記事です。

概要

昨今におけるスマートフォンのゲームアプリもリッチ化が進んでおり、家庭用ゲーム機同様のフィーチャーが求められるようになりました。
3Dゲームに登場するカットシーン実装ですが、検討を失敗すると大きな調整リスクが発生します。
弊社では各種タイトルにおいてストーリーや必殺技などの各種演出などで多く扱っています。

そこで今回はプログラマーがカットシーン実装で検討するべきTipsを紹介します。

  • 基礎知識
  • 役者アセットの見積もり
  • ワークフロー
  • エディットキャラクターの登場
  • インゲームとのスムースな連携

基礎知識

カットシーンを作るにあたって、UnityやUE4などのゲームエンジンでは作成する機能が提供されています。
しかし基礎知識を知っておくとアニメーターとのやり取りをスムーズになりますし、必要なカメラ操作やポストエフェクトのテクニックを理解することができます。

様々な本やサイト記事が多数存在しますが一つ紹介します。
https://www.amazon.co.jp/dp/4768305385/

役者アセットの見積もり

ここでいう見積もりは何をどれだけ用いてカットシーンを作れるのかという洗い出しです。
これをデザイナーに打診・相談して決めます。

例えばゲームで使う予定のポリゴンを約15万ポリゴンとした場合、下記な感じで決めていきます。
(単位はポリゴン)
* 背景 40000
* キャラクター 10000 * 10
* モブ、演出素材 10000

シェーダーやポストエフェクトによっては、CPUかGPUの何かで頭打ちになることはあるので
考慮する必要はあります。
また、一部シーンではどうしてもポリゴンを超過したいという要望はよく受けます。
その場合ポリゴンを減らしたアセットを用意するのが理想ですが、困難な場合はFPSを落とすなどで対応することも検討します。

ワークフロー

カットシーンは、下記の複数のアセットで構成されます。
* 3Dモデル
* アニメーション
* BGM
* SE(SFX)
* 音声
* ポストエフェクト
* 字幕
* etc...

複数の要素はすべて一人の担当者では作るのではなく、複数の担当者が組み込んでいくことになります。
用意するシーン数やアセット数が一定以上ある場合に、どうやってコンポジットを行い、動作確認のイテレーションを高速に行えるかがデザイナーにとって重要になります。

UnityのTimelineではこちらで紹介されているように
大まかな作業カテゴリごとにサブTimlineとして分けることで、各作業担当者が並行してアセット更新作業できるなどのアイデアが考えられます。

エディットキャラクターの登場

カットシーンにエディットキャラクターを登場させる場合は、エディットの仕様には注意するべきです。
スキンのカラーや、身長や体格のスケールが変更可能である場合、調整における作業コストが一定以上に発生するので計上しておくべき要素になります。

スキンのカラーなどであれば露出補正などポストエフェクトの調整。

身長や体格のスケールであれば複数キャラクターが並ぶようなカメラショットの場合、
被写体がフレームから見切れることがあるので、カメラ位置、視野角などでの調整。

また、地面の接地や、登場するエディットキャラクターと別キャラクターが手など触れ合う箇所などにおいては
スケールによってメッシュ間の貫通が起こり得るので、IKなどで適切に処理する必要が発生します。

インゲームとのスムースな連携

既存のゲームで良く見かけるのが、カットシーンからインゲームにスムースにつないでそのままプレイするような内容です。
カメラについてはこちらで紹介されている
UnityのTimelineとCinemachineを用いれば、イース補間でスムースにつなぐことが可能です。

アニメーションについては、アニメーターがMayaなどのDCCツールを使ってアニメーションを作成する際、繋ぐカットシーンとインゲーム両方のアニメーションのルートが合うように考慮、場合にょっては対応する必要があります。

Unityではゲーム内容によってAnimatorコンポーネントのApply Root Motionを有効にするかどうかによって異なると思いますので、それも含めてアニメーターと事前と相談しておきます。

最後に

ここに上げた内容は一例だと思いますが、カットシーン実装時にご参考になれば幸いです。

参考サイト様

テラシュールブログ


CRIADX2のビート同期を使ってリズムに合わせた演出をつける

$
0
0

音ゲーを作っていると、リズムに合わせてタイミングの良い演出をつけたくなることがあるでしょう。
今回は、CRIADX2のビート同期を使ってさくっと実装してみます。
実行環境はUnityを使用しています。

CRIADX2とは

株式会社CRI・ミドルウェアから提供されている、オーディオミドルウェアです。
商用ですが、無料版のADX2 LEもありますので、お試ししたい方はそちらからどうぞ。

ビート同期の機能とデータの作成

ADX2にはビート同期機能が存在し、データ内にビート同期情報を埋め込み、コールバックで呼び出すことでビートに同期した演出を作成することが可能です。
また、途中でビート情報の変更も可能です。

素材データの準備

再生する音声データを準備します。
今回は、途中でBPMが切り替わる素材を試したいのですが、ちょうど良い素材がないので、2つの曲を1つに繋げた素材を作成し、wavファイルにしました。
(AtomCraftには1つのキューに複数の素材を入れて再生することもできますが、ビート同期が未対応の様子なので、1ファイルのwavとして準備しました)

オーディオデータの作成

オーディオデータにビート同期データを挿入します。
オーディオデータの作成には専用ツールAtomCraftを使用します。

新規プロジェクトを作り、準備した音声データを「マテリアルツリー」に入れます。
プロジェクトにCueを作成し、トラックに音声データを追加します。
スクリーンショット

ビート同期の設定

「タイムライン」で「ビート同期情報の作成」で同期情報を追加します。
スクリーンショット

「BeatSync」を曲の頭に設定しましょう。1つ目は0秒目から再生なので、0:00:00に。
BeatSyncを複製して、もう一つをBPMが変わった2曲目の頭に設定します。

「BeatSync」をダブルクリックすると、「BPM」「分子」が設定可能です。
(分母は変更不可。半拍などを表現したい場合は、BPMを倍速にして分子を8にすればいけるかもしれません)
スクリーンショット

また、ビートパターンの設定も可能です。今回は最初は1拍ずつ、BPM変更後は2拍・4拍のみのオフビートに設定してみました。
画面右側の「ビートパターン」で、1つ目は4拍全て、2つ目はオフ・オン・オフ・オンと設定されていることが分かると思います。

ここまで作ったら、キューシートをビルドします。
「ビルド」→「Atomキューシートのバイナリビルド」を選択。
ターゲットは「PC」、一番下の「Unity Assets出力」のチェックをお忘れなく。
正しく設定できたら「ビルド」を選択。
指定したビルドフォルダにPC/Assets/StreamingAssets/フォルダが生成され、その中にacb,awb,acfファイルができていると思います。

Unityでビート同期演出を実装

ここからはUnityを使用してBGMの再生とリズムに合わせてスケーリングする表示演出の作成を行います。

Unityプロジェクトでの再生準備

Unityの新規プロジェクトを作成してCRIミドルウェアをプラグインを導入します・・・というのが一般的な手順ですが、面倒なのでここは提供されているsampleを利用し、サンプルシーンをコピーします。
criatom/basicのいずれかのシーンをコピーし、
CRIWARE,CriWareErrorHandler,CriWareLibraryInitializer以外不要なものを削除します。

先程生成された、acb,awb,acfファイルをプロジェクトのStreamingAssetsフォルダに追加しましょう。

シーン上のCRIWAREオブジェクトに「Cri Atom(Script)」が存在していますので、ここのCueSheet情報を、先程のキューシート名に書き換えます。

また、シーンにオブジェクトを配置し、「Cri Atom Source」コンポーネントを追加します。正しいCueSheet名、CueName名を入れて実行、正しく再生されるか確認します。

ビート同期コールバックの設定

今回は、ビートのたびにテクスチャが大きくなる演出を作ります。
SpriteをスケーリングさせるMonoBehaviorをSpriteにアタッチします。
BeatOn()がビート同期が来るたびに呼び出したい関数となります。

SpriteScaler.cs
    /// <summary>
    /// ビートイベントが来た時に実行する関数
    /// </summary>
    public void BeatOn()
    {
        // サイズを最大に
        SetScale(MaxScale);
    }

    void SetScale(float scale)
    {
        // Spriteにスケールを設定
        nowScale = scale;
        this.transform.localScale = new Vector3(nowScale, nowScale, 1.0f);
    }

    void Update()
    {
        // サイズが大きければ元のサイズまで徐々に戻す
        SetScale(Mathf.Max(NormalScale, nowScale - (ScaleSpeed * Time.deltaTime)));
    }

ビート同期時に呼び出したい関数をコールバックに登録します。
BeatOn()をコールバック関数で呼び出します。

BeatSyncTest.cs
    [SerializeField] private SpriteScaler spriteScaler;

    void callback(ref CriAtomExBeatSync.Info info)
    {
        spriteScaler.BeatOn();
    }

    /* Initialization process */
    void Start()
    {
        CriAtomExBeatSync.SetCallback(this.callback);
    }

これでビート同期コールバック登録ができました。
実行したものがこちらになります。

ビート同期の準備

BPMの拍に同期して音符が動くこと、曲が変わったタイミングでBPMと拍のとり方が変わったことが確認できました。

今回のサンプル作成にあたり、以下の素材を使わせていただきました。ありがとうございました。
楽曲素材:Music-Note.jp
絵素材:いらすとや

Android ARM64bit対応まとめ

$
0
0

やっはろこんこん。明日はクリスマスですね。
がんばりましょう。

Androidの64bit対応について前に調査したことをまとめておきます。
対象はUnityで開発されたアプリを想定しています。
また、実行環境はmacOS Sierraとなっています。(古い

Android ARM64bit対応とは

Android Developers Blogで2019年8月までにAndroidアプリの64bit対応が必要になることが発表されています。
具体的にはAArch64をサポートするARMv8ベースのCPU向けのビルドが必要となります。
これらのCPU向けのABIがarm64-v8aです。

Unityで開発されたアプリでは、大きく2つの対応が必要となると思います。

  • ARM64bitビルドに対応したUnityにバージョンアップする
    • monoでビルドしている場合はIL2CPPビルドにする必要あり
  • 内部で使われているネイティブプラグインを64bitのものに差し替える

今回は特にネイティブプラグインまわりについて記載しています。

Unityのバージョンアップ

Unity2018.2以降のバージョンを選択します。
TargetArchitecturesにARM64が追加されています。
スクリーンショット 2018-12-21 18.51.48.png
古いUnityではバージョンアップが大変だと思いますが、がんばってください。

※2018/12/25 追記
Unity2017.4.16f1にもAndroid 64bit対応が入っているようです。

arm64-v8a対応済みのプラグインをアップデートする

よく使われそうなプラグインのアップデートに関して、簡単に記述しておきます。
ここで自前でネイティブプラグインをビルドする必要がなく、通常のアップデートでできる範囲となります。

  • Google Play Game ServiceをUnityで扱うためのプラグイン
    ver0.9.51以上でarm64-v8aに対応しています。
    リポジトリ内の最新のunitypackageをインポートするだけで取り込めると思います。

  • Firebase
    ver5.2.0以上で対応されています。古いバージョンが入っている場合は削除してから、再インポートする方が罠がないと思います。

その他のプラグインをアップデートする

使っているネイティブプラグインでarm64-v8aをサポートしていない場合があります。
新しいプラグイン導入を検討するか、ソースコードが公開されていれば自前でのビルドが可能な場合があります。

基本的にプラグインはAssets/Plugins/Android/libs/arm64-v8a以下に入れます。

自前ビルドの方法

例として、Google Play Game ServiceをUnityで扱うためのプラグインの0.9.50以前をarm64-v8aでビルドしてみます。

まず、リポジトリからソースコードをcloneしてきます。

一度、arm64-v8aを追加する前にビルドしてみます。
まず、環境変数を設定します。
ネイティブプラグインをビルドするにあたっては、必ず設定しておく必要があります。

  • ANDROID_NDK_ROOT
    Android NDKのディレクトリを指定します。Unityの要求バージョンに合わせr16bを使いました。

  • ANDROID_HOME
    Android SDKのディレクトリを指定します。Android SDKのバージョンは、compileSdkVersionが27になっていますので、それ以降のバージョンをいれておきます。

  • UNITY_EXE
    このプラグインはUnityでunitypackageにパッキングするため、UNITY_EXEも設定しておく必要があります。他のプラグインのビルドでは通常不要かと思います。macOSの場合は以下ような形になると思います。

export UNITY_EXE=/Applications/Unity/Unity.app/Contents/MacOS/Unity

それではビルドをしてみます。
プロジェクトルート以下でgradlewを実行します。

./source/gradlew

一旦これで、ビルドができました。

ここから実際にarm64-v8a向けのビルドをしてみます。

次のファイルの35行目ぐらいのndk.abiFiltersに'arm64-v8a'を追記します。
./source/SupportLib/PlayGamesPluginSupport/build.gradle

defaultConfig {
    versionName  project.version
    archivesBaseName = project.ext.baseName
    minSdkVersion 14

    ndk.abiFilters 'x86', 'armeabi-v7a', 'arm64-v8a'


    externalNativeBuild {
        cmake {
            cppFlags "-std=c++11 -frtti -Wall -Werror"
            arguments "-DGPG_SDK_PATH=${gpgSdkDir}",
                    "-DANDROID_STL=c++_static",
                    "-DANDROID_TOOLCHAIN=clang"
        }
    }
}

これで再度ビルドをしてみましょう

./source/gradlew

ビルドが終わると
./source/SupportLib/PlayGamesPluginSupport/build/output/aar以下にgpgs-plugin-support-release.aarが生成されています。

実際にarm64-v8aが含まれているか確認します。
aarはzipファイルなので、unzipしてみます。

unzip ./source/SupportLib/PlayGamesPluginSupport/build/outputs/aar/gpgs-plugin-support-release.aar

jniディレクトリ以下ににarm64-v8aディレクトリとその下にlibgpg.soが入っていることがわかります。

まとめ

UnityでAndroidのARM64対応するためには、Unity2018.2以降のバージョンでビルドする必要があります。
また、対応されていないネイティブプラグインはプラグインの再選定もしくは、自前でビルドする必要があります。
自前でビルドする際には以下の設定が必要です。

  • Android SDKとNDKを正しく設定する
  • abiFilterにarm64-v8aを追記する

Androidアプリ開発の助けになれば幸いです。
最後までお読みいただきありがとうございました。

AWS EC2 インスタンスをプログラム実行中のみ起動

$
0
0

こちらのアドベントカレンダーの最終日の記事となります.
最後の記事がこのような記事で申し訳ないですがやっていきます.
chkconfig で処理の実行 -> シャットダウン をしようという記事です.

そもそもなぜそんなことをするの?

機械学習で AWS を使用しようとしましたが付けっ放しにするにはあまりにも高かったためです.

推奨インスタンスタイプ: p2.xlarge
時間単価: 0.900 USD/h

0.900 USD/h = 72,000 JPY/month
下手すると2ヶ月でGPU積んだパソコンが買えてしまい, 半日消し忘れでも 1,200 JPY.
物忘れが激しいので, 消し忘れを防止するために今回は処理 -> シャットダウンを仕組みに組み込みます.

実装

chkconfig というランレベルに応じて設定されたプログラムを実行するサービスを使用します.
まずは設定するスクリプトを作成します.

/path/to/project/run-script
#!/bin/bash
# chkconfig 4 99 10
# description: 処理したらオサラバ
# processname: run-script
cd /path/to/project
sudo -u ec2-user git pull
./script.sh
if [[ ! -f ./blocking_file ]]; then
    shutdown 0
fi

2 行目の # chkconfig 4 99 10 は chkconfig のファイルとして扱うための文言です.

  • 4 がこのスクリプトを実行するランレベル
  • 99 が起動時の優先度(小さいほど優先される)
  • 10 が終了時の優先度(今回関係なし)

です.

sudo -u ec2-user は ssh の設定を ec2-user のものを使用するためです.
git に使わせる鍵を ec2-user のものにしています.

if は設定ミスの時にシャットダウンを防止して, サーバへアクセスする手段を残すものです.
今回はリポジトリに blocking_file というファイルが存在した場合, シャットダウンされないようにしております.
僕は何度か失敗したのでインスタンスをいくつも潰す羽目になりました.
(後で書きます).

これに symlink を張って chkconfig に追加すれば chkconfig 側の設定は終了です.

$ ln -s /path/to/project/run-script /etc/init.d/
$ chkconfig --list | grep run-script
$ chkconfig --add /etc/init.d/run-script
$ chkconfig --list | grep run-script
> run-script            0:off   1:off   2:off   3:off   4:on    5:off   6:off

最後に ssh の設定, git clone などを済ませて終了です.

blocking_file を追加したり, 削除したりして動作が実行されていることをご確認ください.

結局何を間違えたの?

主にこの2つのミスを同時にやらかし失敗しました.
- if 文つけ忘れ
- git pull の失敗

if 文のつけ忘れに関しては最初の段階で気をつけねば〜と思っていたのですが,
つけて再起動すればサーバのコードも更新されて済むだろと軽い気持ちで考えておりました.
しかし git pull が失敗しておりサーバのコードに更新がかからずアクセス不能になりました.

chkconfig は root ユーザで実行されます.
なので git で ec2-user の鍵を使いたい場合はユーザを変更するか, 鍵を頑張って指定するかになります.
僕は両者どちらも忘れていたため, git pull が不発に終わり作ったサーバにサヨナラバイバイする羽目になりました.
run-script が sudo -u ec2-user git pull になっているのはそのためです.
root で鍵作っても動くかと思います.

参考

chkconfig で起動順序と終了順序を指定する
EC2起動時にスクリプトを実行する

最後に

初めて記事を書き間違いも多いかと思いますので, これ危ないよ, ここ間違ってるぞという場合は何卒ご指摘お願いいたします.