OpenGLでゲーム向け3次元剛体シミュレーションをやる

最近こちらの本(以降参考書)を読みました。

タイトル通り、物理エンジンの内部でどのように剛体シミュレーションが行われているかについて解説している書籍となっています。一通り読み終わったので、復習も兼ねて軽く剛体シミュレーションについて解説しようと思います。

できたもの

公開されているサンプルコードを参考にしつつ、自分の環境でも剛体シミュレーションをやってみました。

youtu.be

シェーディングがおかしいことには目を瞑ってください...。

使ったライブラリ:

https://github.com/yubeneko/PhysicsSimulation

剛体シミュレーションとは

3次元空間にある剛体には重力がかかるので、何もしないと剛体は下に向かって落ちていきます。また、剛体が他の剛体と衝突した際はそれぞれの剛体が跳ね返り、速度や位置、姿勢が変化します(反発)。他にも、ある剛体を他の剛体の上で滑らせた場合に、滑っている方の剛体がなかなか停止しなかったり、すぐに停止したりします(摩擦)。

剛体シミュレーションとは、これらの物理現象をコンピュータでシミュレートすることを意味します。

データ構造

剛体シミュレーションでは1つの剛体を表現するために「形状」、「状態」、「属性」の3つのデータ構造が必要となります。

「形状」はその名の通り剛体の形状を表し、剛体同士の衝突検出に使用されます。衝突検出を簡単にするため、剛体の形状は基本的に凸形状で表現されます。剛体が凹形状である場合、いくつかの凸形状を組み合わせることで1つの形状を表すようにします。

「状態」は剛体の座標や並進速度、姿勢、回転速度といった情報を保持します。

「属性」は質量や慣性テンソル、反発係数などを保持します。

シミュレーションの流れ

剛体シミュレーションは1タイムステップ中に以下の処理を行います。

1. 外力の適用

重力などの剛体にかかる外部の力を剛体に適用し、時間積分により剛体の速度、回転速度を更新します。

2. 衝突検出

3次元空間内に存在する全ての剛体のペアについて衝突が発生しているかを調べます。このフェーズでは2つの剛体が衝突しているかどうかを調査し、衝突している場合はめり込み量(貫通深度)と衝突点の法線方向を求めます。この処理は剛体シミュレーションの中でもかなり重い処理で、特に凸メッシュ同士の衝突判定はポリゴン数が多いとめちゃんこ重たくなります。そのため、全ての剛体について衝突しているか詳しく調べようとするととても時間がかかってしまいます。そこで、一般的な衝突検出フェーズは、衝突しているかもしれないペアを検出し、その後でそのペアが本当に衝突しているかどうか詳しく調べるという二段構成にすることで効率化されています。それぞれ「ブロードフェーズ」、「ナローフェーズ」と呼ばれており、お互いにいくつかの手法が存在します。

ブロードフェーズでは主に軸平行バウンディングボックス(AABB: Axis-Aligned Bounding Box)が使われます。ブロードフェーズは3次元空間中に存在する剛体の数が少ないならば総当たりでも良いのですが、数が多くなってくると調べなくてはならないペアが増えてくるのでしんどくなってきます。その際は、スイープ&プルーンや AABB tree といった高速化のためのアルゴリズムを使う必要があります。

ナローフェーズでは、分離軸の理論に基づいた衝突検出法、GJKアルゴリズムといった手法が使われます。参考書では分離軸の理論に基づいた手法について説明されていました。この部分は数多くの数学から成り立っているためかなり難しいです。自分も完全に理解しきれていません。もっと詳しく知りたいならば以下の本あたりを読むと良いかもしれません。

https://www.amazon.co.jp/ゲームプログラミングのためのリアルタイム衝突判定-Christer-Ericson/dp/493900791X

値段高っけー。

3. 拘束演算

衝突検出フェーズにて衝突が検出されたペアは互いに並進速度と回転速度が変化します。これらを求めるのが拘束演算フェーズの役割です。まず、今のままでは剛体同士がめり込んだままになっているので、それを解消するような速度を剛体に与えなくてはなりません。他にも、衝突点の法線方向に対して直角となる2つの軸方向(摩擦方向)の速度も考慮する必要があります。これらの条件を満たすような数式をそれぞれの軸(衝突点の法線方向 + 摩擦方向 x 2 の 3つ)ごとに立てて、それらを解くことで並進速度と回転速度それぞれの増分を求めます。これらを前のタイムステップでの速度に足しこむことで現在のタイムステップでの速度が求まることになります。また、例えば1つの剛体に対して複数の剛体が衝突している場合、1つの剛体の並進速度と回転速度を求めることで他の衝突のめり込みを増やすことになったりしてしまいますが、これは拘束の式を繰り返し解くことにより全ての剛体の速度がイイ感じになるよう収束させることが可能です。繰り返す回数を増やすほど精度は向上しますが、その分計算時間も増加してしまうので、ゲーム向けの剛体シミュレーションでは繰り返し回数を指定してしまって、その回数だけ拘束の計算をするようにしています。

衝突に関する拘束以外にも、ヒンジジョイントやスライダージョイントといった各種ジョイントの拘束の演算もこのフェーズで行われます。

4. 位置更新

拘束演算により現在の剛体の並進速度と回転速度がもとまったので、これをもとに剛体の座標と姿勢を更新します。こうして求まった座標と姿勢をゲーム内のオブジェクトに適用することで、あたかもゲームオブジェクトが物理法則に則った動きをしているかのように見えるのです。

今回の実装の改善の余地

剛体シミュレーションの並列処理化

剛体シミュレーションのゲームへの組み込みについては Unity と同じ感じです。シミュレーションを行うクラスとシミュレーションの結果をゲームオブジェクトに適用するコンポーネントクラス(RigidBodyコンポーネント)を作成し、剛体シミュレーションの対象にしたいオブジェクトにそのコンポーネントを付けることで剛体シミュレーションを行うことができます。

シミュレーションを挟むタイミングですが、今回はゲームオブジェクトの更新処理と描画の間に挟むようにしました。シミュレーションの結果はRigidBodyコンポーネントにて更新されます。ただし、このままだとシミュレーションが重たい場合、描画されるまでの時間が延期されてしまってゲーム全体のフレームレートが落ちるといった問題が生じます。そこで、Unityのように物理シミュレーションだけは別のスレッドで一定のタイムステップ間隔で実行し続け、RigidBodyコンポーネントがシミュレーション結果をゲームループの更新タイミングで取得し、ゲームオブジェクトに適用するという感じにすると、物理シミュレーションが重たくなったとしてもゲーム全体のフレームレートは落とさずに済むようになります。

詳細はこちらの記事をご覧ください。

Cocos2d-xにおけるマルチスレッドを利用した並列処理技法―物理演算パフォーマンスの最適化に向けて― - LINE ENGINEERING

衝突検出の最適化

今回の実装では凸メッシュのみを使った衝突検出を行いました。凸メッシュを使えばかなり幅広い形状の衝突検出ができるのは間違いないのですが、球やカプセルといったプリミティブ形状でゲームオブジェクトの形状を近似することができるのであればそちらを使った方が効率的に衝突検出が行える場合があります。ただし、形状の組み合わせごとに衝突検出処理が必要になってくるのでそこは大変ですね。

