フューチャー技術ブログ

Quest2のハンドトラッキングでVRテルミンシミュレータを作ってみた

はじめに

こんにちは。TIG DXユニット所属、金欠コンサルタントの藤井です。
最近、長らく買おう買おうと思っていたMeta Quest 2をとうとう買いました。

夏の自由研究ブログ連載2022 6日目のこの記事では、このMeta Quest 2向けに作ってみた、VRテルミンシミュレータというものを題材に記事を書きます。
作ったものはこちらです。readmeの動画は音量注意かもしれません。

https://github.com/shomuMatch/thereminSimulator

自分で作成したリソースと、ビルド後のapk、サンプル動画のみ配置してあります。

apkは自由に使っていただいて構わないので、うまく演奏出来たら教えてください。
ところでgithub、readmeに動画置けるようになっていたんですね。

前提知識

Meta Quest 2とハンドトラッキング

皆さんご存知の通り、Meta Quest 2(以下、Quest2と略記)はMeta(旧Facebook)社が開発・販売しているVRヘッドセットです。

Meta社の社名変更前はOculus Quest 2という名前で販売されており、こちらの名前の方がなじみ深い方も居られるかもしれません。

詳細なスペック等は公式情報1を参照頂くとして、このQuest2には前面に4つのカメラが搭載されています。この4つのカメラの画像を元に位置・回転のいわゆる6DoFの情報を特定することができるのですが、このカメラは他の目的にも利用できます。

その1つが今回用いるハンドトラッキング。カメラの画像から手を検出し、手の各部位の位置を特定、入力情報として用いるための機能です。

テルミン

テルミンというのは、レフ・テルミンという人が1920年にロシアで発明した楽器の名前なのですが、楽器の中でも電子楽器と呼ばれるジャンルに分類される楽器です。

電子ピアノや、キーボード等が電子楽器の例ですが、電子回路によって音の信号を作り出す物を総称して電子楽器と呼びます。テルミンはこの電子楽器の中でも世界最古の電子楽器であり、その特徴は何といってもテルミンそのものに触れることなく演奏できるという、一見不思議な楽器です。

もちろん演奏と言うからには、音量や音程等をコントロールする必要があるのですが、演奏者はこのコントロールを楽器に触れずに行うことができます。テルミンからは2本のアンテナが伸びており、このアンテナと演奏者の手の距離に応じて音量・音程が変動します。

高校時代に何かの折でテルミンを知り、一度演奏してみたいと思っていたのですが、その夢が叶う時が来たようです。

概要原理

では、どうやって触れずに演奏するのか、一言で言うと、演奏者自身が楽器の一部となるように設計されています。

最初から演奏者が楽器に含まれているので、改めて楽器に触れずとも演奏が可能であるという、どうやったらそんな発想に至れるのかという仕組みでテルミンは動いています。
とても簡単に構成図を書くとこんな感じです。

image.png

この仕組みをシミュレーションしたいので、数式に落とし込む必要があるのですが、少し話が膨らんでしまうため、より詳細な原理については後回しにします。

詳細原理の項に記載しておくので、先に読みたいという方はそちらからお読みください。

作ってみる

それではテルミンシミュレータを作っていきます。
開発環境は以下です。

  • IDE: Unity 2021.3.7f1(LTS)
  • 入れたモジュール
    • Android Build Support
      • OpenJDK
      • Android SDK & NDK Tools
  • テンプレート: VR2
  • 入れたパッケージ
    • Oculus Integration

また、Quest Link(Quest2をPC用のVRヘッドセットとして使える機能)を使うと、都度ビルドせずともQuest2実機を用いてテストプレイができるのですが…

ダメでした。記事を書きながらPCを注文したので、また今度こちらも含めて環境構築記事を書きます。

初期設定

まずはメニューのAssets > Create > SceneからSceneを作成しましょう。
今回はScene間の遷移はしない想定のため、ThereminSimulatorと言う名前のSceneを1つ作成しました。

