UIでオブジェクトの位置と角度を微調整し,保存したい状態になったら空間アンカーを保存する.
更に,保存した空間アンカーを読み込んで保存時の位置と角度を復元する.
後述するマーカー式位置合わせの方法が見つけられなかった私にChatGPTさんが

ChatGPTさん
UIで動的にオブジェクトの位置と角度を微調整して位置合わせすればいい,ジリ貧だけど確実
とご助言をくださったため,その方法で実装したときの記録.※アイコンはChatGPT4oさんに「ChatGPT風の抽象AIアイコンを、緑と白でお願いします」と言って生成してもらったものです.
空間アンカー関連は非同期処理や自動紐づけが多く,ところどころ「待つ」や「無効化する」処理を挟まないと思うような挙動をしなかったので高難易度.
UI作成
MetaQuest3でVRデモ 4. オブジェクトを初期位置にリスポーンするUI作成と同様の方法で,以下のレイアウトのUIを作成した.まだボタンや入力欄には処理を入れていない.あと座標系の3Dモデルは自作で,これもOVRCameraRig > TrackingSpace > CenterEyeAnchorの子になる位置に配置.


また,Sceneを有効化する必要がある.MetaQuest3でMRデモ 0. 予備知識と環境構築を参照.
Input Fieldを有効にする
InputFieldは最も上にある階層のオブジェクト(上図ではInputField_meterとInputField_degree)のInspector > TextMeshPro – Input Field内のTextに初期値を入力する.子のTextAreaやText(TMP)のTextは空白でいい.
また,余計な型変換とかにひっかかると面倒なのと,バーチャルキーボードがテンキーになるので,その下のInput Field Settings内のContent Typeを「Decimal Number」にする.
また,バーチャルキーボードを有効にするため,Camera RigのInspectorを開き,OVR Managerを展開し,Quest Features > General内のRequires System Keybordにチェックを入れる.
上記の処理をすれば,Input FieldをRayやPokeで押すとバーチャルテンキーが表示され数値が入力出来るようになる.
C#でスクリプト作成
UIで設定したボタンに追加するイベントをスクリプトで作成し,空のオブジェクトに追加し,[SerializeField]に対応したオブジェクトを入れる.
オブジェクトの位置情報やUIの座標値等の表記を更新するための処理をまとめた「updatetrans.cs」と,空間アンカーの保存・読み込みの処理をまとめた「saveload_spatialanchor.cs」の2つを作成.
オブジェクトの位置情報やUIの座標値等の表記を更新するための処理(updatetrans.cs)
このコード(全文は本節の最後の記載)では,以下の処理を実行している.
void Start()関数
obj(Cubeなど位置合わせするオブジェクト,Inspectorで指定)のTransfromと,InputField_meterとInputField_degreeの初期値を取得し,対応したUIのTextMeshPro(Inspectorで指定)の中身を更新する.

void Start()
{
target = obj.GetComponent<Transform>();
if (target != null)
{
initialPosition = target.position;
initialRotation = target.rotation;
x.text = initialPosition.x.ToString();
y.text = initialPosition.y.ToString();
z.text = initialPosition.z.ToString();
rx.text = initialRotation.x.ToString();
ry.text = initialRotation.y.ToString();
rz.text = initialRotation.z.ToString();
frate_trans = float.Parse(rate_trans.text);
frate_rot = float.Parse(rate_rot.text);
}
}
public async void reset()関数
「Reset」ボタンをクリックしたら呼び出される関数.Inspector内で呼び出すための設定が必要.
Transformをvoid Start()関数で定義したinitialPositionとinitialRotationの値に戻し,UIの中身にも反映させる.空間アンカーを削除する工程が含まれ「待ち」の処理が入る関係で,async void関数としないとエラーが出る.



public async void reset()
{
spatialAnchor = obj.GetComponent<OVRSpatialAnchor>();
if (spatialAnchor != null)
{
Destroy(spatialAnchor);
}
x.text = initialPosition.x.ToString();
y.text = initialPosition.y.ToString();
z.text = initialPosition.z.ToString();
rx.text = initialRotation.x.ToString();
ry.text = initialRotation.y.ToString();
rz.text = initialRotation.z.ToString();
target.position = initialPosition;
target.rotation = initialRotation;
}
public void ChangeRate()関数
InputField_meterとInputField_degreeに値を入力したら呼び出される関数.Inspector内で呼び出すための設定が必要.
入力値の文字列を取得してfloat値に変換し,updatetransクラス内の変数frate_trans, frate_rotの値を更新する.