また、今回のブロードフェーズの実装は総当たりでやっているので、この部分をスイープ&プルーンや AABB tree といったアルゴリズムに置き換えるとさらに効率化することができます。

まとめ

ゲームにおける剛体シミュレーションの内部実装について軽く紹介しました。書籍についてですが、理論と実践のバランスがちょうど良くまとまっていてかなり理解しやすかったと思います。物理エンジンでどのように剛体シミュレーションが実装されているのか知りたい人にはオススメの一冊です。

C++ と OpenGLでゲームを作ってみた

最近こちらの本(以降参考書)を読みました。

内容は、ゲームエンジンを使わずに C++OpenGL を使ってゲームを作る上での基本(ゲームオブジェクトの概念や基本的なレンダリング技術、衝突判定やUI、オーディオ等)を習得しようというものでした。一通り読み終わったので、復習のためにシンプルな2Dゲームを作ってみました。今回はそのプロジェクトで学んだことなどを書こうと思います。

目次

作ったもの


www.youtube.com

プレイヤーは右からやってくる敵のUFOをレーザーで倒します。WASDで移動、スペースキーでレーザーの発射が可能です。プレイヤーが敵のUFOと衝突するか、敵が1体でも画面の左端に到達してしまったらゲームオーバーとなります。どれだけ長く敵の侵攻を食い止められるかがこのゲームの楽しみ方です。

敵の生成インターバルは時間が経つにつれて早くなっていき、最短の状態になっていると1発の外しが致命的になってきます。

github.com

※ 上の github リポジトリではソースファイルの公開のみ行っています。実行ファイルは配布していません。

(本当は実行ファイルも配りたかったのですが、Apple のセキュリティ周りとかライセンス関連が大変そうだったので断念してしまいました。)

使ったライブラリ、フレームワークなど

OpenGL

言わずと知れたグラフィックスAPIですね。

SDL

こちらは知らない人もいるかもしれません。SDL (Simple DirectMedia Layer) はキーボードやマウスといった入力デバイスOpenGL/Direct3D 等を経由したグラフィックスハードウェアへのアクセスを提供する開発ライブラリです。よく OpenGL と一緒に使われる GLFW に近い存在ですね。C言語で書かれていて、C++から利用することが可能です。

SDL_ttf

SDL には機能を拡張するような周辺ライブラリがいくつかあり、SDL_ttf はそのうちの1つです。このライブラリには TrueType フォントのレンダリングのための機能が含まれていて、ゲーム内の UI に使うことができます。参考書でもゲーム内のUIテキストの描画のために使われています。

GLEW

OpenGL拡張機能を有効化するためのライブラリですね。似たようなライブラリに GLAD があります。

SOIL

OpenGL で画像を描画する際は OpenGL に画像のピクセルデータを渡す必要があります。しかし、画像ファイルからピクセルデータを読み出す処理は OpenGL から提供されていないので、その部分は自分でなんとかするしかありません。選択肢としては、画像ファイルのデータフォーマットを紐解いてデータを読み込む処理を自分で実装するか、画像ファイルを読み込むライブラリを導入するかのどちらかになります。

参考書ではどうしていたかというと、SOIL という OpenGL 向けの画像読み込みライブラリを利用していました。しかし、このライブラリが公開されていたWebページはどうやら無くなってしまったみたいです。一方で、githubリポジトリは残っていました。

画像ファイルを読み込んで、OpenGLが直接読める形式でピクセルデータを扱ってくれるのでかなり便利です。

github.com

FMOD

FMOD はゲーム開発に使われるサウンドミドルウェアの1つです。こちらも参考書で使われていたので採用しました。

GLM

OpenGL と親和性の高い数学系ライブラリです。参考書ではベクトルが列ベクトルではなく行ベクトルで表されていました。つまり変換行列を作る時の掛け算の順番が変換を適用する順番と同じになります。大学の講義で散々列ベクトルを扱ってきた自分にとって、行ベクトルには大きな違和感を感じていました。かといって自前で列ベクトル形式で演算できるコードを書くのも時間がかかりそうで面倒だったので、ベクトルが列ベクトルで表されている数学系ライブラリのGLMを採用することにしました。

このライブラリはヘッダオンリーなライブラリなので導入が簡単でいいですね。

学んだこと

レンダリングに関する基本的な知識

私は今までCG分野では Unity にしか触れたことがありませんでした。しかも、レンダリング系には特別興味を抱いていなかったため、レンダリング系の知識はほぼ0と言ってもよいくらいでした。そんな私でしたが、今回のゲーム開発を通して基本的なレンダリングの知識を習得することができたと思います。特に、今回はテクスチャについて深く知ることができました。

参考書ではテクスチャのアニメーションのために、フレームごとの画像ファイルを1枚1枚ロードして、フレームごとにテクスチャを切り替えてアニメーションを実現するような実装が紹介されていました。自分も最初はそのような方式でアニメーションを実装していたのですが、爆発アニメーションを実装する時に、テクスチャアトラスの形をとった素材しか見つけることができませんでした。テクスチャアトラス形式の画像からフレームごとの領域を切り出してそれぞれ1つの画像データとしてロードして描画することも考えましたが、画像の特定の領域を切り出すツールを探しても良さげなものが見つかりませんでした。結局、テクスチャアトラスを1枚の画像データとしてロードして、描画時は利用するUV座標を切り替えることでアニメーションさせるようにしました。これについては参考書には書かれていなかったテクニックだったので、ネットで調べながら実装していきました。

実装の手間は少しかかりますが、テクスチャアトラスを利用することでロードするテクスチャの数を減らすことができてメモリ効率が良くなったりするので、テクスチャを複数個利用する際はなるべく画像データをテクスチャアトラス化してまとめておくのがいいですね。

継承

参考書では継承が至るところで使われていました。中でも、派生クラス独自の振る舞いを記述できるメソッドと基底クラスで絶対に実行する処理を記述するメソッドを分割するテクニックが興味深かったですね。

class Actor
{
public:
    virtual void Update(float deltaTime);

private:
    std::vector<Component*> mComponents;
};
  • Actor クラスはゲームオブジェクトを意味し、Actor クラスのインスタンスは1つのゲームオブジェクトとしてシーンに存在します。
  • Actor は Component 型のオブジェクト(ポインタ)を保持します。
  • シーンに存在する全 Actor はゲームループを管理する Game クラスに保持されています。
  • Game クラスは保持している Actor の Update を毎フレーム呼び出します。

Update メソッドはオーバーライド可能で、派生クラスで独自の振る舞いを定義することが可能です。しかし、Actor クラスの Update メソッドには保持しているコンポーネントUpdate メソッドを呼び出す処理が記述されています。

void Actor::Update(float deltaTime)
{
    for (auto comp : mComponents)
    {
        comp->Update(deltaTime);
    }
}