続いて、ハンドトラッキングができるよう、ProjectのAssets > Oculus > OculusProjectConfigを選択し、GeneralのHand Tracking SupportをHands Onlyに設定します。

Oculusストアでアプリを公開したい場合は、Controllers and Handsに設定する必要があるようです。
Hand Tracking Versionはデフォルトでも良いですが、V2に設定しておきます。

次に、メインプレイヤーになるOVRCameraRigと、ハンドトラッキング結果を表示するためのOVRHandPrefabを以下の様に配置します。

この際、デフォルトで配置されているMain Cameraは削除しましょう。

右手用にRightHandAnchorの子として配置したOVRHandPrefabのInspectorを見ると、以下の3か所に手の左右を選択する部分があるため、全てHand Rightに変更します。

  • OVR HandのHand Type
  • OVR SkeletonのSkeleton Type
  • OVR MeshのMesh Type

デフォルト値がHand Leftであるため、左手用のOVRHandPrefabは変更不要です。

ここまでで、ハンドトラッキングの初期設定は完了です。
ビルドして動かしてみるとこの通り、フレミングの左手の法則も、右ネジの法則もばっちり表現できます。

想像以上にトラッキングの精度が高くて驚きましたが、手を握るなどするとトラッキングできなくなるため、使いどころには注意が必要かもしれません。
(急に自分の手が消えたので、結構焦りました)

あとはテルミンや床を置いたり、位置やサイズの調整をしましょう。こんな感じになりました。

Quest2で見るとこんな感じです。(最初テルミンのサイズを間違えてとんでもないサイズのテルミンに潰されかけました)

ちなみにテルミンの3Dモデルは自作です。本体とアンテナを当社のカラーで作っています。

実装する

実装の流れ

さて、テルミンを実装していくにあたり、以下が必要になります。

  1. トラッキングした手の各部位の位置情報の取得
  2. ↑により決定される発振回路の発振周波数と音程・音量の計算
  3. ↑により決定された音の出力

では、上から順にやっていきましょう。

トラッキングした手の各部位の位置情報の取得

まず、手の各部位の位置情報ですが、OVRHandPrefabにアタッチされているOVRSkeletonクラスのBonesに格納されています。

手の各部位にボーンが割り当てられており、そのTransformを取得することが出来ます。こちらの記事にボーンと部位の対応図が載せられているので、ご参考ください。

手のひらのボーンは無いため、小指と親指は0番目、それ以外の指は1番目のボーン位置の中心を手のひらの位置として扱うことにしましょう。指の位置は各指の3番目のボーン位置とします。

以下のメソッドに位置を取得したい手のOVRSkeletonを渡せば、各部位のpositionの配列が返されます。

private Vector3[] getHandPositions(OVRSkeleton skeleton)
{
if (!(_leftHand.IsTracked && _rightHand.IsTracked))
{
return new Vector3[] { };
}
Vector3 palmPosition = (
skeleton.Bones[(int)OVRSkeleton.BoneId.Hand_Thumb0].Transform.position +
skeleton.Bones[(int)OVRSkeleton.BoneId.Hand_Index1].Transform.position +
skeleton.Bones[(int)OVRSkeleton.BoneId.Hand_Middle1].Transform.position +
skeleton.Bones[(int)OVRSkeleton.BoneId.Hand_Ring1].Transform.position +
skeleton.Bones[(int)OVRSkeleton.BoneId.Hand_Pinky0].Transform.position
) / 5;
return new Vector3[]{
skeleton.Bones[(int) OVRSkeleton.BoneId.Hand_Thumb3].Transform.position,
skeleton.Bones[(int) OVRSkeleton.BoneId.Hand_Index3].Transform.position,
skeleton.Bones[(int) OVRSkeleton.BoneId.Hand_Middle3].Transform.position,
skeleton.Bones[(int) OVRSkeleton.BoneId.Hand_Ring3].Transform.position,
skeleton.Bones[(int) OVRSkeleton.BoneId.Hand_Pinky3].Transform.position,
palmPosition
};
}

