皆様こんにちは。イーソル アプリケーション開発部 Entertainment課のK.Oです。
我々Entertainment課は、昨年9月に開催されたイーソルのプライベートフォーラム「eSOL Technology Forum2024」にて、「ROS × XR」をテーマにゲームエンジンGodot*1にROS 2*2を組み込んで作成したゲームのデモ展示を行いました。
本デモではROS 2を用いて、現実空間上のロボットトイtoio*3 (以下ロボット)の位置とVR空間上のオブジェクトの位置を同期させることで、デジタルツイン*4を実現させました。
本記事では、GodotやROS 2を活用して開発した本デモにおける技術要素について、実装例などを示しながら詳しくご紹介します。
|
目次
1. Godotへの機能追加
本開発では、Godotからロボットを制御するためにGodotにROS 2を組み込むことにしました。
そこで初めに、Godotに機能追加する方法についてご紹介します。Godotへの機能追加には以下の2つの方法があります。
- カスタムモジュールによる機能追加
- GDExtensionによる機能追加
今回は、カスタムモジュールによる機能追加を選択することにしました。理由は、ダイナミックリンクライブラリの依存関係の解消のしやすさです。
本開発では、ROS 2のダイナミックリンクライブラリを使用することを想定しています。
カスタムモジュールによる機能追加の場合は環境変数”Path”にROS 2のダイナミックリンクライブラリがあるディレクトリへのパスを追加するだけで使用できます。
一方で、GDExtensionによる機能追加の場合には同じ方法ではダイナミックリンクライブラリの依存関係が解消できず、ライブラリを静的にリンクするようにするなどの少し複雑な手順が必要になるため、本開発に適しているのは前者であるという結論に至りました。
本章では、本開発で実際に用いたカスタムモジュールによる機能追加の方法をメインにして、それぞれの機能追加の方法についてご紹介します。
1.1 カスタムモジュールによる機能追加
はじめにmodulesディレクトリ内にカスタムモジュール用のディレクトリを作成します。作成したディレクトリ内で必要になるファイルは以下の通りです。
①追加したい機能が記述されたC++ヘッダファイル(複数ファイル可)
②追加したい機能が記述されたC++ソースコード(複数ファイル可)
③register_types.h
④register_types.cpp
⑤SCsub
⑥config.py
それでは、整数totalに数を加算、totalを取得、totalをリセットするという機能追加を例にした各ファイルの実装方法についてコード例を示しながら説明していきます。
①total_count.h
1 #ifndef TOTAL_COUNT_H
2 #define TOTAL_COUNT_H
3
4 #include "core/object/ref_counted.h"
5
6 class TotalCount : public RefCounted {
7 GDCLASS(TotalCount, RefCounted);
8 protected:
9 static void _bind_methods();
10 public:
11 TotalCount();
12 void calc_add(int p_value);
13 int get_total();
14 void reset_total();
15
16 private:
17 int total_;
18 };
19
20 #endif // TOTAL_COUNT_H
6,7行目では、4行目でインクルードしたRefCountクラスを本例で実装したいTotalCountクラスに継承させます。RefCountクラスは多くの既存クラスの継承元になっており、参照カウントを保持するクラスです。
9行目の_bind_methods()はユーザー定義関数をGDScriptから使用できるようにバインドする関数です。本実装例では12~14行目の関数をバインドします。
②total_count.cpp
1 #include "total_count.h"
2
3 TotalCount::TotalCount() : total_(0) {}
4
5 void TotalCount::calc_add(int val) {
6 total_ += val;
7 }
8
9 int TotalCount::get_total() {
10 return total_;
11 }
12
13 void TotalCount::reset_total() {
14 total_ = 0;
15 }
16 void TotalCount::_bind_methods() {
17 ClassDB::bind_method(D_METHOD("calc_add", "val"), &TotalCount::calc_add);
18 ClassDB::bind_method(D_METHOD("get_total"), &TotalCount::get_total);
19 ClassDB::bind_method(D_METHOD("reset_total"), &TotalCount::reset_total);
20 }
17~19行目のClassDBクラスは、ほぼ全てのクラスの基底クラスになっているObjectクラスから継承される登録済みクラスのリスト全体と、すべてのメソッドプロパティと整数定数への動的バインディングを保持する静的クラスです。
このクラスのbind_method()を使って関数をバインドしていきます。
D_METHODは、第一引数にバインドする関数名、第二引数以降にバインドする関数の引数名を受け取ることで関数名を文字列に変換するマクロです。
③register_types.h
1 #ifndef TOTAL_COUNT_REGISTER_TYPES_H
2 #define TOTAL_COUNT_REGISTER_TYPES_H
3
4 #include "modules/register_module_types.h"
5
6 void initialize_total_count_module(ModuleInitializationLevel p_level);
7 void uninitialize_total_count_module(ModuleInitializationLevel p_level);
8
9 #endif // TOTAL_COUNT_REGISTER_TYPES_H
6,7行目のinitialize(uninitialze)_[文字列]_moduleの[文字列]にはmodulesディレクトリに追加したカスタムモジュールのディレクトリ名を入力します。
④register_types.cpp
1 #include "register_types.h"
2
3 #include "core/object/class_db.h"
4 #include "total_count.h"
5
6 void initialize_total_count_module(ModuleInitializationLevel p_level) {
7 if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
8 return;
9 }
10 ClassDB::register_class();
11 }
12
13 void uninitialize_total_count_module(ModuleInitializationLevel p_level) {
14 if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
15 return;
16 }
17 // 本実装例では何もしない
18 }
6, 13行目のModuleInitializationLevelはenum型変数で以下の4つ値が存在します。
- MODULE_INITIALIZATION_LEVEL_CORE = 0
Godot Coreの初期化完了直後 - MODULE_INITIALIZATION_LEVEL_SERVERS = 1
Godot Serverの初期化直後 - MODULE_INITIALIZATION_LEVEL_SCENE = 2
Godotのランタイムクラスの登録直後 - MODULE_INITIALIZATION_LEVEL_EDITOR = 3
Godotのエディタークラスの登録直後
このカスタムモジュールはエディター機能を拡張するものではないのでMODULE_INITIALIZATION_LEVEL_SCENEを待ってからClassDBの登録を行っています。
10行目のregister_class<クラス名>()においてGDScriptで使用したいクラス名を登録します。
追加機能で複数のクラスを作った場合はすべてのクラス名を登録します。
⑤SCsub
1 Import("env")
2
3 env.add_source_files(env.modules_sources, "*.cpp")
4 env.Append(CPPPATH=["include"] + [<外部インクルードディレクトリパス>])
5 env.Append(LIBPATH=[<静的ライブラリディレクトリパス>])
6 env.Append(LIBS=[<各静的ライブラリファイル名>])
このファイルでSConsによるビルド時に必要なソースコード、インクルードファイル、静的ライブラリを指定します。中身は基本的にPythonのコードになっています。
3行目ではカスタムモジュールのディレクトリにあるすべてのcppファイルをソースコードとして登録しています。別のディレクトリにもcppファイルを配置した場合にはそちらのパスも指定する必要があります。Pythonのリスト型変数でファイルを個別に指定することもできます。
必要に応じて、4行目では外部ヘッダファイルのディレクトリパス、5行目では静的ライブラリのディレクトリパス、6行目では各静的ライブラリのファイル名を指定します。
⑥config.py
1 def can_build(env, platform):
2 return True
3
4 def configure(env):
5 pass
このファイルは、モジュールの設定ファイルです。
can_build()では特定のプラットフォーム用にビルドするかどうかを指定できます。本実装例のようにTrueを返すことはすべてのプラットフォーム用にビルドすることを意味します。
以上のファイルをカスタムモジュールのディレクトリに用意してSConsでGodotをビルドすることにより、GDScriptから実装した機能を呼び出すことができます。
以下が、呼び出す際のGDScriptの例になります。
1 extends Node
2
3 var instance = TotalCount.new()
4
5 func _process(delta: float) -> void:
6 var val = randi_range(0, 9):
7
8 if val == 0:
9 instance.reset_total()
10 else:
11 instance.calc_add(val)
12
13 print(“total: ”, instance.get_total())
1.2 GDExtensionによる機能追加
次に、Godotへの機能追加のもう一方の選択肢である、GDExtensionを用いた方法についてご紹介します。
GDExtensionはダイナミックリンクライブラリを用いてGodotに機能追加をする方法です。
必要なものは以下の2点です。
- 機能追加のためのダイナミックリンクライブラリ
- 上記のダイナミックリンクライブラリの情報などが書かれた「.gdextension」という拡張子のテキストファイル
これらのファイルは慣習的にbinディレクトリ下に置くことが多く見られます。機能追加のためのダイナミックリンクライブラリの作成方法については、詳しく説明されている日本語のサイトが既に存在するため、本記事では割愛させていただきます。
2. ROS 2を用いた、ロボットとVR空間上のオブジェクトとのデジタルツイン化の実現
本開発では、ROS 2を用いて現実空間上のロボットの位置とVR空間上のオブジェクトの位置を同期させてデジタルツイン化を実現しました。本章では、このデジタルツイン化において要となる「ロボットからの位置情報の取得」と「ロボットとVR空間上のオブジェクトとの衝突判定」についてご紹介します。
2.1 ROS 2によるロボットからの座標の取得
ROS 2が提供する機能の1つにトピック通信というものがあります。
ROS 2では、ノードと呼ばれる処理単位にプログラムが分割され、ノード間のメッセージの送受信によりアプリケーション全体が機能します。
トピックとは通信するデータの型と名前をもつ通信チャネルであり、トピック通信では下図のように各ノードがトピックにメッセージを送信したり、トピックからメッセージを受信したりします。そのため、各ノードが互いの存在を意識せずに通信することができます。
本デモで使用したロボットトイtoioには、BLE(Bluetooth Low Energy)で接続したPCから制御するためのライブラリ(toio.py*5)が公式に提供されています。
こちらのライブラリを含む形で実装したtoioとの通信を担うモジュール(以下、toio APIと呼ぶ)で、ROS 2のメッセージをBLEのコマンドに変換してロボットのプレイマット上で座標情報を取得したり、ロボット内蔵のモーターを回転させてロボットを移動させたりしています。
本開発における各モジュールの概念図は下図のようになります。
本開発では、ROS 2のノード(以下toio nodeと呼ぶ)を作成して、toio nodeとtoio APIを結びつけるプログラムを作成しました。
具体的には、トピックから受信したメッセージを変換してtoio APIを呼び出すことでロボットを移動させたり、toio APIを呼び出して取得した座標情報を変換してトピックに送信したりするプログラムとなっています。また、1章でご紹介した方法でGodotにROS 2とのインターフェースを実装しました。これにより、Godot上からtoio nodeとトピック通信するためのノードを作成できるようになり、ロボットの制御と座標情報の取得を可能にしました。
このように取得した座標情報からVR空間上の座標へと変換することで、現実空間のマット上のロボットの位置とVR空間上のプレイヤーオブジェクトの位置を同期させています。
2.2 ロボットとVR空間上のオブジェクトとの衝突判定
ロボットと周囲の建物との衝突判定は、現実空間における物理的な衝突ではなく、VR空間上でのソフトウェア的な衝突により判定しました。
この際に、プレイヤーオブジェクト前面での衝突と後面での衝突を区別するために、下図のようにプレイヤーオブジェクトの前面・後面にそれぞれ衝突オブジェクト(図中の直方体)を配置しました。
これは、プレイヤーオブジェクト前面での衝突の際はロボットを後退、後面での衝突の際はロボットを前進させることで衝突状態を解消させるためです。
このように配置した衝突オブジェクトと建物オブジェクトの衝突判定がTrueになった場合には、ロボットを静止させるとともに前進または後退の操作を受け付けなくすることでロボットとVR空間上のオブジェクトとの衝突判定を実装しました。
本デモでは、以上のような、現実空間上のロボットとVR空間上のプレイヤーオブジェクトの位置の同期および、周囲の建物との衝突判定を実装することによって、ロボットとVR空間上のオブジェクトとのデジタルツイン化を実現させました。
3. GodotでのVRゲーム開発
ゲーム開発時は、HUD(Head Up Display)など画面の定位置にオブジェクトを表示させたいケースがあります。このとき、同じ3DゲームでもVRゲームかどうかで大きく実装方法が異なります。
そこで本章では、3DVRゲームにおいて、HMD上での2Dオブジェクトを定位置に表示するために本開発で実際に用いた実装方法についてご紹介します。
3.1 HMD上での2Dオブジェクトの表示
非VRの3Dゲームの場合は、下図のように単純に「Label」などの2Dオブジェクトをノードとして追加し、2Dワークスペースでオブジェクトの位置を調整するだけで2Dオブジェクトを画面に定位置に表示できます。
一方で、3DVRゲーム上で2Dオブジェクトを表示したい場合には、単純に2Dオブジェクトをノードとして追加しただけではHMD上では表示されません。
そこで、「Sprite3D」と「SubViewport」の2つのノードを用います。
まず、下図のように「Sprite3D」の子ノードに「SubViewport」を追加して、その子ノードに表示したい2Dオブジェクトを追加します。
その後、「Sprite3D」のインスペクターの「Texture」をクリックして下図のようなメニューから「新規ViewportTexture」を選択します。
そうすると、下図のようなViewportを選択する画面が表示されるので、表示したい2Dオブジェクトを子ノードにもつSubViewportを選択します。
「SubViewport」や表示したい2Dオブジェクトのサイズ、「Sprite3D」の位置などを調整すると下図のような状態になります。
この状態で「プロジェクトを実行」または「現在のシーンを実行」を選択すると、HMD上でも2Dオブジェクトが表示されます。 しかし、ここまでの方法では、ある方向に2Dオブジェクトが表示されているだけで、HMD上の定位置に2Dオブジェクトを表示させることはできません。そこで次節では、オブジェクトの画面表示を3-DoFセンサーに対応させる方法ついてご紹介します。
3.2 HMDの3-DoFセンサーに対応させたオブジェクトの画面表示
HMD上の定位置にオブジェクトを表示させるための考え方は非常にシンプルで、「XRCamera3D」の座標系上で常に同じ位置にオブジェクトを配置する、というだけです。
そのため、HMD上の定位置に表示させたいオブジェクトのノードを「XRCamera3D」の子ノードにし、子ノードオブジェクトのtranslationプロパティを変更して「XRCamera3D」に対して適切な位置に配置するだけで実装が可能です。
このように簡単に実装できるのは、子ノードオブジェクトのtranslationプロパティの値が親ノードの「XRCamera3D」の座標系に対しての相対位置を指定するものだからです。
ただし、何らかの事情で、「XRCamera3D」の子ノードにできない場合には、GDScriptで座標変換する必要があります。この場合、シンプルに3次元空間上の座標系の回転に合わせて物体を回転させる計算しようとすると、非常に煩雑な行列計算が必要になります。しかし、Godotにはtransformという、座標変換において非常に強力なプロパティが存在します。
transformはbasisとoriginという2つのプロパティをもっています。
basisは親ノードに対する相対座標系の基底を表しており、デフォルトでは(1, 0, 0), (0, 1, 0), (0, 0, 1)という正規直交基底となっています。この基底の値を変更することで、オブジェクトのスケール変更や回転などの操作が可能になります。また、originは親ノードに対する相対座標系の原点を表しており、デフォルトでは(0, 0, 0)となっています。
さて、このtransformプロパティを使った座標変換について考えていきます。下図のように原点をO、基底を{x, y, z}とする座標系1(黒い軸の座標系)と原点をO'、基底を{x', y', z'}とする座標系2(青い軸の座標系)を考えます。
座標系1に対する座標系2の基底をそれぞれ x' = (x'1, x'2, x'3)、y' = (y'1, y'2, y'3)、z' = (z'1, z'2, z'3)、2つの座標系の原点の並進ベクトルをt = (t1, t2, t3)とします。
このとき、座標系1における点Aの座標(a1, a2, a3)は座標系2における点Aでの座標(a'1, a'2, a'3)を用いて
のように表されます。
以上のことをGDScriptで記述すると以下のようになります。
1 $Object.global_position = $XRCamera3D.global_transform * Vector3(a, b, c)
2 $Object.global_rotation = $XRCamera3D.global_rotation
グローバルな座標系(ルートノードの座標系)に関するposition, transform, rotationを扱いたいので、「global_XXX」というプロパティを用いています。
1行目は上式と完全には一致していませんが、transformプロパティにVector3型変数をかけることで同じ計算がされるようになっています。Vector3型変数の各値は「XRCamera3D」の位置やオブジェクトを配置したい位置で適宜変更してください。
また、2行目では、オブジェクトが「XRCamera3D」に対して正面を向くようにするために記載したコードです。
本開発では、以上のような、Godotの特長であるtransformプロパティを用いた方法で、VRゲームでもHMD上で2Dオブジェクトが定位置に表示されるように実装しました。
4. 最後に
本記事では、Godotへの機能追加やロボットを用いたデジタルツインの実現などについて解説してきました。
今回ご紹介したデモの開発に活用した、イーソルが提供する産業XRシステム向けソリューション「eXRP」の詳細は以下よりご覧頂けます。
このように、イーソルではXR機器・ゲーム機器の開発や、それに関わるアプリケーション開発など、エンタテインメント領域のエンジニアリングサービスを幅広く提供しております。
ご興味がありましたらお気軽にお問い合わせください。
Entertainment課 K.O