PICO Unity空间锚点管理
锚点创建、删除、持久化、应用、轴向锁定等功能;
视频展示
PICOXR锚点管理
代码
csharp
using UnityEngine;
using Unity.XR.PXR;
/// <summary>
/// 使能PICO视频透视
/// </summary>
public class EnableVideoSeeThrough : MonoBehaviour
{
private void Start()
{
// 监听透视功能状态
PXR_Manager.VstDisplayStatusChanged += VstDisplayStatusChanged;
// 开启视频透视
EnableVideoSeeThroughOrNo(true);
}
//Re-enable seethrough after the app resumes
private void OnApplicationPause(bool pause)
{
if (!pause)
PXR_Manager.EnableVideoSeeThrough = true;
}
private void OnDestroy()
{
// 取消监听
PXR_Manager.VstDisplayStatusChanged -= VstDisplayStatusChanged;
}
/// <summary>
/// 开启或关闭视频透视功能
/// </summary>
/// <param name="enable"></param>
void EnableVideoSeeThroughOrNo(bool enable)
{
PXR_Manager.EnableVideoSeeThrough = enable;
}
private void VstDisplayStatusChanged(PxrVstStatus status)
{
switch (status)
{
case PxrVstStatus.Disabled: // 已关闭
Debug.Log("视频透视已关闭");
break;
case PxrVstStatus.Enabling: // 开启中
Debug.Log("视频透视正在开启...");
break;
case PxrVstStatus.Enabled: // 已开启
Debug.Log("视频透视已开启");
break;
case PxrVstStatus.Disabling: // 关闭中
Debug.Log("视频透视正在关闭...");
break;
}
}
}
csharp
using UnityEngine;
using UnityEngine.UI;
using Unity.XR.PXR;
using UnityEngine.XR.Interaction.Toolkit;
using System.Collections.Generic;
using UnityEngine.XR.Interaction.Toolkit.Transformers;
public class PXRSample_SpatialAnchor : MonoBehaviour
{
// XR 基础交互对象
private XRBaseInteractable interactable;
private XRGrabInteractable grabInteractable;
private PXRSample_SpatialAnchorRotationConstraintGrabTransformer rotationConstraintGrabTransformer;
// 锚点句柄,唯一标识一个空间锚点
[HideInInspector]
public ulong anchorHandle;
// UI 显示锚点 ID 的文本
[SerializeField]
private Text txtAnchorID, txtCreatedIsOrNo, txtPersistedIsOrNo, txtAppliedIsOrNo;
[SerializeField] private GameObject previewObject; // 预览对象
[SerializeField] private GameObject toggleGroupLock; // 锁定旋转轴的 Toggle 组
// UI 画布
[SerializeField]
private GameObject uiCanvas, uiAnchorPanel;
// UI 按钮
[SerializeField] private Button btnApply; // 应用锚点的按钮
[SerializeField] private Button btnPersist; // 持久化锚点的按钮
[SerializeField] private Button btnDestroyAnchor; // 销毁锚点的按钮
[SerializeField] private Button btnDeleteAnchor; // 删除锚点的按钮
private void Awake()
{
toggleGroupLock.SetActive(false);
uiAnchorPanel.SetActive(false);
// 设置画布使用主摄像头
uiCanvas.GetComponent<Canvas>().worldCamera = Camera.main;
// 为按钮添加点击事件监听器
btnApply.onClick.AddListener(OnBtnApply);
btnPersist.onClick.AddListener(OnBtnPressedPersist);
btnDestroyAnchor.onClick.AddListener(OnBtnPressedDestroy);
btnDeleteAnchor.onClick.AddListener(OnBtnPressedUnPersist);
}
protected void OnEnable()
{
// 获取 XRBaseInteractable 组件并添加事件监听器
interactable = GetComponent<XRBaseInteractable>();
grabInteractable = GetComponent<XRGrabInteractable>();
interactable.firstHoverEntered.AddListener(OnFirstHoverEntered);
interactable.lastHoverExited.AddListener(OnLastHoverExited);
// interactable.firstSelectEntered.AddListener(OnFirstSelectEntered);
// interactable.lastSelectExited.AddListener(OnLastSelectExited);
}
// protected void OnDisable()
// {
// // 在禁用时可以执行清理操作(当前未实现)
// }
//
// private void Start()
// {
// // 初始化时的操作(当前未实现)
// }
private void Update()
{
// 如果 UI 画布处于活动状态,使其始终面向摄像头
if (uiCanvas.activeSelf)
{
uiCanvas.transform.LookAt(new Vector3(uiCanvas.transform.position.x * 2 - Camera.main.transform.position.x,
uiCanvas.transform.position.y * 2 - Camera.main.transform.position.y,
uiCanvas.transform.position.z * 2 - Camera.main.transform.position.z),
Vector3.up);
}
}
// private void LateUpdate()
// {
// // 尝试定位空间锚点(获取空间锚点的实时位置)
// var result = PXR_MixedReality.LocateAnchor(anchorHandle, out var position, out var rotation);
// if (result == PxrResult.SUCCESS)
// {
// // 如果成功,更新当前对象的位置和旋转
// transform.position = position;
// transform.rotation = rotation;
// }
// else
// {
// // 记录定位锚点的结果
// PXRSample_SpatialAnchorManager.Instance.SetLogInfo("LocateSpatialAnchor:" + result.ToString());
// }
// }
/// <summary>
/// 获取空间锚点的实时位置
/// </summary>
public bool LocateAnchor(Transform targetTf)
{
bool ret = false;
// 尝试定位空间锚点
var result = PXR_MixedReality.LocateAnchor(anchorHandle, out var position, out var rotation);
if (result == PxrResult.SUCCESS)
{
// 如果成功,更新当前对象的位置和旋转
targetTf.position = position;
targetTf.rotation = rotation;
ret = true;
}
else
{
// 记录定位锚点的结果
PXRSample_SpatialAnchorManager.Instance.SetLogInfo("LocateSpatialAnchor:" + result.ToString());
}
return ret;
}
// 处理第一次悬停进入事件
protected virtual void OnFirstHoverEntered(HoverEnterEventArgs args) => UpdateColor();
// 处理最后一次悬停退出事件
protected virtual void OnLastHoverExited(HoverExitEventArgs args) => UpdateColor();
// // 处理第一次选择进入事件
// protected virtual void OnFirstSelectEntered(SelectEnterEventArgs args) => UpdateCanvas();
//
// // 处理最后一次选择退出事件
// protected virtual void OnLastSelectExited(SelectExitEventArgs args) => UpdateCanvas();
// 更新对象的颜色以指示悬停状态
protected void UpdateColor()
{
if (interactable.isHovered)
{
foreach (var renderer in GetComponentsInChildren<Renderer>())
{
// 如果被悬停,设置发光颜色为黄色
renderer.material.SetColor("_EmissionColor", Color.yellow);
}
}
else
{
foreach (var renderer in GetComponentsInChildren<Renderer>())
{
// 如果未被悬停,清除发光颜色
renderer.material.SetColor("_EmissionColor", Color.clear);
}
}
}
// // 更新 UI 画布的显示状态
// protected void UpdateCanvas()
// {
// uiCanvas.SetActive(interactable.isSelected);
// }
/// <summary>
/// 应用锚点
/// </summary>
private void OnBtnApply()
{
PXRSample_SpatialAnchorManager.Instance.ApplyAnchor(this);
}
/// <summary>
/// 持久化锚点会将锚点数据保存到设备中,以便在应用程序关闭后仍然存在。
/// 持久化后,锚点会分配一个唯一的 ID,可以通过该 ID 来重新定位和管理锚点。
/// 需要注意的是,持久化锚点可能会占用设备存储空间,因此请根据实际需求合理使用。
/// </summary>
private async void OnBtnPressedPersist()
{
var result = await PXR_MixedReality.PersistSpatialAnchorAsync(anchorHandle);
PXRSample_SpatialAnchorManager.Instance.SetLogInfo("PersistSpatialAnchorAsync:" + result.ToString());
if (result == PxrResult.SUCCESS)
{
// 如果成功,显示保存图标
ShowPersistedTip();
}
}
/// <summary>
/// 销毁锚点会直接销毁锚点对象,但不会取消持久化(如果已持久化)。如果需要删除锚点,请先取消持久化,然后销毁锚点对象。
/// </summary>
private void OnBtnPressedDestroy()
{
PXRSample_SpatialAnchorManager.Instance.DestroySpatialAnchor(anchorHandle);
}
/// <summary>
/// 删除锚点
/// </summary>
private void OnBtnPressedUnPersist()
{
UnPersistAndDestoryAnchor();
}
/// <summary>
/// 先去消持久化(如果已持久化),然后销毁锚点对象
/// </summary>
public async void UnPersistAndDestoryAnchor()
{
var result = await PXR_MixedReality.UnPersistSpatialAnchorAsync(anchorHandle);
PXRSample_SpatialAnchorManager.Instance.SetLogInfo("UnPersistSpatialAnchorAsync:" + result.ToString());
OnBtnPressedDestroy();
// if (result == PxrResult.SUCCESS)
// {
// // 如果成功,销毁锚点
// OnBtnPressedDestroy();
// }
}
// 设置锚点句柄并更新 UI 显示
public void SetAnchorHandle(ulong handle)
{
anchorHandle = handle;
txtAnchorID.text = "锚点ID:" + anchorHandle;
}
public void ShowPersistedTip()
{
txtPersistedIsOrNo.text = $"是否已持久化:是";
}
public void ShowCreatedTip(bool isTrue = true)
{
txtCreatedIsOrNo.text = $"是否已创建:{(isTrue ? "是" : "否")}";
}
public void ShowAppliedTip(bool isTrue = true)
{
txtAppliedIsOrNo.text = $"是否已应用:{(isTrue ? "是" : "否")}";
}
/// <summary>
/// 显隐锁定旋转轴的 Toggle 组。该组包含用于配置锚点旋转约束的 UI 元素
/// </summary>
/// <param name="isTrue"></param>
public void ShowLockToggleGroup(bool isTrue)
{
toggleGroupLock.SetActive(isTrue);
}
// 显隐锚点设置面板和预览对象
public void ShowPreviewAndUICavas(bool isTrue)
{
uiAnchorPanel.SetActive(isTrue);
previewObject.SetActive(isTrue);
}
public void SetMovable(bool isMovable)
{
if (grabInteractable == null)
{
grabInteractable = GetComponent<XRGrabInteractable>();
}
if (grabInteractable != null)
{
grabInteractable.enabled = isMovable;
}
}
/// <summary>
/// 为当前锚点配置抓取时的旋转轴锁定规则。
/// </summary>
/// <remarks>
/// 约束会直接插入 XRGrabInteractable 的 Grab Transformer 链中,
/// 因此限制会在拖动过程中实时生效,而不是等用户放手后再回正。
/// </remarks>
public void ConfigureRotationConstraints(bool lockX, bool lockY, bool lockZ, Quaternion referenceRotation)
{
EnsureRotationConstraintTransformerRegistered();
if (rotationConstraintGrabTransformer != null)
{
rotationConstraintGrabTransformer.Configure(lockX, lockY, lockZ, referenceRotation);
}
}
/// <summary>
/// 确保自定义旋转约束器已经注册到抓取处理链的最后。
/// </summary>
private void EnsureRotationConstraintTransformerRegistered()
{
if (grabInteractable == null)
{
grabInteractable = GetComponent<XRGrabInteractable>();
}
if (grabInteractable == null)
{
return;
}
if (rotationConstraintGrabTransformer == null)
{
rotationConstraintGrabTransformer = GetComponent<PXRSample_SpatialAnchorRotationConstraintGrabTransformer>();
if (rotationConstraintGrabTransformer == null)
{
rotationConstraintGrabTransformer = gameObject.AddComponent<PXRSample_SpatialAnchorRotationConstraintGrabTransformer>();
}
}
List<IXRGrabTransformer> singleGrabTransformers = new List<IXRGrabTransformer>();
grabInteractable.GetSingleGrabTransformers(singleGrabTransformers);
if (!singleGrabTransformers.Contains(rotationConstraintGrabTransformer))
{
grabInteractable.AddSingleGrabTransformer(rotationConstraintGrabTransformer);
grabInteractable.MoveSingleGrabTransformerTo(rotationConstraintGrabTransformer, grabInteractable.singleGrabTransformersCount - 1);
}
List<IXRGrabTransformer> multipleGrabTransformers = new List<IXRGrabTransformer>();
grabInteractable.GetMultipleGrabTransformers(multipleGrabTransformers);
if (!multipleGrabTransformers.Contains(rotationConstraintGrabTransformer))
{
grabInteractable.AddMultipleGrabTransformer(rotationConstraintGrabTransformer);
grabInteractable.MoveMultipleGrabTransformerTo(rotationConstraintGrabTransformer, grabInteractable.multipleGrabTransformersCount - 1);
}
}
}
csharp
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine.UI;
using Unity.XR.PXR;
using UnityEngine;
// 定义空间锚点管理器类
public class PXRSample_SpatialAnchorManager : MonoBehaviour
{
// 单例实例
private static PXRSample_SpatialAnchorManager instance = null;
// 单例属性
public static PXRSample_SpatialAnchorManager Instance
{
get
{
// 如果实例为空,则查找场景中的实例
if (instance == null)
{
instance = FindObjectOfType<PXRSample_SpatialAnchorManager>();
}
return instance; // 返回实例
}
}
// 存储锚点的字典
private Dictionary<ulong, PXRSample_SpatialAnchor> anchorList = new Dictionary<ulong, PXRSample_SpatialAnchor>();
[SerializeField] private Button btnStartAnchorCreateMode; // 进入创建锚点模式按钮
[SerializeField] private Button btnCreateAnchor; // 创建锚点按钮
[SerializeField] private Button btnLoadAnchors; // 显示/隐藏所有锚点按钮
[SerializeField] private Button btnDeleteAllAnchors; // 删除所有锚点
[SerializeField] private Toggle toggleLockRotationX; // 锁定X轴旋转的Toggle
[SerializeField] private Toggle toggleLockRotationY; // 锁定Y轴旋转的Toggle
[SerializeField] private Toggle toggleLockRotationZ; // 锁定Z轴旋转的Toggle
[SerializeField] private GameObject anchorPrefab; // 锚点预制体
[SerializeField] private GameObject GameObjectRoot; // 锚点对象的根节点
[SerializeField] private Text headerText; // 顶部标题文本,用于显示"空间锚点管理 N"
[SerializeField] private Text tipsText; // 提示文本
[SerializeField] private bool IsSyncRealtime = true; // 创建模式下拖动预览锚点时,目标根节点是否实时跟随
[SerializeField] private bool lockAnchorRotationX; // 是否锁定预览锚点的 X 轴旋转
[SerializeField] private bool lockAnchorRotationY; // 是否锁定预览锚点的 Y 轴旋转
[SerializeField] private bool lockAnchorRotationZ; // 是否锁定预览锚点的 Z 轴旋转
private int maxLogCount = 16; // 最大日志条数
private Queue<string> logQueue = new Queue<string>(); // 日志队列
private bool isLoadingAnchors; // 防止重复加载锚点
private bool hasStartedSpatialAnchorProvider; // 记录锚点服务是否已启动
private bool isStartingSpatialAnchorProvider; // 防止重复启动锚点服务
private bool isShowingLoadedAnchorVisuals; // 当前是否显示已加载锚点的预览和 UI
private bool isAnchorCreateMode; // 是否处于锚点创建模式
private bool isCreatingAnchorFromPreview; // 是否正在根据预览锚点创建空间锚点
private Vector3 initialRootPosition; // 根节点初始位置
private Quaternion initialRootRotation; // 根节点初始旋转
private Vector3 pendingAnchorPosition; // 正在创建中的锚点位置
private Quaternion pendingAnchorRotation; // 正在创建中的锚点旋转
private PXRSample_SpatialAnchor appliedAnchor; // 当前被应用到根节点的锚点
private Quaternion previewRotationReference; // 进入创建模式时的标准朝向,用于做轴向锁定基准
private const float AnchorPreviewDistance = 0.5f;
private const string HeaderTextPrefix = "空间锚点管理";
private const string StartAnchorCreateModeText = "开启空间锚点创建";
private const string CancelAnchorCreateModeText = "取消空间锚点创建";
private const string ShowAllAnchorsText = "显示所有空间锚点";
private const string HideAllAnchorsText = "隐藏所有空间锚点";
// 初始化
void Start()
{
// PXR_Manager.EnableVideoSeeThrough = true; // 启用视频透视
if (GameObjectRoot != null)
{
initialRootPosition = GameObjectRoot.transform.position;
initialRootRotation = GameObjectRoot.transform.rotation;
}
if (anchorPrefab != null)
{
anchorPrefab.SetActive(false);
PXRSample_SpatialAnchor previewAnchor = anchorPrefab.GetComponent<PXRSample_SpatialAnchor>();
if (previewAnchor != null)
{
previewAnchor.ShowCreatedTip(false);
previewAnchor.ShowAppliedTip(false);
previewAnchor.ShowLockToggleGroup(false);
previewAnchor.SetMovable(true);
previewAnchor.ConfigureRotationConstraints(lockAnchorRotationX, lockAnchorRotationY, lockAnchorRotationZ, anchorPrefab.transform.rotation);
}
}
//更新顶部标题文本,显示当前锚点数量
UpdateHeaderText(anchorList.Count);
// 添加按钮点击事件
btnStartAnchorCreateMode.onClick.AddListener(OnBtnStartAnchorCreateMode);
btnCreateAnchor.onClick.AddListener(OnBtnPressedCreateAnchor);
btnLoadAnchors.onClick.AddListener(OnBtnPressedLoadAllAnchors);
btnDeleteAllAnchors.onClick.AddListener(OnBtnDeleteAllAnchors);
// 添加旋转轴锁定Toggle事件
toggleLockRotationX.onValueChanged.AddListener((value) => { lockAnchorRotationX = value; });
toggleLockRotationY.onValueChanged.AddListener((value) => { lockAnchorRotationY = value; });
toggleLockRotationZ.onValueChanged.AddListener((value) => { lockAnchorRotationZ = value; });
toggleLockRotationX.isOn = lockAnchorRotationX;
toggleLockRotationY.isOn = lockAnchorRotationY;
toggleLockRotationZ.isOn = lockAnchorRotationZ;
// 显示按钮
btnCreateAnchor.gameObject.SetActive(true);
btnLoadAnchors.gameObject.SetActive(true);
SetButtonText(btnLoadAnchors, ShowAllAnchorsText);
// // 获取右手控制器
// rightController = InputDevices.GetDeviceAtXRNode(XRNode.RightHand);
//初始化空间锚点服务,确保服务已启动后加载锚点数据
_ = InitializeSpatialAnchorsAsync();
}
/// <summary>
/// 初始化空间锚点服务,确保服务已启动后加载锚点数据
/// </summary>
private async Task InitializeSpatialAnchorsAsync()
{
if (await EnsureSpatialAnchorProviderReadyAsync())
{
// 启动时做"静默加载":
// 1. 把持久化锚点实例化到场景中;
// 2. 默认隐藏所有锚点的 UI 和预览;
// 3. 如果存在锚点,则自动将找到的第一个锚点应用到目标根节点。
await LoadAnchorsAsync(false, true);
}
}
/// <summary>
/// 确保空间锚点服务提供者已准备就绪,防止重复启动和加载
/// </summary>
/// <returns></returns>
private async Task<bool> EnsureSpatialAnchorProviderReadyAsync()
{
if (hasStartedSpatialAnchorProvider)
{
return true;
}
if (isStartingSpatialAnchorProvider)
{
while (isStartingSpatialAnchorProvider)
{
await Task.Delay(100);
}
return hasStartedSpatialAnchorProvider;
}
isStartingSpatialAnchorProvider = true;
try
{
var result = await PXR_MixedReality.StartSenseDataProvider(PxrSenseDataProviderType.SpatialAnchor); // 启动空间锚点感知数据提供者
SetLogInfo("StartSenseDataProvider:" + result); // 记录日志
hasStartedSpatialAnchorProvider = result == PxrResult.SUCCESS;
return hasStartedSpatialAnchorProvider;
}
finally
{
isStartingSpatialAnchorProvider = false;
}
}
// 启用时注册事件
void OnEnable()
{
PXR_Manager.SpatialAnchorDataUpdated += SpatialAnchorDataUpdated; // 注册锚点数据更新事件
}
// 禁用时注销事件
void OnDisable()
{
PXR_Manager.SpatialAnchorDataUpdated -= SpatialAnchorDataUpdated; // 注销锚点数据更新事件
}
private void LateUpdate()
{
if (GameObjectRoot == null)
{
return;
}
// 预览锚点在创建模式下可以被拖拽,拖拽后先按配置做旋转轴锁定,
// 再决定是否把结果实时同步到目标根节点。
// 正常抓取时,实时锁定由自定义 Grab Transformer 处理;
// 这里保留一次兜底修正,兼容非抓取代码改动 transform 的情况。
if (isAnchorCreateMode && anchorPrefab != null && anchorPrefab.activeInHierarchy)
{
ApplyPreviewRotationConstraints();
}
if (isAnchorCreateMode && IsSyncRealtime && anchorPrefab != null && anchorPrefab.activeInHierarchy)
{
GameObjectRoot.transform.SetPositionAndRotation(anchorPrefab.transform.position, anchorPrefab.transform.rotation);
return;
}
if (isCreatingAnchorFromPreview)
{
GameObjectRoot.transform.SetPositionAndRotation(pendingAnchorPosition, pendingAnchorRotation);
return;
}
if (appliedAnchor != null)
{
appliedAnchor.LocateAnchor(GameObjectRoot.transform);
}
}
/// <summary>
/// 锚点数据更新事件
/// </summary>
private void SpatialAnchorDataUpdated()
{
if (!hasStartedSpatialAnchorProvider || isLoadingAnchors)
{
return;
}
// 系统通知锚点数据发生变化时,保持静默加载策略,避免自动把所有锚点 UI 打开。
_ = LoadAnchorsAsync(isShowingLoadedAnchorVisuals, false);
}
/// <summary>
/// 开启创建锚点模式
/// </summary>
private void OnBtnStartAnchorCreateMode()
{
if (anchorPrefab == null)
{
return;
}
if (isCreatingAnchorFromPreview)
{
return;
}
if (isAnchorCreateMode)
{
CancelAnchorCreateMode();
return;
}
Transform cameraTransform = Camera.main != null ? Camera.main.transform : null;
if (cameraTransform == null)
{
SetLogInfo("OnBtnStartAnchorCreateMode:Main camera not found");
return;
}
isAnchorCreateMode = true;
anchorPrefab.SetActive(true);
anchorPrefab.transform.position = cameraTransform.position + cameraTransform.forward * AnchorPreviewDistance;
Vector3 lookDirection = cameraTransform.position - anchorPrefab.transform.position;
if (lookDirection.sqrMagnitude < 0.0001f)
{
lookDirection = -cameraTransform.forward;
}
anchorPrefab.transform.rotation = Quaternion.LookRotation(lookDirection.normalized, Vector3.up);
previewRotationReference = anchorPrefab.transform.rotation;
ApplyPreviewRotationConstraints();
PXRSample_SpatialAnchor previewAnchor = anchorPrefab.GetComponent<PXRSample_SpatialAnchor>();
if (previewAnchor != null)
{
previewAnchor.ShowCreatedTip(false);
previewAnchor.ShowAppliedTip(false);
previewAnchor.ShowLockToggleGroup(true);
previewAnchor.SetMovable(true);
previewAnchor.ConfigureRotationConstraints(lockAnchorRotationX, lockAnchorRotationY, lockAnchorRotationZ, previewRotationReference);
}
SetButtonText(btnStartAnchorCreateMode, CancelAnchorCreateModeText);
}
/// <summary>
/// 删除所有锚点
/// </summary>
private void OnBtnDeleteAllAnchors()
{
//先将锚点去持久化,再销毁,最后清空列表
foreach (var anchor in anchorList.Values.ToList())
{
if (anchor != null)
{
anchor.UnPersistAndDestoryAnchor();
}
}
appliedAnchor = null;
if (!isAnchorCreateMode && !isCreatingAnchorFromPreview)
{
RestoreRootToAppliedAnchorOrInitial();
}
}
/// <summary>
/// 创建锚点按钮事件
/// </summary>
private async void OnBtnPressedCreateAnchor()
{
if (!isAnchorCreateMode || anchorPrefab == null)
{
SetLogInfo("OnBtnPressedCreateAnchor:Anchor create mode is not active");
return;
}
if (!await EnsureSpatialAnchorProviderReadyAsync())
{
return;
}
// 用户点击创建按钮时,再执行一次旋转轴锁定,
// 避免最后一次拖拽与按钮点击之间还没来得及经过 LateUpdate,导致创建结果带入未约束的旋转值。
ApplyPreviewRotationConstraints();
isAnchorCreateMode = false;
isCreatingAnchorFromPreview = true;
pendingAnchorPosition = anchorPrefab.transform.position;
pendingAnchorRotation = anchorPrefab.transform.rotation;
PXRSample_SpatialAnchor previewAnchor = anchorPrefab.GetComponent<PXRSample_SpatialAnchor>();
if (previewAnchor != null)
{
previewAnchor.ShowLockToggleGroup(false);
}
// 创建空间锚点
CreateSpatialAnchor(anchorPrefab.transform);
//隐藏
anchorPrefab.SetActive(false);
SetButtonText(btnStartAnchorCreateMode, StartAnchorCreateModeText);
}
/// <summary>
/// 异步加载所有锚点
/// </summary>
private async void OnBtnPressedLoadAllAnchors()
{
if (!await EnsureSpatialAnchorProviderReadyAsync())
{
return;
}
if (isShowingLoadedAnchorVisuals)
{
SetLoadedAnchorVisuals(false);
return;
}
// 点击"显示所有空间锚点"时,显式显示所有锚点 UI/预览;
// 再次点击同一个按钮时,则改为统一隐藏。
await LoadAnchorsAsync(true, false);
}
/// <summary>
/// 加载所有锚点,查询设备中所有持久化的锚点,并在场景中实例化对应的锚点对象进行显示和管理。
/// "showVisualsAfterLoad"
/// true:显式显示所有锚点的 Canvas 和 Preview,适用于用户点击"加载所有空间锚点"按钮。
/// false:静默加载,仅保证锚点存在于场景中,但默认隐藏 Canvas 和 Preview。
/// "applyFirstAnchorIfNeeded"
/// true:如果当前还没有已应用锚点,则把找到的第一个锚点应用到目标根节点。
/// false:不自动切换当前应用锚点。
/// </summary>
private async Task LoadAnchorsAsync(bool showVisualsAfterLoad, bool applyFirstAnchorIfNeeded)
{
if (!hasStartedSpatialAnchorProvider)
{
return;
}
if (isLoadingAnchors)
{
return;
}
isLoadingAnchors = true;
try
{
var result = await PXR_MixedReality.QuerySpatialAnchorAsync(); // 查询所有空间锚点
if (result.result == PxrResult.ERROR_HANDLE_INVALID)
{
SetLogInfo("LoadSpatialAnchorAsync:No persisted anchors");
return;
}
SetLogInfo("LoadSpatialAnchorAsync:" + result.result.ToString() + result.anchorHandleList.Count); // 记录日志
if (result.result != PxrResult.SUCCESS)
{
return;
}
// 记录本次查询结果里的第一个锚点。
// 启动静默加载时,如果还没有当前应用锚点,就使用它来恢复目标根节点的位置和旋转。
PXRSample_SpatialAnchor firstResolvedAnchor = null;
foreach (var key in result.anchorHandleList) // 遍历锚点句柄
{
PXRSample_SpatialAnchor anchor = null;
if (!anchorList.ContainsKey(key)) // 如果锚点列表中不存在该锚点
{
GameObject anchorObject = Instantiate(anchorPrefab); // 实例化锚点预制体
anchorObject.SetActive(true);
anchor = anchorObject.GetComponent<PXRSample_SpatialAnchor>(); // 获取锚点组件
anchor.SetAnchorHandle(key); // 设置锚点句柄
// 定位锚点
var locateResult = PXR_MixedReality.LocateAnchor(key, out var position, out var orientation);
if (locateResult != PxrResult.SUCCESS)
{
SetLogInfo("LocateSpatialAnchor:" + locateResult.ToString());
Destroy(anchorObject);
continue;
}
anchor.transform.position = position; // 设置位置
anchor.transform.rotation = orientation; // 设置旋转
anchor.SetMovable(false);
anchorList.Add(key, anchor); // 添加到锚点列表
anchor.ShowPersistedTip(); // 显示保存图标
anchor.ShowCreatedTip();
anchor.ShowLockToggleGroup(false);
}
anchor = anchorList[key];
if (firstResolvedAnchor == null)
{
firstResolvedAnchor = anchor;
}
// 根据加载入口决定是否显示锚点的可视化 UI。
anchor.ShowAppliedTip(appliedAnchor == anchor);
anchor.ShowPreviewAndUICavas(showVisualsAfterLoad);
anchor.ShowLockToggleGroup(false);
}
// 启动时若尚未有当前应用锚点,则自动应用设备中查询到的第一个锚点。
if (applyFirstAnchorIfNeeded && appliedAnchor == null && firstResolvedAnchor != null)
{
ApplyAnchor(firstResolvedAnchor);
}
SetLoadedAnchorVisuals(showVisualsAfterLoad, false);
UpdateHeaderText(anchorList.Count);
}
finally
{
isLoadingAnchors = false;
}
}
/// <summary>
/// 异步创建空间锚点
/// </summary>
/// <param name="transform"></param>
private async void CreateSpatialAnchor(Transform transform)
{
var result = await PXR_MixedReality.CreateSpatialAnchorAsync(transform.position, transform.rotation); // 创建锚点
SetLogInfo("CreateSpatialAnchorAsync:" + result.ToString()); // 记录日志
isCreatingAnchorFromPreview = false;
if (result.result == PxrResult.SUCCESS) // 成功创建
{
GameObject anchorObject = Instantiate(anchorPrefab); // 实例化锚点预制体
anchorObject.SetActive(true);
PXRSample_SpatialAnchor anchor = anchorObject.GetComponent<PXRSample_SpatialAnchor>(); // 获取锚点组件
if (anchor == null) // 如果锚点组件不存在
{
anchor = anchorObject.AddComponent<PXRSample_SpatialAnchor>(); // 添加锚点组件
}
anchorObject.transform.SetPositionAndRotation(transform.position, transform.rotation);
anchor.SetAnchorHandle(result.anchorHandle); // 设置锚点句柄
anchor.ShowCreatedTip();
anchor.ShowAppliedTip(false);
anchor.ShowPreviewAndUICavas(true);
anchor.ShowLockToggleGroup(false);
anchor.SetMovable(false);
anchorList.Add(result.anchorHandle, anchor); // 添加到锚点列表
UpdateHeaderText(anchorList.Count);
ApplyAnchor(anchor);
// 获取锚点的UUID
var result1 = PXR_MixedReality.GetAnchorUuid(result.anchorHandle, out var uuid);
SetLogInfo("GetUuid:" + result1.ToString() + " " + (result.uuid.Equals(uuid)) + "Uuid:" + uuid); // 记录UUID信息
return;
}
RestoreRootToAppliedAnchorOrInitial();
}
/// <summary>
/// 销毁空间锚点
/// </summary>
/// <param name="anchorHandle"></param>
public void DestroySpatialAnchor(ulong anchorHandle)
{
var result = PXR_MixedReality.DestroyAnchor(anchorHandle); // 销毁锚点
SetLogInfo("DestroySpatialAnchor:" + result.ToString()); // 记录日志
if (result == PxrResult.SUCCESS) // 如果成功
{
if (anchorList.ContainsKey(anchorHandle)) // 如果锚点列表中存在该锚点
{
PXRSample_SpatialAnchor anchor = anchorList[anchorHandle];
if (appliedAnchor == anchor)
{
appliedAnchor = null;
}
Destroy(anchorList[anchorHandle].gameObject); // 销毁锚点对象
anchorList.Remove(anchorHandle); // 从列表中移除锚点
UpdateHeaderText(anchorList.Count);
if (anchorList.Count == 0)
{
SetLoadedAnchorVisuals(false, false);
}
RefreshAppliedTips();
RestoreRootToAppliedAnchorOrInitial();
}
}
}
/// <summary>
/// 应用指定锚点到目标根节点。
/// </summary>
public void ApplyAnchor(PXRSample_SpatialAnchor anchor)
{
if (anchor == null || GameObjectRoot == null)
{
return;
}
appliedAnchor = anchor;
RefreshAppliedTips();
if (!isAnchorCreateMode && !isCreatingAnchorFromPreview)
{
bool locateResult = appliedAnchor.LocateAnchor(GameObjectRoot.transform);
if (!locateResult)
{
GameObjectRoot.transform.SetPositionAndRotation(anchor.transform.position, anchor.transform.rotation);
}
}
}
private void RefreshAppliedTips()
{
foreach (var anchor in anchorList.Values)
{
if (anchor != null)
{
anchor.ShowAppliedTip(anchor == appliedAnchor);
}
}
}
private void CancelAnchorCreateMode()
{
isAnchorCreateMode = false;
PXRSample_SpatialAnchor previewAnchor = anchorPrefab != null ? anchorPrefab.GetComponent<PXRSample_SpatialAnchor>() : null;
if (previewAnchor != null)
{
previewAnchor.ShowLockToggleGroup(false);
}
anchorPrefab.SetActive(false);
SetButtonText(btnStartAnchorCreateMode, StartAnchorCreateModeText);
RestoreRootToAppliedAnchorOrInitial();
}
private void RestoreRootToAppliedAnchorOrInitial()
{
if (GameObjectRoot == null)
{
return;
}
if (appliedAnchor != null)
{
bool locateResult = appliedAnchor.LocateAnchor(GameObjectRoot.transform);
if (locateResult)
{
return;
}
GameObjectRoot.transform.SetPositionAndRotation(appliedAnchor.transform.position, appliedAnchor.transform.rotation);
return;
}
GameObjectRoot.transform.SetPositionAndRotation(initialRootPosition, initialRootRotation);
}
/// <summary>
/// 对创建模式下的预览锚点做旋转轴约束。
/// </summary>
/// <remarks>
/// 约束只影响当前可拖拽的预览锚点,不会修改已经创建完成的持久化锚点。
/// 锁定后的轴会保持"进入创建模式时"的标准朝向值,从而让放置结果更统一。
/// </remarks>
private void ApplyPreviewRotationConstraints()
{
if (anchorPrefab == null)
{
return;
}
anchorPrefab.transform.rotation = PXRSample_SpatialAnchorRotationConstraintGrabTransformer.ConstrainRotation(
anchorPrefab.transform.rotation,
previewRotationReference,
lockAnchorRotationX,
lockAnchorRotationY,
lockAnchorRotationZ);
}
/// <summary>
/// 更新顶部标题文字。
/// </summary>
private void UpdateHeaderText(int anchorCount)
{
if (headerText == null)
{
return;
}
headerText.text = anchorCount > 0 ? $"{HeaderTextPrefix} {anchorCount}" : HeaderTextPrefix;
}
/// <summary>
/// 统一更新已加载锚点的可视化状态,并同步刷新"显示/隐藏所有空间锚点"按钮文案。
/// </summary>
private void SetLoadedAnchorVisuals(bool isVisible, bool updateAnchorObjects = true)
{
isShowingLoadedAnchorVisuals = isVisible && anchorList.Count > 0;
if (updateAnchorObjects)
{
foreach (var anchor in anchorList.Values)
{
if (anchor != null)
{
anchor.ShowPreviewAndUICavas(isShowingLoadedAnchorVisuals);
anchor.ShowLockToggleGroup(false);
}
}
}
SetButtonText(btnLoadAnchors, isShowingLoadedAnchorVisuals ? HideAllAnchorsText : ShowAllAnchorsText);
}
private void SetButtonText(Button button, string text)
{
if (button == null)
{
return;
}
Text buttonText = button.GetComponentInChildren<Text>(true);
if (buttonText != null)
{
buttonText.text = text;
}
}
// 设置日志信息
public void SetLogInfo(string log)
{
if (logQueue.Count > 0 && logQueue.Last() == log)
{
return;
}
if (logQueue.Count >= maxLogCount) // 如果日志队列达到最大条数
{
logQueue.Dequeue(); // 移除最旧的日志
}
logQueue.Enqueue(log); // 添加新的日志
Debug.Log("PXRSample_SpatialAnchorManager" + log); // 输出到控制台
if (tipsText != null)
{
tipsText.text = string.Join("\n", logQueue.ToArray()); // 更新提示文本显示日志
}
}
}
csharp
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
using UnityEngine.XR.Interaction.Toolkit.Transformers;
/// <summary>
/// 在抓取交互处理阶段直接约束 SpatialAnchor 的旋转。
/// </summary>
/// <remarks>
/// 这个约束器和管理器里最后兜底的旋转修正不同:
/// 它会在 XRGrabInteractable 计算目标位姿时立即生效,
/// 因此用户拖拽过程中就无法把被锁定的轴真正旋转出去。
/// </remarks>
public class PXRSample_SpatialAnchorRotationConstraintGrabTransformer : XRBaseGrabTransformer
{
private const float AxisEpsilon = 0.0001f;
private bool lockRotationX;
private bool lockRotationY;
private bool lockRotationZ;
private Quaternion referenceRotation = Quaternion.identity;
private Quaternion initialInteractorRotation = Quaternion.identity;
private Quaternion initialConstrainedRotation = Quaternion.identity;
private bool hasGrabReference;
/// <inheritdoc />
protected override RegistrationMode registrationMode => RegistrationMode.SingleAndMultiple;
/// <summary>
/// 配置当前抓取约束器的旋转锁定规则。
/// </summary>
public void Configure(bool lockX, bool lockY, bool lockZ, Quaternion reference)
{
lockRotationX = lockX;
lockRotationY = lockY;
lockRotationZ = lockZ;
referenceRotation = reference;
hasGrabReference = false;
}
/// <summary>
/// 根据锁定规则对输入旋转做"世界坐标轴约束"。
/// 被锁定的轴不会再去读取或回写任何欧拉角参考值,
/// 而是直接把对应世界轴的旋转增量清零,只保留未锁定轴的世界轴增量。
/// </summary>
public static Quaternion ConstrainRotation(Quaternion inputRotation, Quaternion referenceRotation, bool lockX, bool lockY, bool lockZ)
{
if (!lockX && !lockY && !lockZ)
{
return inputRotation;
}
if (GetUnlockedAxisCount(lockX, lockY, lockZ) == 1)
{
return ConstrainSingleFreeWorldAxisRotation(inputRotation, referenceRotation, lockX, lockY, lockZ);
}
Quaternion worldDeltaRotation = inputRotation * Quaternion.Inverse(referenceRotation);
Vector3 worldAxisDelta = GetWorldAxisAngles(worldDeltaRotation);
return ApplyUnlockedWorldAxisDelta(referenceRotation, worldAxisDelta, lockX, lockY, lockZ);
}
/// <inheritdoc />
public override void OnGrab(XRGrabInteractable grabInteractable)
{
base.OnGrab(grabInteractable);
CaptureGrabReference(grabInteractable);
}
/// <inheritdoc />
public override void OnGrabCountChanged(XRGrabInteractable grabInteractable, Pose targetPose, Vector3 localScale)
{
base.OnGrabCountChanged(grabInteractable, targetPose, localScale);
CaptureGrabReference(grabInteractable);
}
/// <inheritdoc />
public override void Process(XRGrabInteractable grabInteractable, XRInteractionUpdateOrder.UpdatePhase updatePhase, ref Pose targetPose, ref Vector3 localScale)
{
if (!hasGrabReference)
{
CaptureGrabReference(grabInteractable);
}
targetPose.rotation = ComputeConstrainedRotationFromInteractor(grabInteractable, targetPose.rotation);
// 默认抓取器先根据手部位姿计算出目标位置和目标旋转。
// 当我们在这里强行改写目标旋转后,如果不同时重算目标位置,
// 动态 Attach 点与手部 Attach 点之间的空间关系就会被打破,
// 表现出来就会像"物体几乎拖不动"或者"位置被锁死"。
//
// 因此这里要基于"当前交互器 Attach 点位置"以及"物体自身 Attach 点的局部偏移"
// 重新求一次目标位置,让受限后的旋转和抓取位置保持一致。
if (grabInteractable.interactorsSelecting.Count <= 0)
{
return;
}
IXRSelectInteractor interactor = grabInteractable.interactorsSelecting[0];
Transform interactorAttachTransform = interactor.GetAttachTransform(grabInteractable);
Transform grabAttachTransform = grabInteractable.GetAttachTransform(interactor);
if (interactorAttachTransform == null || grabAttachTransform == null)
{
return;
}
Vector3 localAttachPosition = grabInteractable.transform.InverseTransformPoint(grabAttachTransform.position);
targetPose.position = interactorAttachTransform.position - targetPose.rotation * localAttachPosition;
}
/// <summary>
/// 将未锁定的世界轴旋转增量按固定世界轴顺序叠加到基准旋转上。
/// </summary>
private static Quaternion ApplyUnlockedWorldAxisDelta(Quaternion baseRotation, Vector3 worldAxisDelta, bool lockX, bool lockY, bool lockZ)
{
Quaternion constrainedRotation = baseRotation;
if (!lockX)
{
constrainedRotation = Quaternion.AngleAxis(worldAxisDelta.x, Vector3.right) * constrainedRotation;
}
if (!lockY)
{
constrainedRotation = Quaternion.AngleAxis(worldAxisDelta.y, Vector3.up) * constrainedRotation;
}
if (!lockZ)
{
constrainedRotation = Quaternion.AngleAxis(worldAxisDelta.z, Vector3.forward) * constrainedRotation;
}
return constrainedRotation;
}
/// <summary>
/// 当只放开一个轴时,直接把物体约束为"绕单个固定世界轴"的绝对旋转。
/// 这样可以确保:
/// 1. 只放开 Y 时,Y 永远等于地面法线方向;
/// 2. 只放开 X 时,X 永远等于世界右方向;
/// 3. 只放开 Z 时,Z 永远等于世界前方向。
/// </summary>
private static Quaternion ConstrainSingleFreeWorldAxisRotation(Quaternion inputRotation, Quaternion referenceRotation, bool lockX, bool lockY, bool lockZ)
{
if (!lockY)
{
return ConstrainAroundWorldUp(inputRotation, referenceRotation);
}
if (!lockX)
{
return ConstrainAroundWorldRight(inputRotation, referenceRotation);
}
return ConstrainAroundWorldForward(inputRotation, referenceRotation);
}
private static Quaternion ConstrainAroundWorldUp(Quaternion inputRotation, Quaternion referenceRotation)
{
Vector3 referenceForward = GetProjectedAxis(referenceRotation * Vector3.forward, Vector3.up, Vector3.forward);
Vector3 inputForward = GetProjectedAxis(inputRotation * Vector3.forward, Vector3.up, referenceForward);
float deltaAngle = Vector3.SignedAngle(referenceForward, inputForward, Vector3.up);
Quaternion baseRotation = Quaternion.LookRotation(referenceForward, Vector3.up);
return Quaternion.AngleAxis(deltaAngle, Vector3.up) * baseRotation;
}
private static Quaternion ConstrainAroundWorldRight(Quaternion inputRotation, Quaternion referenceRotation)
{
Vector3 referenceUp = GetProjectedAxis(referenceRotation * Vector3.up, Vector3.right, Vector3.up);
Vector3 inputUp = GetProjectedAxis(inputRotation * Vector3.up, Vector3.right, referenceUp);
float deltaAngle = Vector3.SignedAngle(referenceUp, inputUp, Vector3.right);
Quaternion baseRotation = Quaternion.LookRotation(Vector3.Cross(Vector3.right, referenceUp).normalized, referenceUp);
return Quaternion.AngleAxis(deltaAngle, Vector3.right) * baseRotation;
}
private static Quaternion ConstrainAroundWorldForward(Quaternion inputRotation, Quaternion referenceRotation)
{
Vector3 referenceUp = GetProjectedAxis(referenceRotation * Vector3.up, Vector3.forward, Vector3.up);
Vector3 inputUp = GetProjectedAxis(inputRotation * Vector3.up, Vector3.forward, referenceUp);
float deltaAngle = Vector3.SignedAngle(referenceUp, inputUp, Vector3.forward);
Quaternion baseRotation = Quaternion.LookRotation(Vector3.forward, referenceUp);
return Quaternion.AngleAxis(deltaAngle, Vector3.forward) * baseRotation;
}
/// <summary>
/// 计算手部当前世界旋转相对抓取开始时的世界轴旋转增量,
/// 再叠加到抓取开始时缓存的受限旋转上。
/// </summary>
private Quaternion ComputeConstrainedRotationFromInteractor(XRGrabInteractable grabInteractable, Quaternion fallbackRotation)
{
if (grabInteractable.interactorsSelecting.Count <= 0)
{
return ConstrainRotation(fallbackRotation, referenceRotation, lockRotationX, lockRotationY, lockRotationZ);
}
Transform interactorAttachTransform = grabInteractable.interactorsSelecting[0].GetAttachTransform(grabInteractable);
if (interactorAttachTransform == null)
{
return ConstrainRotation(fallbackRotation, referenceRotation, lockRotationX, lockRotationY, lockRotationZ);
}
Quaternion interactorDeltaRotation = interactorAttachTransform.rotation * Quaternion.Inverse(initialInteractorRotation);
Vector3 interactorAxisAngles = GetWorldAxisAngles(interactorDeltaRotation);
return ApplyUnlockedWorldAxisDelta(initialConstrainedRotation, interactorAxisAngles, lockRotationX, lockRotationY, lockRotationZ);
}
/// <summary>
/// 记录抓取开始时的手部世界旋转和物体世界旋转,供后续做统一的世界轴约束。
/// </summary>
private void CaptureGrabReference(XRGrabInteractable grabInteractable)
{
if (grabInteractable.interactorsSelecting.Count <= 0)
{
hasGrabReference = false;
return;
}
Transform interactorAttachTransform = grabInteractable.interactorsSelecting[0].GetAttachTransform(grabInteractable);
if (interactorAttachTransform == null)
{
hasGrabReference = false;
return;
}
initialConstrainedRotation = ConstrainRotation(grabInteractable.transform.rotation, referenceRotation, lockRotationX, lockRotationY, lockRotationZ);
initialInteractorRotation = interactorAttachTransform.rotation;
hasGrabReference = true;
}
/// <summary>
/// 将一个相对旋转投影到世界 X/Y/Z 三个轴上,得到更稳定的有符号角度。
/// </summary>
/// <remarks>
/// 这里不再直接读取欧拉角差值,因为欧拉角在某些姿态下会发生耦合和跳变,
/// 尤其是只放开单个世界轴时,最容易出现"抽动"或"来回回弹"。
/// 当前实现通过把旋转后的参考向量投影到指定世界轴的法平面上,再求有符号夹角,
/// 从而得到绕该世界轴的稳定旋转量。
/// </remarks>
private static Vector3 GetWorldAxisAngles(Quaternion deltaRotation)
{
return new Vector3(
GetSignedAngleAroundWorldAxis(deltaRotation, Vector3.right),
GetSignedAngleAroundWorldAxis(deltaRotation, Vector3.up),
GetSignedAngleAroundWorldAxis(deltaRotation, Vector3.forward));
}
/// <summary>
/// 计算一个相对旋转绕指定世界轴的有符号角度。
/// </summary>
private static float GetSignedAngleAroundWorldAxis(Quaternion deltaRotation, Vector3 axis)
{
Vector3 referenceDirection = GetStablePerpendicular(axis);
Vector3 rotatedDirection = deltaRotation * referenceDirection;
Vector3 projectedReference = Vector3.ProjectOnPlane(referenceDirection, axis);
Vector3 projectedRotated = Vector3.ProjectOnPlane(rotatedDirection, axis);
if (projectedReference.sqrMagnitude < AxisEpsilon || projectedRotated.sqrMagnitude < AxisEpsilon)
{
return 0f;
}
return Vector3.SignedAngle(projectedReference.normalized, projectedRotated.normalized, axis);
}
/// <summary>
/// 获取一个与指定轴稳定垂直的参考向量。
/// </summary>
private static Vector3 GetStablePerpendicular(Vector3 axis)
{
Vector3 candidate = Mathf.Abs(Vector3.Dot(axis.normalized, Vector3.up)) < 0.99f ? Vector3.up : Vector3.right;
Vector3 perpendicular = Vector3.ProjectOnPlane(candidate, axis);
return perpendicular.sqrMagnitude < AxisEpsilon ? Vector3.forward : perpendicular.normalized;
}
private static int GetUnlockedAxisCount(bool lockX, bool lockY, bool lockZ)
{
int unlockedAxisCount = 0;
if (!lockX)
{
unlockedAxisCount++;
}
if (!lockY)
{
unlockedAxisCount++;
}
if (!lockZ)
{
unlockedAxisCount++;
}
return unlockedAxisCount;
}
private static Vector3 GetProjectedAxis(Vector3 sourceDirection, Vector3 planeNormal, Vector3 fallbackDirection)
{
Vector3 projectedDirection = Vector3.ProjectOnPlane(sourceDirection, planeNormal);
if (projectedDirection.sqrMagnitude >= AxisEpsilon)
{
return projectedDirection.normalized;
}
projectedDirection = Vector3.ProjectOnPlane(fallbackDirection, planeNormal);
if (projectedDirection.sqrMagnitude >= AxisEpsilon)
{
return projectedDirection.normalized;
}
return GetStablePerpendicular(planeNormal);
}
}