はじめに
概要
本記事では、Unityを用いたMeta Quest2用のVRコンテンツにおいて、ハンドトラッキングで非貫通の手を用いてオブジェクトを叩くことについて記載したいと思います。
なお、非貫通化については、下記記事について記載しているので、そちらを実装していることを前提としています。
本記事では手でタンバリンを叩くことを説明しますが、同じ方法で他のオブジェクト同士の衝突でも応用できます。
本記事のゴール
ハンドでオブジェクトを叩いた際に、叩く強さに応じて音量を変えたり、エフェクトを変えたりできるようになります。
下記では、タンバリンを叩く強さにより、音量を変えています。
叩く強さにより音量が変わるサンプル pic.twitter.com/4AzxTfzbIS
— コヒロト (@Cohiroto) September 17, 2023
動作環境
本記事内容を作成したのは下記環境です。
- Unity 2021.3.25f1
- Oculus Integration 55.0
仕組み
相対速度
今回は、叩く際の強さについては、物体間の相対速度から取得します。
2つの物体間の衝突を考える際、それぞれの物体の速度の差を求めることで相対速度を求めることができます。
今回はその大きさが知る方法として、Rayを用います。
オブジェクト毎の過去数フレームの平均位置から相対速度を求めることもしたのですが、精度が良くなかったのと、タンバリンの場合は手を動かしてタンバリンに衝突した場合と、タンバリンを動かして手に衝突した場合の両方で音を鳴らしたかったので、Rayを用いました。
Rayを用いた相対速度
今回は、Rayは手の方につけます。
手の中心から、手のひら、手の甲、および手の横方向にRayを飛ばします。
タンバリンを叩くことを前提としているのでこれくらいでよいと思いますが、手の指先で何かを叩きたいなどの場合は、Rayを増やしたり位置を変えたりしても良いです。
手をタンバリンの近くに持っていくと、Rayがタンバリンに当たるので、そこから手とタンバリンの位置見て、フレーム間での距離の変化(相対速度)をバッファリングしていきます。
そして、手とタンバリンが衝突したタイミングでバッファリングしていた速度の平均を求めます。
その大きさが一定の閾値以上ならば、その速度の大きさに応じて音を鳴らします。
作成方法
1. タンバリンの準備
タンバリンのモデルを以下に配置し、左手で持っているようにPosition、Rotation、Scaleを調整する。
OVRHandsSlave/LeftHand/HandVisualLeft/OVRLeftHandVisual/OculusHand_L/l_handMeshNode
タンバリンのモデルにはRigidbodyとColliderをつけておいてください。なお、左手のコライダーと干渉しないようにレイヤーを設定しておいてください。
2. タンバリンのオブジェクトにAudio Sourceコンポーネント追加
タンバリンのオブジェクトにAudio Sourceコンポーネントをアタッチする。
AudioClipにタンバリンを叩いたときの音を設定する。
Play On Awakeのチェックを外す。
Spatial Blendを1(3D)に設定する。
その他必要に応じて設定する。
3. タンバリンのオブジェクトに、BeatObjectコンポーネント追加
タンバリンのオブジェクトに、下記Beat Objectを追加する。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BeatObject : MonoBehaviour
{
public AudioSource SE;
public float velocityThread = 0.3f;
public float maxVolumeVelocity = 2.0f;
public float volumeOffset = 0.5f;
[NonSerialized] public float collisionVelocityMagnitude;
[NonSerialized] public float audioVolume;
public event Action BeatEvent;
public AudioSource objSE
{
get
{
return SE;
}
}
public void AdjustAudioVolume()
{
float volume = collisionVelocityMagnitude / maxVolumeVelocity + volumeOffset;
if (volume < 0)
{
volume = 0;
}
else if (volume > 1)
{
volume = 1;
}
audioVolume = volume;
}
}
SEにタンバリンオブジェクトのAudio Sourceを設定
Velocity Thread(音を鳴らす最低相対速度)を調整(例では0.2)
Max Volume Velocity(最大相対速度(これ以上の速度で衝突しても音が大きくならない))を調整(例では1.5)
Volume Offet(最低の音量(0.0~1.0))を調整(例では0.1)
4. 右手にRayを追加
下記にray_r_1~ray_r_4を追加する。
OVRHandsSlave/RightHand/HandVisualRight/OVRRightHandVisual/OculusHand_R/r_handMeshNode
ray_r_1~ray_r_4のTransformコンポーネントのPositionとRotationを下記のように設定する。
ray_r_1~ray_r_4に下記Hand Rayコンポーネントをアタッチする。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class HandRay : MonoBehaviour
{
public Transform hitObjectTrans { get; private set; }
public float oldDistance { get; private set; }
private float maxRayLength = 0.2f;
private int bufferNumber = 5;
private float[] velocityBuffer;
private int bufferIndex = 0;
private bool isHit = false;
// Start is called before the first frame update
void Start()
{
ResetBuffer();
hitObjectTrans = null;
}
// Update is called once per frame
void Update()
{
// UpdateでRayを飛ばし、そのRayが当たったオブジェクトとの距離を測り、
// その距離の変化量(=相対速度)を計算してリングバッファに格納する
// Rayを前方にmaxRayLengthの長さ飛ばす
RaycastHit hit;
// 視覚的にRayを表示する
Debug.DrawRay(transform.position, transform.forward * maxRayLength, Color.red, 0.1f);
// Rayが何かに当たった場合
if(Physics.Raycast(transform.position, transform.forward, out hit, maxRayLength))
{
// Rayが初めてオブジェクトに当たった
if(hit.transform != hitObjectTrans)
{
oldDistance = hit.distance;
hitObjectTrans = hit.transform;
isHit = true;
}
// ずっと同じオブジェクトにRayが当たっている
else
{
// 相対速度を計算し、バッファに格納
float velocity = (oldDistance - hit.distance) / Time.deltaTime;
velocityBuffer[bufferIndex] = velocity;
bufferIndex = (bufferIndex + 1) % bufferNumber;
oldDistance = hit.distance;
}
}
// Rayが当たっていない場合
else
{
// Rayがこれまで当たっていたオブジェクトから外れた場合
if (isHit)
{
// バッファをリセット
ResetBuffer();
}
}
}
// バッファのリセット
private void ResetBuffer()
{
velocityBuffer = new float[bufferNumber];
bufferIndex = 0;
hitObjectTrans = null;
isHit = false;
}
// 平均の相対速度を取得
public float GetAverageVelocity()
{
// バッファに格納されている速度の平均を計算する
float averageVelocity = 0;
foreach(float v in velocityBuffer)
{
averageVelocity += v;
}
averageVelocity /= bufferNumber;
return averageVelocity;
}
}
5. 右手のオブジェクトにRaysManagerコンポーネント追加
下記右手のオブジェクトに、下記Rays Managerを追加する。
OVRHandsSlave/RightHand/HandVisualRight/OVRRightHandVisual/OculusHand_R
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RaysManager : MonoBehaviour
{
[SerializeField] private HandRay[] handRays;
// オブジェクトに触れているRayのうち、最も短いものの相対速度を取得する
public float GetMinAverageVelocity(string name)
{
float minDistance = 1;
float averageVelocity = 0;
HandRay minDistanceHandRay = null;
foreach(HandRay handRay in handRays)
{
if(handRay.hitObjectTrans == null)
{
continue;
}
// Rayが指定のオブジェクトに触れている場合
if(handRay.hitObjectTrans.name == name)
{
// 最短距離のRayを取得
float distance = handRay.oldDistance;
if(distance < minDistance)
{
minDistance = distance;
minDistanceHandRay = handRay;
}
}
}
// Rayを取得した場合
if (minDistanceHandRay != null)
{
// そのRayの平均距離を取得
averageVelocity = minDistanceHandRay.GetAverageVelocity();
}
return averageVelocity;
}
}
Hand Raysを4つにし、それぞれray_r_1~ray_r_4のオブジェクトを設定する。
6. 右手のオブジェクトにBeatコンポーネント追加
下記右手のオブジェクトに、下記Beatを追加する。
OVRHandsSlave/RightHand/HandVisualRight/OVRRightHandVisual/OculusHand_R
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Beat : MonoBehaviour
{
[SerializeField] private float timeThread = 0.1f;
[SerializeField] private RaysManager raysManager;
private float velocityThread = 0.6f;
private AudioSource audioSource;
private float lastCollisionTime = 0;
private float maxVelocityMagnitude;
private string collisionName;
private BeatObject beatObject;
private void Start()
{
// Beatオブジェクトリセット
ResetCollisionObj();
}
// Beatオブジェクトに触れた場合
private void OnCollisionEnter(Collision collision)
{
// 前回衝突からtimeThread以上経過した場合のみ処理
if (Time.time - lastCollisionTime < timeThread)
{
return;
}
// 衝突した物のBeatObjectを取得
beatObject = collision.transform.GetComponent<BeatObject>();
if (beatObject == null)
{
// 音が鳴らないオブジェクトとの衝突
// 処理しない
return;
}
// 衝突したオブジェクトの情報取得
velocityThread = beatObject.velocityThread;
collisionName = beatObject.name;
// オブジェクトとの衝突前の速度取得
maxVelocityMagnitude = raysManager.GetMinAverageVelocity(collisionName);
beatObject.collisionVelocityMagnitude = maxVelocityMagnitude;
// 相対速度の大きさが閾値以上の場合
// ゆっくり触ったときに音が鳴るのを防ぐ
if (maxVelocityMagnitude >= velocityThread)
{
// オブジェクトを叩いた際の音を鳴らす
// 音量調整
beatObject.AdjustAudioVolume();
// AudioClipに設定
audioSource = beatObject.SE;
audioSource.volume = beatObject.audioVolume;
audioSource.clip = beatObject.objSE.clip;
// 音を鳴らす
audioSource.Play();
// 叩いた時間を取得
lastCollisionTime = Time.time;
// Beatオブジェクトの情報リセット
ResetCollisionObj();
}
}
// Beatオブジェクトの情報リセット
private void ResetCollisionObj()
{
maxVelocityMagnitude = 0;
collisionName = string.Empty;
}
}
Time Thread(すぐに音が鳴るのを防ぐためのしきい値)を設定(例では0.1)
Beatに手順5で作成したRaysManagerコンポーネントを設定
おわりに
以上で、手でオブジェクトを叩いた際に、速度に応じて音の大きさを変化させることができます。
音を鳴らすところに別な処理を追記することで、例えば叩いた強さに応じてエフェクトを変えるなどもできるようになります。