public void ChangeRate()
{
frate_trans = float.Parse(rate_trans.text);
frate_rot = float.Parse(rate_rot.text);
}
public void ChangeTransform(string label)関数
各パラメータを増減させるボタンをクリックしたら呼び出される関数.Inspector内で呼び出すための設定が必要.
引数(string label)の内容によってどの値を増減させるか判定させている.X座標の値を減少させるボタンの場合は以下のとおり.
この関数では対応するUIのTextMeshProの中身を変えただけで,オブジェクトのTransformの更新は次のpublic async void UpdateTransform()関数で行われる.



public void ChangeTransform(string label)
{
if (float.TryParse(x.text, out float xf))
{
if (label == "x_minus") xf -= frate_trans;
else if (label == "x_plus") xf += frate_trans;
x.text = xf.ToString();
}
if (float.TryParse(y.text, out float yf))
{
if (label == "y_minus") yf -= frate_trans;
else if (label == "y_plus") yf += frate_trans;
y.text = yf.ToString();
}
if (float.TryParse(z.text, out float zf))
{
if (label == "z_minus") zf -= frate_trans;
else if (label == "z_plus") zf += frate_trans;
z.text = zf.ToString();
}
if (float.TryParse(rx.text, out float rxf))
{
if (label == "rx_minus") rxf -= frate_rot;
else if (label == "rx_plus") rxf += frate_rot;
rx.text = rxf.ToString();
}
if (float.TryParse(ry.text, out float ryf))
{
if (label == "ry_minus") ryf -= frate_rot;
else if (label == "ry_plus") ryf += frate_rot;
ry.text = ryf.ToString();
}
if (float.TryParse(rz.text, out float rzf))
{
if (label == "rz_minus") rzf -= frate_rot;
else if (label == "rz_plus") rzf += frate_rot;
rz.text = rzf.ToString();
}
UpdateTransform();
}
public async void UpdateTransform()関数
public void ChangeTransform(string label)関数実行時とsaveload_spatialanchor.csのpublic async void LoadSpatialAnchor()関数を実行時に呼び出される関数.
空間アンカーが追加されていると保存時のTransform情報と同期するようで,一瞬新しいTransformに更新されるがすぐに前の状態に戻ってしまう.そのため,まず空間アンカーを削除して,TextMeshProの文字列を読み取ってfloat値に変換しTransformに反映している.
public async void UpdateTransform()
{
if (target != null)
{
spatialAnchor = obj.GetComponent<OVRSpatialAnchor>();
if (spatialAnchor != null)
{
Destroy(spatialAnchor);
}
// TextMeshPro の text プロパティから float に変換
if (float.TryParse(x.text, out float xf) &&
float.TryParse(y.text, out float yf) &&
float.TryParse(z.text, out float zf) &&
float.TryParse(rx.text, out float rxf) &&
float.TryParse(ry.text, out float ryf) &&
float.TryParse(rz.text, out float rzf))
{
// 座標と回転を更新
target.position = new Vector3(xf, yf, zf);
target.rotation = Quaternion.Euler(rxf, ryf, rzf);
}
else
{
Debug.LogWarning("TextMeshPro の入力が float に変換できませんでした。");
}
}
}
static int GetDecimalDigits(float value)関数
UIでの座標値や回転角の表示がぐちゃぐちゃにならないよう,frate_trans, frate_rotの値に基づいた有効桁数で丸めるためにこれらの桁数を取得するための関数.次のpublic void UpdateText()関数で使用する.
static int GetDecimalDigits(float value)
{
decimal decValue = Convert.ToDecimal(value);
if (decValue == 0) return 0;
int digits = 0;
while (decValue != Math.Floor(decValue))
{
decValue *= 10;
digits++;
}
return digits;
}
public void UpdateText()関数
saveload_spatialanchor.csのpublic async void LoadSpatialAnchor()関数を実行時に呼び出される関数.向こうではObjectのTransform更新だけ行うため,UIの中身をこちらで更新する必要がある.
public void UpdateText()
{
int dig_p = GetDecimalDigits(frate_trans);
int dig_r = GetDecimalDigits(frate_rot);
x.text = Math.Round(target.position.x, dig_p, MidpointRounding.AwayFromZero).ToString();
y.text = Math.Round(target.position.y, dig_p, MidpointRounding.AwayFromZero).ToString();
z.text = Math.Round(target.position.z, dig_p, MidpointRounding.AwayFromZero).ToString();
rx.text = Math.Round(target.rotation.eulerAngles.x, dig_r, MidpointRounding.AwayFromZero).ToString();
ry.text = Math.Round(target.rotation.eulerAngles.y, dig_r, MidpointRounding.AwayFromZero).ToString();
rz.text = Math.Round(target.rotation.eulerAngles.z, dig_r, MidpointRounding.AwayFromZero).ToString();
}
updatetrans.cs 全文
using UnityEngine;
using System;
using System.Globalization;
using System.Threading.Tasks;
using TMPro;
public class updatetrans : MonoBehaviour
{
[SerializeField] private GameObject obj;
private Transform target;
private OVRSpatialAnchor spatialAnchor;
private Vector3 initialPosition;
private Quaternion initialRotation;
float frate_trans, frate_rot;
[SerializeField] private TMP_Text x;
[SerializeField] private TMP_Text y;
[SerializeField] private TMP_Text z;
[SerializeField] private TMP_Text rx;
[SerializeField] private TMP_Text ry;
[SerializeField] private TMP_Text rz;
[SerializeField] private TMP_InputField rate_trans;
[SerializeField] private TMP_InputField rate_rot;
void Start()
{
target = obj.GetComponent<Transform>();
if (target != null)
{
initialPosition = target.position;
initialRotation = target.rotation;
x.text = initialPosition.x.ToString();
y.text = initialPosition.y.ToString();
z.text = initialPosition.z.ToString();
rx.text = initialRotation.x.ToString();
ry.text = initialRotation.y.ToString();
rz.text = initialRotation.z.ToString();
frate_trans = float.Parse(rate_trans.text);
frate_rot = float.Parse(rate_rot.text);
}
}
public async void reset()
{
spatialAnchor = obj.GetComponent<OVRSpatialAnchor>();
if (spatialAnchor != null)
{
Destroy(spatialAnchor);
}
x.text = initialPosition.x.ToString();
y.text = initialPosition.y.ToString();
z.text = initialPosition.z.ToString();
rx.text = initialRotation.x.ToString();
ry.text = initialRotation.y.ToString();
rz.text = initialRotation.z.ToString();
target.position = initialPosition;
target.rotation = initialRotation;
}
public void ChangeRate()
{
frate_trans = float.Parse(rate_trans.text);
frate_rot = float.Parse(rate_rot.text);
}
public void ChangeTransform(string label)
{
if (float.TryParse(x.text, out float xf))
{
if (label == "x_minus") xf -= frate_trans;
else if (label == "x_plus") xf += frate_trans;
x.text = xf.ToString();
}
if (float.TryParse(y.text, out float yf))
{
if (label == "y_minus") yf -= frate_trans;
else if (label == "y_plus") yf += frate_trans;
y.text = yf.ToString();
}
if (float.TryParse(z.text, out float zf))
{
if (label == "z_minus") zf -= frate_trans;
else if (label == "z_plus") zf += frate_trans;
z.text = zf.ToString();
}
if (float.TryParse(rx.text, out float rxf))
{
if (label == "rx_minus") rxf -= frate_rot;
else if (label == "rx_plus") rxf += frate_rot;
rx.text = rxf.ToString();
}
if (float.TryParse(ry.text, out float ryf))
{
if (label == "ry_minus") ryf -= frate_rot;
else if (label == "ry_plus") ryf += frate_rot;
ry.text = ryf.ToString();
}
if (float.TryParse(rz.text, out float rzf))
{
if (label == "rz_minus") rzf -= frate_rot;
else if (label == "rz_plus") rzf += frate_rot;
rz.text = rzf.ToString();
}
UpdateTransform();
}
public async void UpdateTransform()
{
if (target != null)
{
spatialAnchor = obj.GetComponent<OVRSpatialAnchor>();
if (spatialAnchor != null)
{
Destroy(spatialAnchor);
}
// TextMeshPro の text プロパティから float に変換
if (float.TryParse(x.text, out float xf) &&
float.TryParse(y.text, out float yf) &&
float.TryParse(z.text, out float zf) &&
float.TryParse(rx.text, out float rxf) &&
float.TryParse(ry.text, out float ryf) &&
float.TryParse(rz.text, out float rzf))
{
// 座標と回転を更新
target.position = new Vector3(xf, yf, zf);
target.rotation = Quaternion.Euler(rxf, ryf, rzf);
}
else
{
Debug.LogWarning("TextMeshPro の入力が float に変換できませんでした。");
}
}
}
static int GetDecimalDigits(float value)
{
decimal decValue = Convert.ToDecimal(value);
if (decValue == 0) return 0;
int digits = 0;
while (decValue != Math.Floor(decValue))
{
decValue *= 10;
digits++;
}
return digits;
}
public void UpdateText()
{
int dig_p = GetDecimalDigits(frate_trans);
int dig_r = GetDecimalDigits(frate_rot);
x.text = Math.Round(target.position.x, dig_p, MidpointRounding.AwayFromZero).ToString();
y.text = Math.Round(target.position.y, dig_p, MidpointRounding.AwayFromZero).ToString();
z.text = Math.Round(target.position.z, dig_p, MidpointRounding.AwayFromZero).ToString();
rx.text = Math.Round(target.rotation.eulerAngles.x, dig_r, MidpointRounding.AwayFromZero).ToString();
ry.text = Math.Round(target.rotation.eulerAngles.y, dig_r, MidpointRounding.AwayFromZero).ToString();
rz.text = Math.Round(target.rotation.eulerAngles.z, dig_r, MidpointRounding.AwayFromZero).ToString();
}
}
空間アンカーの保存・読み込みの処理(saveload_spatialanchor.cs)
このコード(全文は本節の最後の記載)では,以下の処理を実行している.
public async void SaveSpatialAnchor()関数
「Save」ボタンをクリックしたら呼び出される関数.Inspector内で呼び出すための設定が必要.
spatialAnchor = obj.AddComponent();でobj(Cubeなど位置合わせするオブジェクト,Inspectorで指定)に空間アンカーを追加している.空間アンカーの作成にawait処理を使うことができなかったので,spatialAnchor.Created=Trueになるか50ミリ秒経過するまでwhileループを回して「待つ」処理を追加している.これがないと空間アンカーが作成されていない状態でこの後のコードが実行されてしまう恐れがある.
bool saveResult = await spatialAnchor.SaveAnchorAsync();で空間アンカーの保存ができ,spatialAnchor.Uuidをstring型に変換してPlayerPrefsに保存する.上手くいけば右側のUUID:欄が32桁の数値に更新される.