手がトラッキングされていない場合は、空の配列を返却するようにしておきました。

発振回路の発振周波数と音程・音量の計算

次に、テルミンから出力される音の音程・音量を計算します。
簡単のため、以下の制約を設けます。

  • 音程コントロールは右手、音量コントロールは左手で行い、反対側の手は考慮しない。
  • 音程コントロールはX-Z平面(水平面)内での距離、音量コントロールはY軸(鉛直)方向の距離のみを考慮する。
    併せて、計算に用いる定数を定義しておきましょう。
    ここのパラメータにより、テルミンの特性が決まります。
    private const float PITCH_INDUCTANCE = 10.0e-6f; //音程コントロール側に使用するコイルのインダクタンス
    private const float PITCH_CAPACITANCE = 1.0e-9f; //音程コントロール側に使用するコンデンサの静電容量
    private const float VOLUME_INDUCTANCE = 1.0e-4f; //音量コントロール側に使用するコイルのインダクタンス
    private const float VOLUME_CAPACITANCE = 2.0e-11f; //音量コントロール側に使用するコンデンサの静電容量
    private const float VOLUME_RESISTANCE = 1.0e1f; //音量コントロール側に使用する抵抗器の抵抗
    private const float PERMITTIVITY = 8.854e-12f; //真空の誘電率(真空中で演奏することにします)
    private readonly float[] HAND_AREAS = { //手の各部位の面積
    11.0e-4f,
    15.0e-4f,
    17.0e-4f,
    15.0e-4f,
    13.0e-4f,
    80.0e-4f
    };
    音程・音量どちらにしても発振回路の発振周波数を用いるため、まずは発振周波数の計算に用いるメソッドを定義します。
    数式が共通なので、バンドパスフィルターの共振周波数もこのメソッドで計算します。
    private float getOscillatingFrequency(float inductance, float capacitance)
    {
    return 1 / (2 * Mathf.PI * Mathf.Sqrt(inductance * capacitance));
    }

あとは上記仮定を元に、数式に基づいて音量・音程を求めるだけです。

private float getPitch()
{
Vector3[] handPositions = getHandPositions(_rightSkeleton);
Vector3 antennaPosition = _pitchAntenna.position;
float capacitance = 0;
for (int i = 0; i < handPositions.Length; i++)
{
handPositions[i].y = antennaPosition.y;
float dist = Vector3.Distance(handPositions[i], antennaPosition);
capacitance += HAND_AREAS[i] / dist;
}
capacitance *= PERMITTIVITY;
capacitance += PITCH_CAPACITANCE;
float fix_frequency = getOscillatingFrequency(PITCH_INDUCTANCE, PITCH_CAPACITANCE);
return Mathf.Abs(fix_frequency - getOscillatingFrequency(PITCH_INDUCTANCE, capacitance));
}

private float getVolume()
{
Vector3[] handPositions = getHandPositions(_leftSkeleton);
Vector3 antennaPosition = _volumeAntenna.position;
float capacitance = 0;
for (int i = 0; i < handPositions.Length; i++)
{
float dist = Mathf.Abs(handPositions[i].y - antennaPosition.y);
capacitance += HAND_AREAS[i] / dist;
}
capacitance *= PERMITTIVITY;
capacitance += VOLUME_CAPACITANCE;
float gainFrequency = getOscillatingFrequency(VOLUME_INDUCTANCE, capacitance);
float gain =
1 /
Mathf.Sqrt(
1 +
Mathf.Pow(
1 / (2 * Mathf.PI * gainFrequency * VOLUME_RESISTANCE * VOLUME_CAPACITANCE) -
2 * Mathf.PI * gainFrequency * VOLUME_INDUCTANCE / VOLUME_RESISTANCE,
2
)
)
;
return gain;
}

なお、音量コントロール側も音程コントロール側も、手の位置で決まる静電容量に、固定の静電容量を加えています。
手をかざしていない間に変な音が出ないように固定するためです。

