【開発ブログ】VR空間で写真撮影できるカメラを作ってみた
はじめに
XRエンジニアのKENTOです。
昨今のVR SNS / メタバース系のサービスにおいて、
写真撮影用カメラを見かけることが増えてきました。
では実際に作ってみるとどのような実装手法になるのか、
というのを調査したので、本記事でご紹介します。
まず、必要な要素を分解して考えます。
( ※前提として対象のプラットフォームをOculus Quest2とします。)
以下が分解した要素です。それぞれを深掘りする形で話を進めていきます。
OpenXR × XR Interaction ToolKitによるプロジェクト作成
写真撮影機能の実装
VR空間で操作可能なカメラの実装
OpenXR × XR Interaction ToolKitによるプロジェクト作成
OpenXR × XR Interaction ToolKitで動作するプロジェクトを作成します。
以下のバージョンで作成しました。
まず、新規Unityプロジェクトを作成し、
XR Interaction ToolKitをインポートします。
その際、Starter AssetsとXR Device SimulatorもImportしておきます。
次に、XR Plug-in Managementを取得し、OpenXRにチェックを入れます。
この時、Project Validationのissueが0になるように
全てのissueを解決しておくとビルドまでスムーズです。
OpenXRの設定は以下のように変更します。
XR Interaction SetupというPrefabがあるので、シーンに配置します。
これにより、基本的なXRのインタラクション操作が可能となります。
今回はUI操作を行いたいので、
対象のCanvasにTracked Device Graphic Raycasterを追加します。
ここまでの設定が正しく行われていれば、
Oculus Quest向けにビルドして動作確認が可能となります。
インポートしたXR Device Simulatorというデバッグツールを利用してUnityEditor上で動作確認を行うことも可能です。
導入後は以下設定画面からSimulatorのアクティブ設定を行えます。
注意点として、この設定をオンにしてビルドすると
カメラの描画結果が画面に張り付いてくる状態となります。
ですので、ビルド時はオフにしておく必要があります。
写真撮影機能の実装
写真撮影機能の実装については以下のフローで実現可能です。
撮影用カメラを用意
RenderTextureに撮影用カメラの映像を書き込む
RenderTextureに書き込まれた描画結果をTexture2Dに変換
Texture2Dをバイナリに変換
バイナリを画像形式にエンコード
エンコード結果をファイルとして保存する
上記フローをコードに落とし込んだものが以下です。
using System;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
public class TakePhotoTest : MonoBehaviour
{
[SerializeField] RenderTexture _renderTexture;
[SerializeField] Button _readPixelsButton;
void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
_readPixelsButton.onClick.AddListener(() => TakePhotoByReadPixelsAsync(ct).Forget());
}
void OnDestroy()
{
_readPixelsButton.onClick.RemoveAllListeners();
}
async UniTask TakePhotoByReadPixelsAsync(CancellationToken ct)
{
await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate, ct);
var texture2D = new Texture2D(_renderTexture.width, _renderTexture.height, TextureFormat.RGBA32, false);
RenderTexture.active = _renderTexture;
texture2D.ReadPixels(new Rect(0, 0, _renderTexture.width, _renderTexture.height), 0, 0);
texture2D.Apply();
var bytes = texture2D.EncodeToPNG();
Destroy(texture2D);
var dateTime = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var path = $"{Application.persistentDataPath}/RenderTexture_{dateTime}.png";
File.WriteAllBytes(path, bytes);
Debug.Log($"Save a photo to {path}");
}
}
このコードを実行した際、以下サンプルのように概ね問題なく動作するのですが、ReadPixelsの処理が重いことが起因して一瞬フレームレートが著しく低下します。
ReadPixelsは対象のTexture2Dのピクセルを全て読み取る処理のため、
画像の解像度に比例して大きなスパイクが起こります。
このフレームレート低下の解決策として、
AsyncGPUReadbackを使用しました。
AsyncGPUReadbackを利用することにより、
ピクセルの情報読み取りを非同期で実行可能です。
注意点として、Unityのバージョンによっては
Oculus Questでの動作をサポートしていないことです。
利用可能となったバージョンの境目については未検証ですが、
2020.3.48f1で検証した場合には以下のエラーログが実機で出ました。
今回は2022.3.5f1でAsyncGPUReadbackを動かし、
正常に動作することが確認できました。そのコードが以下のコードです。
より処理負荷を軽減させる目的で、一部処理を別スレッドに逃しています。
using System;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
public class TakePhotoAsyncTest : MonoBehaviour
{
[SerializeField] RenderTexture _renderTexture;
[SerializeField] Button _asyncGPUReadbackButton;
void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
_asyncGPUReadbackButton.onClick.AddListener(() => TakePhotoByAsyncGPUReadbackAsync(ct).Forget());
}
void OnDestroy()
{
_asyncGPUReadbackButton.onClick.RemoveAllListeners();
}
async UniTask TakePhotoByAsyncGPUReadbackAsync(CancellationToken ct)
{
await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate, ct);
var textureFormat = TextureFormat.RGBA32;
// GPU上にあるピクセル情報を取得する
var request = await AsyncGPUReadback.Request(_renderTexture, 0, textureFormat);
ct.ThrowIfCancellationRequested();
if (request.hasError)
{
Debug.LogError("Error.");
}
else
{
var data = request.GetData<Color32>();
var texture2D = new Texture2D(_renderTexture.width, _renderTexture.height, textureFormat, false);
texture2D.SetPixels32(data.ToArray());
texture2D.Apply();
// 例外発生時はメインスレッドに戻す
ct.Register(() =>
{
UniTask.ReturnToMainThread();
});
var rawByteArray = texture2D.GetRawTextureData<byte>();
var graphicsFormat = texture2D.graphicsFormat;
var width = texture2D.width;
var height = texture2D.height;
Destroy(texture2D);
var dateTime = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var path = $"{Application.persistentDataPath}/RenderTexture_{dateTime}.png";
await UniTask.SwitchToThreadPool();
ct.ThrowIfCancellationRequested();
var bytes = ImageConversion.EncodeNativeArrayToPNG(
rawByteArray,
graphicsFormat,
(uint)width,
(uint)height);
await File.WriteAllBytesAsync(path, bytes.ToArray(), ct);
await UniTask.SwitchToMainThread();
ct.ThrowIfCancellationRequested();
Debug.Log($"Save a photo to {path}");
}
}
}
フレームレートへの影響を比較したGIFが以下です。
ReadPixelsは1度の呼び出しでフレームレートが70から60程度まで下がっているのに対し、AsyncGPUReadbackによる処理はほとんど変わっていません。
複数回の呼び出しはより顕著に差が出ています。
AsyncGPUReadbackと別スレッド実行により、
負荷を軽減することができました。
VR空間で操作可能なカメラの実装
次にVR空間で操作可能なカメラを作成していきます。
掴んだオブジェクトをコントローラーの子階層に配置するだけでも
それっぽい動きにはなります。
ただ、せっかくVR空間で操作可能なカメラなので、
今回はレーザーポインターで遠隔の操作も可能なカメラを作っていきます
まずは成果物です。
以下のように、Rayの当たった箇所を起点に回転、移動が可能です。
以下がコード全文です。
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.XR.Interaction.Toolkit;
public class ControlPhotoCameraTransform : MonoBehaviour
{
[SerializeField] XRRayInteractor _interactor;
[SerializeField] Transform _photoCameraTransform;
[SerializeField] InputActionReference _gripActionReference;
const float FollowSpeed = 0.3f;
bool _isGrip;
/// <summary>
/// Rayの原点
/// </summary>
Transform _rayOrigin;
/// <summary>
/// 移動開始時における、UIとRayとの距離。
/// </summary>
float _distance;
/// <summary>
/// 移動開始時における、Rayの原点の回転。
/// </summary>
Quaternion _beginRayOriginRotation;
/// <summary>
/// Grip時のオフセットとして扱う座標。
/// </summary>
Vector3 _offsetPosition;
/// <summary>
/// 移動開始時における、UIの回転。
/// </summary>
Quaternion _beginRootRotation;
void Start()
{
_rayOrigin = _interactor.rayOriginTransform;
_gripActionReference.action.performed += _ => OnGrip();
_gripActionReference.action.canceled += _ => OnEndGrip();
_gripActionReference.action.Enable();
}
void Update()
{
if(!_isGrip) return;
UpdatePosition();
UpdateRotation();
}
void OnGrip()
{
_isGrip = true;
if (_interactor.TryGetCurrentUIRaycastResult(out RaycastResult result))
{
// 移動開始時の距離を設定する
_distance = Vector3.Distance(_rayOrigin.position, result.worldPosition);
}
var rayEndPosition = _rayOrigin.position + _rayOrigin.forward * _distance;
_offsetPosition = _photoCameraTransform.position - rayEndPosition;
_beginRayOriginRotation = _rayOrigin.rotation;
_beginRootRotation = _photoCameraTransform.rotation;
}
void OnEndGrip()
{
_isGrip = false;
}
/// <summary>
/// 座標の値を更新する。
/// </summary>
void UpdatePosition()
{
// Rayの終点を計算。
var rayEndPosition = _rayOrigin.position + _rayOrigin.forward * _distance;
// 現在のRayの原点の回転と掴み開始時の回転との差分を計算。
var diffRayOriginRotation = _rayOrigin.rotation * Quaternion.Inverse(_beginRayOriginRotation);
// 掴み開始時のオフセットを回転させる。回転によってオフセットが変わるため。この処理によって回転軸が変わる。
var offsetApplyRotate = diffRayOriginRotation * _offsetPosition;
// Rayの終点にオフセットを足して、UIの位置を計算。
var targetPosition = offsetApplyRotate + rayEndPosition;
_photoCameraTransform.position =
Vector3.Lerp(_photoCameraTransform.position, targetPosition, FollowSpeed);
}
/// <summary>
/// 回転の値を更新する。
/// </summary>
void UpdateRotation()
{
// 現在のRayの原点の回転と掴み開始時の回転との差分を計算。
var diffRayOriginRotation = _rayOrigin.rotation * Quaternion.Inverse(_beginRayOriginRotation);
// 最終的に反映させたい回転を計算。
var targetRotation = diffRayOriginRotation * _beginRootRotation;
_photoCameraTransform.rotation = Quaternion.Lerp(_photoCameraTransform.rotation, targetRotation, FollowSpeed);
}
}
Unityの回転処理にはRotateAroundというものが存在しており、
回転軸を設定した上での回転が可能です。
ただ、今回の処理においては毎フレーム移動と回転を行う必要があり、任意の数値分回転させるRotateAroundはやや使いづらいという問題がありました。
そこで、Rayの衝突箇所が回転の軸となるように回転分の移動量を事前計算して座標の移動処理に適用しておくコードを書いて対応しました。
/// <summary>
/// 座標の値を更新する。
/// </summary>
void UpdatePosition()
{
// Rayの終点を計算。
var rayEndPosition = _rayOrigin.position + _rayOrigin.forward * _distance;
// 現在のRayの原点の回転と掴み開始時の回転との差分を計算。
var diffRayOriginRotation = _rayOrigin.rotation * Quaternion.Inverse(_beginRayOriginRotation);
// 掴み開始時のオフセットを回転させる。回転によってオフセットが変わるため。この処理によって回転軸が変わる。
var offsetApplyRotate = diffRayOriginRotation * _offsetPosition;
// Rayの終点にオフセットを足して、UIの位置を計算。
var targetPosition = offsetApplyRotate + rayEndPosition;
_photoCameraTransform.position =
Vector3.Lerp(_photoCameraTransform.position, targetPosition, FollowSpeed);
}
まとめ
簡単に実装できると思っていましたが、
実装してみると意外な発見が多く、勉強になりました。
まだまだ考慮不足な点はあるので、
引き続き検証を重ねていこうと思います。