この処理は派生クラスであっても絶対に実行されなければいけない処理です。そのため、派生クラスでUpdateメソッドをオーバーライドする場合は以下のように書かなくてはなりません。

void DerivedActor::Update(float deltaTime)
{
    Actor::Update(deltaTime);

    // 以降 DerivedActor の独自の振る舞いを記述
}

こうすることで派生クラスであっても基底クラスの処理を実行することができますが、この1文を書き忘れてしまうことで致命的なバグが生じてしまいます。これを解決するために、参考書では以下のようにしていました。

class Actor
{
public:
    void Update(float deltaTime);
    virtual void UpdateActor(float deltaTime);

private:
    std::vector<Component*> mComponents;
};
void Actor::Update(float deltaTime)
{
    for (auto comp : mComponents)
    {
        comp->Update(deltaTime);
    }

    // 派生クラスでオーバーライド可能な UpdateActor を呼び出す
    UpdateActor(float deltaTime);
}

// 基底クラスでは何も実装しない
void Actor::UpdateActor(float deltaTime)
{
}

このように、どんな派生型であっても実行されることが求められる処理を仮想メソッドから非仮想メソッドに切り出してあげて、そのメソッド内で派生クラスで独自に再定義できる仮想メソッドを呼ぶようにしてあげることで先ほどの問題点を解決することができます。

CMake

参考書ではIDE(Visual StudioXcode)を使った開発を推奨していましたが、いつも使っている VScode を使って開発したかったので VScode で開発を進めていました。ソースコードのビルドには、はじめは独自に記述した Makefile を使っていました。

以下はその頃に使っていた Makefile を模したものです。