あとはこれらをUpdate()から呼び出して、1フレームごとに周波数と音量を計算しましょう。
アンプを通した気持ちになって、ボリュームは3倍した上で一定幅内(0.1から1)で収まるようにしています。
なお、周波数とボリュームはメンバ変数としてあらかじめ宣言しておきます。

private float frequency;
private float gain;
void Update()
{
frequency = getPitch();
string scale = getScale(frequency);
gain = Mathf.Min(getVolume() * 3, 1);
if (gain < 0.1f)
{
gain = 0;
}
}

音の出力

それでは実際に音を鳴らしていきましょう。

まずはテルミンのオブジェクトにAudio Sourceのコンポーネントを付与し、音を鳴らせるようにしておきます。すると、音を鳴らす前に(AudioClipをセットしていなくても)OnAudioFilterRead()が呼び出されるので、ここで直接音の情報を書き込んでしまいます。本来はフィルターをかけたりすることが目的のメソッドですが、音情報を書き込むこともできます。

前段で音程・音量の情報をメンバ変数に記録しているため、あとは正弦波の音情報を書き込むのみです。

サンプリング周波数はデバイスに依存するため、Start()内で取得しておきましょう。サンプリング周期ごとに位相と振幅を計算し、書き込んでいます。ステレオ音源にする意味は無いので、両チャンネルに同じ値を書き込んでいます。

void Start()
{
sampling_frequency = AudioSettings.outputSampleRate;
}
void OnAudioFilterRead(float[] data, int channels)
{
increment = frequency * 2 * Mathf.PI / sampling_frequency;

for (var i = 0; i < data.Length; i = i + channels)
{
phase = phase + increment;
data[i] = (gain * Mathf.Sin(phase));
if (channels == 2) data[i + 1] = data[i];
if (phase > 2 * Mathf.PI) phase = 0;
}
}

これで音が鳴るようになりました。

演奏してみる

ではビルドして、演奏してみます。

Quest2とPCを接続し、Build SettingsからPlatformをAndroidにし、作業していたSceneが追加されていることを確認してBuild And Runします。

image.png

しばらく待つと、Quest2側で起動しているので、試しに演奏してみました。↓に動画を配置しているのでご覧ください。
※音量注意かもしれません。

https://github.com/shomuMatch/thereminSimulator

想像の80倍ぐらい難しくて、ロクに演奏できませんでした。きらきら星を演奏しているつもりです。

周波数が連続的に変化していくので、半端な音が出がちなのと、どれぐらい動かせばどの音階になるのかが全くつかめず…低音域だと大きく手を動かさないといけないのに、高音域だと少し動かしただけで一気に周波数が変わるため、脳が混乱し続けていました。

ちなみに実際のテルミンを演奏したことは無いので、実物もこれぐらい難しいのかはよくわかりません。

ちなみに、後ろに今の音の周波数と音階、ボリュームも出るようにしておきました。
それを見ながらならもう少しマシに演奏出来る気もするのですが、動画としてどうなんだという気がしたのでやめておきました。
(Quest2の録画機能だと、プレイヤーの視点がそのまま録画対象になるため)

おわりに

テルミン、とても難しい。

というのはさておき、Quest2のハンドトラッキング、とても簡単かつ高精度に手の動きを入力することができて、非常に可能性を感じました。

今回は一度演奏してみたかったテルミンを作ってみましたが、なんだかもっといろんなことができそうです。

VRにおける最も大切なものは、没入感であると個人的に思っています。

自身の体をそのまま入力装置として扱えるハンドトラッキングは、今後のVRの発展に不可欠なのでは、と思った自由研究でした。

夏の自由研究ブログ連載2022 明日もお楽しみに!

補足

詳細原理

さて、後回しにしたテルミンの詳細原理とその数式化について記載します。

テルミンに限らず、電子回路にはコイル・コンデンサ・抵抗の3つの素子が良く使われます。