public async void SaveSpatialAnchor()
{
spatialAnchor = obj.AddComponent<OVRSpatialAnchor>();
int retry = 0;
while (!spatialAnchor.Created && retry < 50) // 0.1秒 x 50 = 最大5秒
{
await Task.Delay(100); // 100ms待つ
retry++;
}
bool saveResult = await spatialAnchor.SaveAnchorAsync();
if (saveResult)
{
PlayerPrefs.SetString("SavedAnchorUUID", spatialAnchor.Uuid.ToString());
PlayerPrefs.Save();
saveuuid.text = spatialAnchor.Uuid.ToString();
}
}
public async void LoadSpatialAnchor()関数
「Load」ボタンをクリックしたら呼び出される関数.Inspector内で呼び出すための設定が必要.
1つしか呼び出さないのだが,OVRSpatialAnchor.LoadUnboundAnchorsAsyncがunboundAnchorsがをList型でないと引数にできなかったので,unboundAnchorsはList型で定義している.
空間アンカーをロード後,objにそれを追加する.これ以降の処理の順番は必ずこのコードのとおりに実行する.順番が前後するとうまくいかなかった.
最後の2行はUIの表示を更新するための処理.



public async void LoadSpatialAnchor()
{
string savedUuid = PlayerPrefs.GetString("SavedAnchorUUID", "");
if (!string.IsNullOrEmpty(savedUuid))
{
HashSet<Guid> anchorUuids = new();
List<OVRSpatialAnchor.UnboundAnchor> unboundAnchors = new();
Guid anchorGuid = Guid.Parse(savedUuid);
anchorUuids.Add(anchorGuid);
var loadResult = await OVRSpatialAnchor.LoadUnboundAnchorsAsync(anchorUuids, unboundAnchors);
if (loadResult)
{
bool localized = await unboundAnchors[0].LocalizeAsync();
if (localized)
{
if (obj.GetComponent<OVRSpatialAnchor>() != null)
{
Destroy(obj.GetComponent<OVRSpatialAnchor>());
await Task.Yield();
}
spatialAnchor = obj.AddComponent<OVRSpatialAnchor>();
unboundAnchors[0].BindTo(spatialAnchor);
loaduuid.text = spatialAnchor.Created.ToString();
int retry = 0;
while (!spatialAnchor.Created && retry < 50) // 0.1秒 x 50 = 最大5秒
{
await Task.Delay(100); // 100ms待つ
loaduuid.text = $"{retry}: {spatialAnchor.Created}";
retry++;
}
var updatetran = update.GetComponent<updatetrans>();
updatetran.UpdateText();
}
}
}
}
saveload_spatialanchor.cs 全文
using System;
using System.Collections.Generic;
using UnityEngine;
using System.Threading.Tasks;
using TMPro;
public class saveload_spatialanchor : MonoBehaviour
{
[SerializeField] private GameObject obj;
[SerializeField] private TMP_Text saveuuid;
[SerializeField] private TMP_Text loaduuid;
[SerializeField] private GameObject update;
private OVRSpatialAnchor spatialAnchor;
public async void SaveSpatialAnchor()
{
spatialAnchor = obj.AddComponent<OVRSpatialAnchor>();
int retry = 0;
while (!spatialAnchor.Created && retry < 50) // 0.1秒 x 50 = 最大5秒
{
await Task.Delay(100); // 100ms待つ
retry++;
}
bool saveResult = await spatialAnchor.SaveAnchorAsync();
if (saveResult)
{
PlayerPrefs.SetString("SavedAnchorUUID", spatialAnchor.Uuid.ToString());
PlayerPrefs.Save();
saveuuid.text = spatialAnchor.Uuid.ToString();
}
}
public async void LoadSpatialAnchor()
{
string savedUuid = PlayerPrefs.GetString("SavedAnchorUUID", "");
if (!string.IsNullOrEmpty(savedUuid))
{
HashSet<Guid> anchorUuids = new();
List<OVRSpatialAnchor.UnboundAnchor> unboundAnchors = new();
Guid anchorGuid = Guid.Parse(savedUuid);
anchorUuids.Add(anchorGuid);
var loadResult = await OVRSpatialAnchor.LoadUnboundAnchorsAsync(anchorUuids, unboundAnchors);
if (loadResult)
{
bool localized = await unboundAnchors[0].LocalizeAsync();
if (localized)
{
if (obj.GetComponent<OVRSpatialAnchor>() != null)
{
Destroy(obj.GetComponent<OVRSpatialAnchor>());
await Task.Yield();
}
spatialAnchor = obj.AddComponent<OVRSpatialAnchor>();
unboundAnchors[0].BindTo(spatialAnchor);
loaduuid.text = spatialAnchor.Created.ToString();
int retry = 0;
while (!spatialAnchor.Created && retry < 50) // 0.1秒 x 50 = 最大5秒
{
await Task.Delay(100); // 100ms待つ
loaduuid.text = $"{retry}: {spatialAnchor.Created}";
retry++;
}
var updatetran = update.GetComponent<updatetrans>();
updatetran.UpdateText();
}
}
}
}
}
スクリプトをオブジェクトに追加
Hierarchyで右クリックし,Create Emptyをクリックする.これを2回繰り返し.
それぞれ名前は「Contoll_trans」「SaveLoad_SpatialAnchor」とする.
Contoll_transにはupdatetrans.csをコンポーネントとして追加し,public関数は以下のように設定する.

SaveLoad_SpatialAnchorにはsaveload_spatialanchor.csをコンポーネントとして追加し,public関数は以下のように設定する.

アプリの実行
コントローラーのRayかPokeで各パラメータの増減ボタン「<」「>」を押せば,Rate of Changeの値に応じて対応した数値が増減し,オブジェクトのTransformにも反映される.「Reset」ボタンを押せば,Unityで設定したTransformの値に戻る.
保存したい状態になったら「Save」を押すと,UUID表示と同時に保存される.
UUIDが保存された状態で「Load」を押すと,最後に保存したUUIDが表示されTransformが読み込まれる.一度アプリを終了して再度別の場所で立ち上げても,UUIDは残っているため読み込み可能である.