# ソースファイルが置かれるディレクトリ
SRC_DIR = src
# ビルドにより生成されたファイルを配置するディレクトリ
BUILD_DIR = build/debug
# ソースファイルのリスト
SRC_FILES = $(wildcard $(SRC_DIR)/*.cpp)
# 実行ファイル名
PROGRAM = my_app
# コンパイラ
CXX = g++

# インクルードファイルのパス
INCLUDE_PATHS += -I/usr/local/Cellar/glfw/3.3.5/include
INCLUDE_PATHS += -I/usr/local/Cellar/glew/2.2.0_1/include

# 使用するC++バージョン
FLAGS = -std=c++11
# コンパイルオプション
CXXFLAGS = $(FLAGS) -Wall -O0 -g $(INCLUDE_PATHS) -DGL_SILENCE_DEPRECATION

# ライブラリファイルのパス
LDFLAGS += -L/usr/local/Cellar/glfw/3.3.5/lib
LDFLAGS += -L/usr/local/Cellar/glew/2.2.0_1/lib

# リンクするライブラリ
LDLIBS = -framework OpenGL -lGLEW -lglfw

# ビルドコマンド
all:
    $(CXX) $(CXXFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) -o $(BUILD_DIR)/$(PROGRAM)

# 実行ファイルのクリーン
.PHONY: clean
clean:
    -rm -rf build/debug/*

このやり方の問題は、ビルドのたびに全ソースファイルが再コンパイルされてしまうことです。オブジェクトファイルごとに生成方法を定義してあげればこの問題は回避できるのですが、新しくソースファイルを作成したり削除したりするごとに Makefile を編集するのは少し面倒です。

一方で、CMake を利用することでビルドシステムを自動生成することができ、ビルドシステムの選択肢の一つとして Makefile を生成することができるのですが、その Makefile はそのときのビルドに必要なソースファイルだけがコンパイルされるように書かれているのでとっても優秀です。上記の Makefile を使ったビルドのビルド時間の長さにはうんざりしていたので、どうにかこれを解決しようと CMake について色々と調べ上げて、なんとか最低限の CMake の書き方を習得することができました。これのお陰で開発の時間の短縮につながったと思います。

ライセンス関連

今までライセンスについてはそこまで詳しくなかったのですが、エンジニアになるのであればライセンス周りのことも知っとかなきゃならんだろうということで、ライセンス関連の知識を仕入れました。

分かったことは、コピーレフト型のライセンスの元で公開されているライブラリを使うときは気をつけなければならないなーということですね。あと、GPL系のライセンスには本当に気をつけないとダメですね。過去には日本でもGPLライセンスに関わる事件が発生しています。

過去に日本で起こった事件としては、2005年にAQUAPLUSのソフトウェアに GPLでライセンスされた動画デコーダーのライブラリが使われていることがわかり、 郵送によりゲーム本体も含めた当該ソースコード(画像やテキストデータなどのアセットを除く)が開示されたことが有名である。

https://dic.nicovideo.jp/a/gpl

余談ですが、GLM からは2つのライセンスが提供されているので利用するときはどちらかを選んで使うことになります。ひとつは MIT ライセンス。もう1つは Happy Bunny License という可愛らしい名前のライセンスです。このライセンス、ほとんど MIT ライセンスと同じなのですが、それに加えて1つ制限が加えてあります。

Restrictions: By making use of the Software for military purposes, you choose to make a Bunny unhappy.

https://github.com/g-truc/glm/blob/master/copying.txt

つまり、軍事目的での利用を禁止しているのですね。名前は可愛いのに中身は可愛くない...。時間と余裕があればいろんなライセンスの中身を見てみたいものです。

まとめ

支離滅裂で読みづらい文章となってしまいました。とりあえず、今回のプロジェクトを通してC++の基礎の基礎はある程度固めることができたと実感しているのでそこそこ満足しています。冒頭で紹介した本にも大変満足しています。

また、今回のプロジェクトでは動的オブジェクトの扱いのために生のポインタをそのまま扱っていました。しかし、生ポインタで動的オブジェクトを扱うのはメモリリークの危険がつきまといます。だから、早いところスマートポインタを理解して正しく使えるようになりたいですね。

macOS で動的ライブラリを動的リンクするとき

macOSにて、自作のCやC++アプリケーションで動的ライブラリを実行時に読み込ませる方法について調べてみたのでまとめてみます。動的ロードではなく、動的リンクの話です。

実行環境: macOS 12.1

目次:

動的ライブラリをどこに置いておくか

まず、動的ライブラリをどこに置いておくかですが、自分だけが利用するようなライブラリであれば /usr/local/lib あたりに置いておけば基本的に問題なさそうです。なぜなら、アプリを実行する時にリンカがそこを探してくれるからですね。

しかし、作ったアプリを外部に配布するとなると、配布先のPCの /usr/lib/usr/local/lib あたりにアプリで利用するライブラリが存在するとは限らないので、その場合はアプリと一緒に動的ライブラリも配布する必要があります。それだけでなく、配布する実行アプリが同梱した動的ライブラリを読み込めるようにしてあげる必要もあります。実は、今回はこの話がメインです。

動的ライブラリの詳細を追う

まずは、動的ライブラリの詳細を追ってみましょう。

以下のような単純な動的ライブラリを作ってみます。

mylib.cpp:

int my_sum(int a, int b)
{
    return a + b;
}
# コンパイル
$ g++ -c mylib.cpp

# 動的ライブラリ生成
$ g++ -shared mylib.o -o libmylib.dylib

次に、作ったライブラリを利用する実行ファイルを作ってみます。

main.cpp:

#include <iostream>
#include <mylib.h>

int main ()
{
    int a = my_sum(1, 5);
    std::cout << a << std::endl;
}

フォルダ構成は以下のように多少整理しておきました。

.
├── main
├── main.cpp
└── mylib
    ├── include
    │   └── mylib.h
    ├── lib
    │   └── libmylib.dylib
    ├── mylib.cpp
    └── mylib.o

コンパイルとビルド時リンクを行っていきます。

$ g++ main.cpp -I./mylib/include -L./mylib/lib -lmylib -o main

このコマンドにより実行ファイルmainが出来上がります。実行ファイル main があるディレクトリでこのファイルを実行すると...

$ ./main
dyld[17548]: Library not loaded: libmylib.dylib

残念ながらmainを実行したときにリンカが ./mylib/lib/libmylib.dylib を見つけることができなかったようです。

さて、実行時にリンカがアプリで利用される動的ライブラリをどのように見つけるかがここでは問題になっています。デフォルトではリンカは /usr/local/lib や実行ファイルを実行したディレクトリ等を探すようになっています。では、自分好みの位置に配置した動的ライブラリをリンカが見つけられるようにするにはどうしたら良いのでしょう。

これを知るには、実行ファイルが実行時にどのようにライブラリをロードするかを見てみる必要があります。

実行ファイルや動的ライブラリの中にはいろんな情報が書き込まれているのですが、macOS ではこれらの情報を otool コマンドにより調べることが可能です。

# 動的ライブラリのID名(自身のパス)を表示
$ otool -D libmylib.dylib

# 依存する動的ライブラリを表示
$ otool -L libmylib.dylib

# ロード時のコマンドを表示
$ otool -l libmylib.dylib

# 他にもあります

-l オプションでロード時のコマンドを見ることができるので、これで main を見てみましょう。

$ otool -l main
main:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0
    flags 0x0

... 略 ...

Load command 8
          cmd LC_LOAD_DYLINKER
      cmdsize 32
         name /usr/lib/dyld (offset 12)
Load command 9
     cmd LC_UUID
 cmdsize 24
    uuid E9747806-B1DE-3C85-BB7A-A7C1AC3A3502
Load command 10
      cmd LC_BUILD_VERSION
  cmdsize 32
 platform 1
    minos 12.0
      sdk 12.1
   ntools 1
     tool 3
  version 711.0
Load command 11
      cmd LC_SOURCE_VERSION
  cmdsize 16
  version 0.0
Load command 12
       cmd LC_MAIN
   cmdsize 24
  entryoff 15616
 stacksize 0
Load command 13
          cmd LC_LOAD_DYLIB
      cmdsize 40
         name libmylib.dylib (offset 24)
   time stamp 2 Thu Jan  1 09:00:02 1970
      current version 0.0.0
compatibility version 0.0.0
Load command 14
          cmd LC_LOAD_DYLIB
      cmdsize 48
         name /usr/lib/libc++.1.dylib (offset 24)
   time stamp 2 Thu Jan  1 09:00:02 1970
      current version 1200.3.0
compatibility version 1.0.0
Load command 15
          cmd LC_LOAD_DYLIB
      cmdsize 56
         name /usr/lib/libSystem.B.dylib (offset 24)
   time stamp 2 Thu Jan  1 09:00:02 1970
      current version 1311.0.0
compatibility version 1.0.0
Load command 16
      cmd LC_FUNCTION_STARTS
  cmdsize 16
  dataoff 49840
 datasize 16
Load command 17
      cmd LC_DATA_IN_CODE
  cmdsize 16
  dataoff 49856
 datasize 0

重要なのは、cmd LC_LOAD_DYLIBの部分。LC_LOAD_DYLIB は 「このパスで指定されたネイティブライブラリをロードしてくれ!」という意味を表します。

つまり、cmd LC_LOAD_DYLIBの部分をいい感じにいじってあげることで好みの位置に配置したライブラリを読み込むようにできる訳ですね。

絶対パスで動的ライブラリが読み込まれるようにする

macOSには実行ファイルの中身をいじることができるinstall_name_toolコマンドが用意されています。これを使って main の中身を書き換えてみましょう。

$ install_name_tool -change libmylib.dylib /Path/To/Workspace/Folder/mylib/lib/libmylib.dylib main

これは libmylib.dylib/Path/To/Workspace/Folder/mylib/lib/libmylib.dylib に置き換えるコマンドです。

これにより、mainを実行した時に/Path/To/Workspace/Folder/mylib/lib/libmylib.dylib をロードするように変更することができました。この段階で、mainはどのディレクトリにいても実行できるようになっています。

@executable_path と @rpath

自分の好みの位置にある動的ライブラリを読み込ませることに成功しました。しかし、これでは配布するアプリには不適切です。なぜなら、アプリを開発したマシン上の絶対パスをそのまま利用しているからですね。かといって、相対パスにすると「実行時のカレントディレクトリからの相対パスで動的ライブラリのパスを指定する」ことになるので、アプリを実行できるディレクトリが制限されてしまいます。

ロードするライブラリは絶対パスで設定したいけど、この絶対パスは実行時のパスを元にして作り上げたいですね。

これを可能にするのが@executable_path@rpathといったものです。

例えば、main を配布する際に以下のようなフォルダ構成で配布することを考えましょう。

distribution
├── lib
│   └── libmylib.dylib
└── main

この時、以下のコマンドを実行することで main の中身を編集します(main は新しく作り直したことを想定)。

$ install_name_tool -add_rpath @executable_path/lib main
$ install_name_tool -change libmylib.dylib @rpath/libmylib.dylib main

@executable_path には実行ファイルが実行された時のそのファイルが存在するディレクトリへの絶対パスが展開されます。例えば、distribution フォルダが /Users/<ユーザー名>/Downloads フォルダにあるとしましょう。

Downloads/distribution
├── lib
│   └── libmylib.dylib
└── main

この時、main を実行すると、@executable_path/Users/<ユーザー名>/Downloads/distributionに展開されることになります。

install_name_tool -add_rpath @executable_path/lib main は、@executable_path/libというパスを rpath に追加するコマンドになっています。

rpath はリンカが動的ライブラリを検索するパスです。つまり、今回の場合は実行ファイルがあるディレクトリと同じディレクトリにあるlibディレクトリの中からライブラリを探索するように設定していることになります。

さらに、ライブラリ自体のパスを @rpath/libmylib.dylib に書き換えることで、ライブラリ自体のパスを実質的に @executable_path/lib/libmylib.dylib に設定しています。

こうしておくことで、どんなmacOS環境でも、どのディレクトリからでも main を実行できるようになります。

まとめ

今回はmacOS で動的ライブラリを動的リンクするときのことについて書きました。

  • 自分だけが利用するようなライブラリであれば /usr/local/lib あたりにおいておけばOK。
  • 自作のアプリを外部に配布するとき、動的ライブラリも一緒に配布する必要があるならば、アプリが動的ライブラリを読み込めるように正しく設定してあげる必要がある。

おまけ

利用するライブラリのID(インストールパス)を @rpath/libXXX.dylib に書き換えておいて...

$ install_name_tool -id @rpath/libmylib.dylib libmylib.dylib

# 確認
$ otool -D libmylib.dylib 
libmylib.dylib:
@rpath/libmylib.dylib

そのライブラリをリンクすると...

$ g++ main.cpp -I./mylib/include -L./mylib/lib -lmylib -o main
$ otool -l main
main:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0
    flags 0x0

... 略 ...

Load command 13
          cmd LC_LOAD_DYLIB
      cmdsize 48
         name @rpath/libmylib.dylib (offset 24)
   time stamp 2 Thu Jan  1 09:00:02 1970
      current version 0.0.0
compatibility version 0.0.0

... 略 ...

mylib の name(ID) が @rpath/libmylib.dylib になります。

さらに、rpath はコンパイルオプションで設定できます。

$ g++ main.cpp -I./mylib/include -L./mylib/lib -lmylib -Wl,-rpath,@executable_path/lib -o main
 otool -l main
main:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0
    flags 0x0

... 略 ...

Load command 16
          cmd LC_RPATH
      cmdsize 40
         path @executable_path/lib (offset 12)
Load command 17
      cmd LC_FUNCTION_STARTS
  cmdsize 16
  dataoff 49840
 datasize 16
Load command 18
      cmd LC_DATA_IN_CODE
  cmdsize 16
  dataoff 49856
 datasize 0

Load command 16 に rpath のことが書いてありますね。

つまり、あらかじめ利用するライブラリのIDを @rpath/libXXX.dylib に書き換えておいた上でアプリのビルド時に -Wl,-rpath,@executable_path/lib というオプションをつけてビルドすることで、install_name_toolコマンドを使う必要がなくなる訳ですね。

参考

平面直角座標と緯度経度の変換を行う(C#)

ARアプリを作る際に緯度経度と平面直角座標の相互変換の技術が必要になりそうなので、C#で変換をするためのスクリプトを書いてみました。

変換式は?

国土地理院のサイトに変換式についてのページがあります。

平面直角座標への換算 計算式

緯度、経度への換算 計算式

数式がいっぱいで頭が痛くなりそうですね...。幸いにもこれらの数式の中間結果の依存関係を図式化している方がいらっしゃったので、そちらを参考にしながらコーディングをしました。

sw1227.hatenablog.com

コード

平面直交座標と緯度経度の相互変換をする。

使い方

using System;
using GeoCoordinateUtility;

namespace ConvertGeoCoordinate
{
    class Program
    {
        static void Main (string[] args)
        {
            var latLon = GeoCoordinateConverter.Coordinate2LatLon (
                x0: 53999.2571,
                y0: -83939.5673,
                latOrigin_deg : 36.00000000,
                lonOrigin_deg : 139 + 50d / 60d);
            Console.WriteLine ($"緯度, 経度 = {latLon.X}, {latLon.Y}");

            var coordinate = GeoCoordinateConverter.LatLon2Coordinate (
                lat0_deg: 36.483011,
                lon0_deg: 138.896538,
                latOrigin_deg: 36.00000000,
                lonOrigin_deg: 139 + 50d / 60d);
            Console.WriteLine ($"X, Y = {coordinate.X}, {coordinate.Y}");
        }
    }
}

結果

緯度, 経度 = 36.48301100025305, 138.89653799991123
X, Y = 53999.25707184523, -83939.56729232075

ちなみに、サンプルで指定した場所は某車漫画の聖地です。

ざっくりとした変換の仕組みの解説

そもそもどうやって緯度経度を座標に変換するのでしょうか。考え方としては、ある地点の周りが平面になっていると仮定してその地点を平面直角座標の原点として座標系を定めるといった感じです。日本にはその「ある地点」が19個あります。19個もある理由ですが、これは一つの原点だけでは地球の丸みによるずれが生じてしまうためです。地点によって原点の緯度経度が決まります。例えば、群馬県なら系番号9に該当するため、平面直角座標の原点の緯度経度は北緯36度0分0秒、東経139度50分0秒となります。

わかりやすい平面直角座標系 | 国土地理院

本コードの変換メソッドはどちらも座標系原点の緯度経度を引数にとります。なお、引数の緯度経度の単位は10進数の度単位になっているので、度分秒表記の場合は度単位に変換する必要があります。

コード内の定数(a, F, m0)についての詳細はこちらをご覧ください。

日本の測地系 | 国土地理院

注意

緯度経度から座標に変換する際に国土地理院のサイトを使って変換してみると分かるのですが、どうやら測量分野ではXが南北方向を表し、Yが東西方向を表すようです。普段イメージする座標軸とは逆になっているので注意が必要です。

参考サイト

[C#]平面直角座標系と緯度経度を変換する

緯度経度と平面直角座標の相互変換をPythonで実装する - Qiita

Zig SimをUnityで使うためのアセットを作りました。

Zig Simという 1->10(ワントゥーテン)で開発されたアプリケーションがあります。

zig-project.com

ZIG SIMは『フィジカル・プロトタイピングをプロトタイピングする』ためのアプリケーションです。 従来、センサを利用したプロトタイピングを行う場合、電子工作、マイコンプログラミング、通信仕様策定、アプリケーションプログラミングと4つの作業を行う必要があり、異なるエンジニアリングスキルが必要とされていました。

ZIG SIMではスマートフォンを『センサとwifiモジュールを取り付けたフィジカル・プロトタイピングデバイス』とすることで、『センサ受信プログラム』のみでのプロトタイピングが制作可能になりました。

私は大学の講義の演習課題作成や趣味などで何かとこのアプリにお世話になっておりました(主にUnityで)。そして、今後も何かと使うことになると思ったのでUnityのアセットを作ることにしました。

詳細についてはgithubのREADMEを参考にしてください。 github.com

Unityパッケージのダウンロードはこちらから。

アセット作成の際に参考にさせていただいたサイト

[Unity]非同期処理(Task)の利用例(UDP送受信,SerialPortのRead) - Qiita

[C#] イベント入門 - Qiita

ZIG SIMが送信するセンサの種類とデータ構造について - Qiita

【Unity】知らなきゃ損! 「SingletonMonoBehaviour」の解説と使い方 | ゆみねこブログ

UnityでJson.NETとJsonUtilityのデシリアライズを比較してみる

たまにC#Jsonのデシリアライズをすることがあるんですけど、そのとき私はよくJson.NETを使うんですよね。「これが稀によくある」ってやつか!? ただ、毎回使い方を忘れているので使う度に調べる羽目になってしまっています。

そこで、今回はJson.NETでのデシリアライズ方法を過去のユースケースを用いて記録として残しておこうと思います。また、せっかくなんでUnityでのJson関連のAPIであるJsonUtilityのデシリアライズ方法も載せておきました。さらにどうせなので、この2つの処理性能を比較してみました。

実行環境

  • macOS 10.15
  • Unity2019.2.10f1

準備

Json.NETはAsset Storeにもあります。しかし、公式のJson.NETの最新バージョンに比べて少し古いバージョンを使っているようなので、今回は公式の最新リリースを使っていきます。

github.com

2019年11月1日時点では最新バージョンは 12.0.2 でした。上記ページからJson120r2.zipをダウンロードし、解凍した後Json120r2/Bin/net45以下にあるNewtonsoft.Json.dllをUnityプロジェクトのAssets/Plugins以下に配置します。

ケースその1

以下のデータからtype1timesを抽出してDateTimeのリストとして保存します。

{
 "type1": {
   "area": "tokyo",
   "times": [
     "2019-10-25 14:33",
     "2019-10-20 23:51",
     "2019-10-26 18:19",
     "2019-10-20 21:08",
     "2019-10-23 23:57",
     "2019-10-23 23:43",
     "2019-10-24 15:37",
     "2019-10-25 04:14",
     "2019-10-24 23:45"
   ]
 },
 "type2": {
   "area": "kyoto",
   "times": [
     "2019-10-22 23:13",
     "2019-10-25 13:19",
     "2019-10-25 12:24",
     "2019-10-20 07:06",
     "2019-10-21 16:10",
     "2019-10-22 11:49",
     "2019-10-20 03:14",
     "2019-10-21 21:33",
     "2019-10-21 04:32"
   ]
 }
}

Json.NETの場合はJson Utilityのようにパースするためのクラスを用意することなくリストを作成できるのがいいですね。リストを作成する際もLINQと合わせて使うことで直感的にデータを持ってくることができます。反対に、Json Utilityは使いたいデータがあるところまでの構造をクラスを使って再現してあげないといけません。このケースのようにネスト構造になっている場合はクラスを2つ作らないといけないため少し面倒ですね。ただ、必要なJsonフィールドの名前の変数だけ用意してあげればいいので少しは気が楽でしょうか?

処理の観測をする際は条件を整えるため1つずつ計測を行いました。例えば、Json.NETの観測をする際は上記コードのようにJson Utilityの部分をコメントアウトしています。

Profilerで見てみると以下の結果になっていました。

Json.NET f:id:yubeshineko:20191102164121p:plain

Json Utility f:id:yubeshineko:20191102164130p:plain

JObject.Parse(Json.NET) JsonUtility.FromJson(Json Utility)
速度(別メソッド呼び出し込み) 16.61ms 0.35ms
GC Alloc 11.4KB 2.8KB

Json Utilityの方が高速で、さらにGC Allocも少ないという結果になりましたね。うーむ、これはUnityでは積極的にJson Utilityを使った方がいいのでは?

ケースその2

以下のゴミの捨て方のデータよりdataオブジェクトの配列中のitemanswerの要素を持つクラスのリストを作ります。ちなみに、このデータはこちらのものを改変して作りました。

{
    "limit": 50,
    "lastUpdate": "2018-03-07 08:40:05",
    "data": [
        {
            "oid": 1,
            "item": "アイスクリームの容器",
            "synonym": "アイスクリームの容器/アイス容器",
            "answer": "1.紙製のものは「古紙類」として雑誌や新聞折込チラシと一緒に束ねて出してください。2.プラマークの表示があるものは「プラスチック製容器包装」として出してください ※ともに汚れが落ちないものは「燃やせるごみ」として出してください",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 2,
            "item": "アイスノン",
            "synonym": "アイスノン/あいすのん",
            "answer": "燃やせるごみ",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 3,
            "item": "アイロン",
            "synonym": "アイロン/あいろん",
            "answer": "燃やせないごみ",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 4,
            "item": "アイロン台",
            "synonym": "アイロン台/あいろん台",
            "answer": "1.足付きのものは、「燃やせないごみ」として出してください。2.板のみのものは、「燃やせるごみ」として出してください",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 5,
            "item": "アクセサリー類",
            "synonym": "アクセサリー類/アクセサリー/あくせさりー/アクセサリ",
            "answer": "燃やせないごみ",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 6,
            "item": "アコーディオン",
            "synonym": "アコーディオン/あこーでぃおん",
            "answer": "燃やせないごみ",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 7,
            "item": "アタッシェケース",
            "synonym": "アタッシェケース・アタッシュケース/アタッシュケース/アタッシェケース",
            "answer": "燃やせないごみ",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        },
        {
            "oid": 8,
            "item": "厚紙",
            "synonym": "厚紙/あつがみ/あつかみ",
            "answer": "古紙類 ※雑誌と一緒に束ねて出してください",
            "label1": "",
            "url1": "",
            "label2": "",
            "url2": "",
            "label3": "",
            "url3": "",
            "label4": "",
            "url4": ""
        }
    ]
}

itemanswerを要素とするクラスをGarbageクラスとしてGarbageクラスのリストを作成します。

こちらのケースでもJson.NETの場合は必要なデータのみを直感的に持ってこれているのが分かっていていいですね。反対に、Json Utilityの場合は2つのクラスを作ってあげないと必要なデータを持ってくることができません。

では、実行して処理結果を見てみましょう。ケース1と同じように片方ずつ実行しています。

Json.NET f:id:yubeshineko:20191102163144p:plain

Json Utility f:id:yubeshineko:20191102163258p:plain

JObject.Parse(Json.NET) JsonUtility.FromJson(Json Utility)
速度(別メソッド呼び出し込み) 26.92ms 0.32ms
GC Alloc 88.5KB 2.1KB

ケース1に比べてかなりの差ができました。やはりUnityではJson Utilityを使った方がパフォーマンス的に良いということですね。

Jsonシリアライズまとめ

  • Json.NET

    • + 必要な部分のみを直感的に引っ張ってこれる
    • Jsonの様々な処理を行える
    • Json Utilityに比べメモリ使用量が多く、処理も時間がかかる
  • Json Utility

    • − 必要な部分のみ引っ張ってきたいときもわざわざクラスを用意する必要がある
    • − 使える機能がJson.NETに比べて少ない
    • Json.NETに比べメモリ使用量が少なく、処理も高速

いわゆる一長一短ってやつですね。リソースが限られている時などはJson Utilityを積極的に使うべきで、そうでもない時はJson.NETを使ってもいいでしょう。

...とは言いつつも、Json Utilityの性能は素晴らしいですね。今後はUnityではJson Utilityを使っていきたくなってしまいます。

というわけで、Json.NETの使い方を残しておこうと記事を書いていたら、思いがけずJson Utilityのハイパフォーマンス性があらわになってしまったという記事でした(笑)

参考

JSON Utility

https://qiita.com/sea_mountain/items/6513b330983ffa003959

https://docs.unity3d.com/ja/2017.4/Manual/JSONSerialization.html

JSON.net

https://www.newtonsoft.com/json/help/html/Introduction.htm

https://www.newtonsoft.com/json/help/html/SerializingJSONFragments.htm

DateTimeカスタム書式設定

https://docs.microsoft.com/ja-jp/dotnet/standard/base-types/custom-date-and-time-format-strings

ラズパイ x Arduino x Unity x HMDで簡易的なテレイグジスタンス体験をする

今回は夏休み中に趣味兼研究目的で作っていた簡易的なテレイグジスタンス体験ができる装置の紹介をしたいと思います。

目次

テレイグジスタンスとは?

Wikipediaによると以下のように書かれています。

テレイグジスタンス(英: Telexistence、遠隔臨場感、遠隔存在感)とは、バーチャルリアリティの一分野であり、遠隔地にある物(あるいは人)があたかも近くにあるかのように感じながら、操作などをリアルタイムに行う環境を構築する技術およびその体系のこと

テレイグジスタンス - Wikipedia

端的に言えば、遠隔地に存在するロボットなどの装置にユーザーがなんらかの方法で乗り移って、まるで自分がその場にいるかのように振る舞うことができるようになる技術のことです。この技術により、例えば、危険な地域(有毒ガス地帯、放射線量が高い地域、宇宙空間等)に実際に人間が赴くことなく、ロボットから送られてくる五感情報を通してスムーズに作業ができるようになったり、なんらかの理由により家から出ることができない方がロボットを通して社会で活躍したりイベントに参加したりできるようになるのです。

システム構成

システム構成は以下のようにしました。

f:id:yubeshineko:20191030172512p:plain

Unity, Raspberry Pi間はネットワークを通じて、Raspberry Pi, Arduino間はUSBケーブルで繋がっています。

実行環境

作業

ステップ1: ラズパイのカメラモジュールの映像を配信する

ラズパイを使ってカメラの映像を配信するとなったら、一般的にはmjpg-streamerが使われることが多いです。今回のプロジェクトでもこちらを使います。

カメラモジュールの設定についてはこちらが分かりやすいかと思います。

www.pc-koubou.jp

次にmjpg-streamerをインストールします。今回はラズパイのカメラモジュールに対応している派生版のmjpg-streamerを使用します。

github.com

# 準備
$ sudo apt-get install cmake libjpeg8-dev
# もしgcc, g++が入ってなければ
$ sudo apt-get install gcc g++

# インストール
$ git clone https://github.com/jacksonliam/mjpg-streamer.git
$ cd mjpg-streamer-experimental
$ make
$ sudo make install

これでmjpg-streamerを利用できるようになりました。カメラモジュールが接続されていることを確認して利用してみます。

# カメラモジュールが接続されているか確認
$ vcgencmd get_camera
# "supported=1 detected=1" と表示されたら、カメラが認識されている

$ pwd
/[mjpg-streamerをインストールしたフォルダまでのパス]/mjpg-streamer/mjpg-streamer-experimental

$ export LD_LIBRARY_PATH=.
$ ./mjpg_streamer -o "output_http.so -w ./www" -i "input_raspicam.so"

これでカメラの映像が配信されます。ラズパイと同じネットワークに接続されているデバイスでブラウザを開いてhttp://[ラズパイのIPアドレス]:8080/にアクセスするとmjpg-streamerのデモページが開き、左側のメニューからStreamを開くと、現在のカメラの映像が見れます。

しかし、毎回長い実行コマンドを打つのも大変なので、実行する際のシェルスクリプトを書きます。お好きなエディタで以下のファイルを作成し、mjpg-streamer-experimentalフォルダ以下に保存します。

mjpg_streamer_boot.sh

#!/bin/sh
 
# mjpg-streamer start script
 
# Path to mjpg_streamer and libraries
export LD_LIBRARY_PATH="/[mjpg-streamerをインストールしたフォルダまでのパス]/mjpg-streamer/mjpg-streamer-experimental"
STREAMER="$LD_LIBRARY_PATH/mjpg_streamer"
 
# Pi camera configurations
XRES="640"
YRES="480"
FPS="30"
 
# Web configurations
WWWDOC="$LD_LIBRARY_PATH/www"
PORT="8080"
 
# Start streaming
$STREAMER -i "input_raspicam.so -x $XRES -y $YRES -fps $FPS" \
          -o "output_http.so -w $WWWDOC -p $PORT" \
      -b

$ ./mjpg_streamer_boot.shで配信を開始することができます。なお、オプションでバックグラウンドで動作するようにしています。これは後にpythonコードを走らせるためです。

プロセスをkillする際は以下のようにします。

$ pgrep mjpg
# mjpg-streamerのプロセスIDが表示される
$ kill -9 [mjpg-streamerのプロセスID]

オプションについての説明は省略します。色々あるので調べてみてください。

ステップ2: ラズパイが配信している映像をUnityで受信する

次に、ラズパイで配信している映像を受信するためのUnityでの処理が必要になります。ネットで何かしらの方法がないものかと調べていたところ、ぴったりのものを見つけました。

hammmm.hatenablog.com

こちらの方が作成されたアセットを利用させていただきます。なお、このアセットはAndroidWindows 32/64bitでしか動作が確認されておらず、私がMacでやったときは動作しませんでした。

  1. アセットをダウンロード

    上記のページからダウンロードページに飛んでダウンロードします。

  2. ダウンロードしたunityパッケージをProjectにインポートする

  3. シーンにオブジェクトを配置(Quadなど)
  4. 配置したオブジェクトにMJStreamingPlayerスクリプトをアタッチする
  5. MJStreamingPlayerコンポーネントServerUrlフィールドにサーバーのURLを書く。

    http://[ラズパイのIPアドレス]:8080/?action=streamとなります。

  6. マテリアルを作成し、ShaderをUnlit/Textureに変更。このマテリアルを先ほど追加したオブジェクトにD&D。

これで準備はできました。ラズパイでmjpg-streamerを起動しておいてからUnityで実行してみます。Unityまで画像が来ていることが確認できたら成功です。

ステップ3: HMDの角度をサーボモーターで扱える値に変換してラズパイに送信する。

ラズパイに繋がったカメラの映像をUnityで見ることができました。次はUnityでのHMDの傾きをラズパイに送る処理を実装します。HMDはHTC Viveを使いました。

まずはHMDを利用できるようにします。

Project Settings > Player Settings > XR Settingsで Virtual Reality Supportedにチェックを入れてVirtual Reality SDKsOpenVRを追加します。これでカメラオブジェクトの映像がHMDに映るようになります。

あとはカメラオブジェクトに自身の回転情報を取得するスクリプトを書いてあげてそれをラズパイに送ればそれでOK!

...というわけにはいきません。なぜなら、サーボモーターでの回転角度と、Unity内での回転の角度が一致していないためです。そのため、ラズパイに回転角度を送る前にUnityで角度の変換をする必要があります。また、サーボモーターは180度回転のサーボを利用しています。

実際には以下のような違いがあります。

f:id:yubeshineko:20191031013819j:plain

transform.localEulerAnglesをサーボの回転角度に変えていきます。

Unityの角度 -> サーボの角度の変換コード。

ヨーに関してですが、HMDのY軸角度がサーボで回ることができない90度 ~ 270度の範囲にある際は、90~180の間の時は0度に、180~270の間の時は180度に制限しています。最初は360度回るサーボモーターでやってみたのですが、360度サーボをArudinoのサーボモーターライブラリで動かすことができなかったので今回は断念しました。

次に、ラズパイに変換した角度データを送信するスクリプトを書きます。通信にはUDPを用います。

Udp通信をするためのスクリプト。

そして、この2つのクラスを利用するスクリプトを書いてあげます。

UserDataManager.cs

using UnityEngine;

public class UserDataManager : MonoBehaviour
{
    public GameObject cameraObject;
    public string remoteHost = "";
    public int remotePort = 60000;
    UdpSender udpSender;
    HeadRotation headRotation;

    void Start()
    {
        udpSender = new UdpSender(remoteHost, remotePort);
        headRotation = cameraObject.GetComponent<HeadRotation>();
    }

    void Update()
    {
       udpSender.SendData(headRotation.GetServoAngle()); 
    }

    void OnApplicationQuit()
    {
        udpSender.UdpClientClose();
    }
}

3つのスクリプトが書けたら、カメラオブジェクトにHeadRotationスクリプトUserDataManagerスクリプトをアタッチします。アタッチしたら、UserDataManagerコンポーネントのフィールドを適切に設定していきます。

CameraObjectにはカメラオブジェクトをD&D、RemoteHostはラズパイのIPアドレスとします。RemotePortは今回は60000にしておきます。

準備ができたら、ラズパイで以下のコマンドを打ってパケットのキャプチャをしてみます。

# tcpdumpがなければ
$ sudo apt-get install tcpdump
$ sudo tcpdump -A -n udp port 60000

Unityにてシーンを再生して、正しくパケットがキャプチャされていれば成功です。

最後に、スクリーンとなるオブジェクトをVRカメラの子にしてHMDの正面に張り付くようにしてあげましょう。

ステップ4: Unityから送られてきたサーボモーターの角度データを受信してArduinoに送信する

ラズパイでUnityから送られてきたデータを受信することができましたので、次は送られてきたデータを受け取って、Arduinoに渡すためのPythonスクリプトを書きます。

ArduinoとラズパイはUSBケーブルで繋がっています。そのため、シリアル通信用のライブラリが必要になります。今回はpyserialというライブラリを使用することにします。pyserialはpipを使ってインストールできます。

# pipがなければ
$ sudo apt-get install python-pip
$ pip3 install pyserial

また、ラズパイのシリアル通信を有効にしていないのであれば有効にしてあげる必要があります。

$ sudo raspi-config

pyserialがインストールできたらコードを書いていきます。

Unityから送られてくる角度データ受信し、Arduinoに送信するスクリプト。

スレッドを利用してUDP通信部とシリアル通信部を同時に実行しています。ちなみに、32行目の/dev/ttyACM0ついて、基本的にはこれで問題ないのですが、この部分は人によっては違うことがあるかもしれません。その際はこの部分を書き換えてあげる必要があります。

ステップ5: ラズパイから送られてきたデータを処理してサーボを制御する

いよいよサーボを制御する段階まできました。というわけで、早速スクリプトです。

ラズパイから送られてきたサーボ角度のデータを処理してサーボを制御するコード

38行目でSerial.setTimeout(20)としてタイムアウトの時間を短くしています。デフォルトの値は1000(ms)となっており、今回のような速さが求められるプロジェクトにおいて1秒では大きすぎるので20msとしてあります。

また、送られてくるデータは{ピッチの角度},{ヨーの角度}となっているため、それぞれの角度を取り出さなくてはなりません。どうやら、Arduinoには文字列を区切り文字で分割するsplit関数は用意されていないとのことなので、以下の方が作成されたsplit関数を利用させていただきます。

algorithm.joho.info

コードが書けたら回路も作ります。

f:id:yubeshineko:20191031134241p:plain

サーボモーターの電源は外部から取ります。私の場合、12V1AのACアダプターを利用しました。また、サーボモーターの定格電圧は4.8V~5Vとなっているので、5Vの三端子レギュレーターによって5Vを作り出してサーボモーターに給電しています。

カメラマウントの組み立てについてはこちらをご覧ください。

studio.beatnix.co.jp

ヨー制御用のサーボとカメラマウントの間に微小のスペースが空いていて少しぐらつくので、スペーサーを挟んで接着剤で固定したりするといいかもしれないです。カメラの設置には両面テープなどを使用します。また、サーボを動かす際はカメラマウントは固定しないとかなり動きますので、何かしらの方法で固定する必要があります。

ステップ6: 全体テスト

最終チェックをします。

  1. mjpg-streamerを起動する
  2. Arduinoにコードを書き込む
  3. ArduinoとラズパイをUSBケーブルで接続する
  4. ラズパイでPythonコードを実行する

    $ python3 telexistenceApp.py

  5. Unityでシーンを再生する

シーンを再生して、HMDサーボモーターの角度が一致していることが確認できたら成功です!

ちなみに、Viveでやった後にOculus Questでも動作することを確認しました!

まとめ

今回は遠隔のカメラ映像をHMDを通して見て、HMDの傾きを遠隔のカメラにも適応させることで簡易的なテレイグジスタンス体験を味わうことをやってみました。しかし、ただ遠隔地のカメラ映像を見るだけではテレイグジスタンスとしては不十分です。そこで、次はロボットアームを導入したりして遠隔地の対象とのインタラクションができるようにしてみたいですね。また、今はまだ同一のネットワークでしか利用できないので、外部ネットワークからのアクセスをできるようにもしてみたいです。他にも、サーボモーターの制御にて、writeMicroseconds()関数を利用することで角度を指定するよりも細かくサーボの角度を制御できるので、これについてもやってみたいです。

エラーなど

C#でのUdpClientについて

UdpClientでのデータの送信方法としては

UdpClient udp = new UdpClient ();
udp.Send (sendBytes, sendBytes.Length, remoteHost, remotePort);

とする方法と

UdpClient udp = new UdpClient (remoteHost, remotePort);
udp.Send (sendBytes, sendBytes.Length);

とする方法があるのだが、後者のほうでやろうとすると、最初の1度はデータが送られるのに次からはデータが送られずにエラーを吐く。SocketExcepthonをキャッチしてエラーコードを確認したところ、コードは10061であった。接続対象から接続が拒否されているらしいが、原因は全く持って不明。

https://support.microsoft.com/ja-jp/help/819124/windows-sockets-error-codes-values-and-meanings

Python2系と3系でのpyserialの違い

pyserialにてPython2系と3系で微妙な違いがあった。 シリアル通信の際に 2系では

ser.write("Hoge")

とすればすぐにできるが、3系では

ser.write(str.encode(“Hoge”))

としないとできないとのこと。

stackoverflow.com

参考

mjpg-streamer

https://blue-black.ink/?page_id=5298

https://blue-black.ink/?page_id=2245

PythonでのUDP通信

https://www.shujima.work/entry/2018/07/13/195100

C#でのUDP通信

https://dobon.net/vb/dotnet/internet/udpclient.html

シリアル通信について

https://www.arduino.cc/reference/en/language/functions/communication/serial/readstring/

https://karaage.hatenadiary.jp/entry/2015/06/10/080000

https://novicengineering.com/シリアルモニターの使い方【arduino】/

Pythonでのスレッドの止め方

https://codeday.me/jp/qa/20190711/1225730.html