この内、コンデンサというのは少し特殊なつくりをしていて、2つの導体(電気を通す物体)で絶縁体(電気を通しにくい物体)を挟み込むような形をしています。導体は電気を通すが、絶縁体は電気を通しにくいので、コンデンサに電圧をかけると導体の絶縁体付近に電気がたまるという仕組みです。導体の例としては、鉄や、人体などが導体です。絶縁体はゴムや、空気などです。

勘の良い方は気づかれたかもしれませんが、例えば鉄板から少し離れて手のひらをかざすと、これだけでコンデンサができます。(絶縁体は間の空気)また、コンデンサに使われる導体を、極板と呼びます。

この鉄(アンテナ)・空気・人体で構成されたコンデンサを電子回路に取り込んだ楽器がテルミンなのです。

2つの発振回路で音程コントロール

さて、テルミンには上記の3つの素子を用いた、発振回路と呼ばれるものが音程コントロールのために2つ組み込まれています。発振回路と言うのは、発振周波数と呼ばれる周波数の正弦波の電気信号を作り出す電子回路のことです。

発振回路の種類にもよりますが、例えばハートレー発振回路と呼ばれるものだと、発振周波数はコイルの特性値であるインダクタンス(L)とコンデンサの特性値である静電容量(C)を用いて以下の数式で求められます。

一方の発振回路には演奏者を取り込まず、固定の周波数で発振する(電気信号を作る)ようにしておき、もう一方の発振回路には演奏者をコンデンサの片方の極板として取り込みます。

すると、演奏者の操作によりコンデンサの静電容量が変動し、片方の発振回路の発振周波数が変動します。この2つの発振回路の発振周波数の差を電気信号として出力、音に変換する(これはスピーカーの役割)ことで、音楽を演奏することができます。

ここまでをまとめると、最終的にテルミンから出る音の周波数(≒音程)は以下となります。




静電容量は演奏者の挙動(これはさらに時間に依存)に依存することを明記するため、時間を変数として記載しています。

もう1つの発振回路と共振回路で音量コントロール

テルミンにはもう1つ、演奏者をコンデンサとして取り込んだ発振回路が含まれており、こちらは音量をコントロールするためのものです。
また、音量コントロール用の回路には発振回路に加え、共振回路(バンドパスフィルタ)と呼ばれる、特定の周波数帯の電気信号を通す回路が組み込まれています。
共振回路に周波数の電気信号を流すと、以下の式に従って、その信号は減衰します。

上記より、入力の周波数が以下の(共振周波数)であるとき、出力は最大となります。

つまり、演奏者の操作により、発振回路の発振周波数が共振回路の共振周波数に近づけば出力は大きく、遠ざかれば小さくなります。
この出力を、最終的な音量として用いることで、テルミン自体の音量を操作します。

演奏者による静電容量の変化

さて、ここまでで、演奏者による静電容量の変化に応じて音量・音程が操作できることを見てきました。
では、はどのように変化するのでしょう。

これを厳密に求めることは実質的に不可能であるため、演奏者を含む発振回路のコンデンサは、以下の図のような構成であるとモデル化してみましょう。

要するに、演奏者の手のひらから先だけを演奏者側の極板として扱い、テルミンのアンテナは演奏者に合わせて複数の極板に分割されているとします。

こうしてできた6つのコンデンサが並列に接続されることで、発振回路のコンデンサが構成されていると考えましょう。

さて、誘電率(絶縁体の特性) の絶縁体を距離離れた面積 の2枚の極板で挟んだ場合、静電容量は以下となります。

また、コンデンサを複数個並列につないだ場合、トータルの静電容量は以下となります。

以上より、今回考えるモデルにおいて、は以下となります。

演奏者が指や手のひらの位置をテルミン側の極板に近づけたり離したりすることで、静電容量が変化することが分かります。
もテルミン側の極板と指や手のひらの角度に応じて変化させればもうちょっと正確になりますが、ここでは割愛します。

テルミンから出る音

以上より、テルミンから出る音の波形は以下の数式で表されます。


上記のように仮定・導出した各数式をシミュレータでは使っていました。
先にこちらの項を読まれた方は、作ってみるまでお戻りください。

補足も含めて、本記事は以上です。