// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Utilities; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; namespace Microsoft.MixedReality.Toolkit.Boundary { public abstract class BaseBoundarySystem : BaseCoreSystem, IMixedRealityBoundarySystem { /// /// Constructor. /// /// The configuration profile for the service. /// The application's configured . protected BaseBoundarySystem( MixedRealityBoundaryVisualizationProfile profile, ExperienceScale scale) : base(profile) { Scale = scale; BoundaryProfile = profile; } /// /// Reads the visualization profile contents and stores the values in class properties. /// private void ReadProfile() { if (BoundaryProfile == null) { return; } BoundaryHeight = BoundaryProfile.BoundaryHeight; ShowFloor = BoundaryProfile.ShowFloor; FloorPhysicsLayer = BoundaryProfile.FloorPhysicsLayer; ShowPlayArea = BoundaryProfile.ShowPlayArea; PlayAreaPhysicsLayer = BoundaryProfile.PlayAreaPhysicsLayer; ShowTrackedArea = BoundaryProfile.ShowTrackedArea; TrackedAreaPhysicsLayer = BoundaryProfile.TrackedAreaPhysicsLayer; ShowBoundaryWalls = BoundaryProfile.ShowBoundaryWalls; BoundaryWallsPhysicsLayer = BoundaryProfile.BoundaryWallsPhysicsLayer; ShowBoundaryCeiling = BoundaryProfile.ShowBoundaryCeiling; CeilingPhysicsLayer = BoundaryProfile.CeilingPhysicsLayer; } /// /// Whether any XR device is present. /// [System.Obsolete("This value is no longer used.")] protected virtual bool IsXRDevicePresent { get; } = true; #region IMixedRealityService Implementation private MixedRealityBoundaryVisualizationProfile BoundaryProfile { get; } private BoundaryEventData boundaryEventData = null; /// public override string Name { get; protected set; } = "Mixed Reality Boundary System"; /// public override void Initialize() { // Initialize this value earlier than other systems, so we can use it to block boundary events being raised too early IsInitialized = false; // The profile needs to be read on initialization to ensure that re-initialization // after profile change reads the correct data. ReadProfile(); if (!Application.isPlaying || !DeviceUtility.IsPresent) { return; } boundaryEventData = new BoundaryEventData(EventSystem.current); SetTrackingSpace(); CalculateBoundaryBounds(); base.Initialize(); RefreshVisualization(); RaiseBoundaryVisualizationChanged(); } #if UNITY_EDITOR public override void Update() { base.Update(); // If a device is attached late, initialize with the new state of the world if (!IsInitialized && DeviceUtility.IsPresent) { Initialize(); } } #endif // UNITY_EDITOR /// public override void Destroy() { // First, detach the child objects (we are tracking them separately) // and clean up the parent. if (boundaryVisualizationParent != null) { if (Application.isEditor) { Object.DestroyImmediate(boundaryVisualizationParent); } else { boundaryVisualizationParent.transform.DetachChildren(); Object.Destroy(boundaryVisualizationParent); } boundaryVisualizationParent = null; } // Next, clean up the detached children. if (currentFloorObject != null) { if (Application.isEditor) { Object.DestroyImmediate(currentFloorObject); } else { Object.Destroy(currentFloorObject); } currentFloorObject = null; } if (currentPlayAreaObject != null) { if (Application.isEditor) { Object.DestroyImmediate(currentPlayAreaObject); } else { Object.Destroy(currentPlayAreaObject); } currentPlayAreaObject = null; } if (currentTrackedAreaObject != null) { if (Application.isEditor) { Object.DestroyImmediate(currentTrackedAreaObject); } else { Object.Destroy(currentTrackedAreaObject); } currentTrackedAreaObject = null; } if (currentBoundaryWallObject != null) { if (Application.isEditor) { Object.DestroyImmediate(currentBoundaryWallObject); } else { Object.Destroy(currentBoundaryWallObject); } currentBoundaryWallObject = null; } if (currentCeilingObject != null) { if (Application.isEditor) { Object.DestroyImmediate(currentCeilingObject); } else { Object.Destroy(currentCeilingObject); } currentCeilingObject = null; } showFloor = false; showPlayArea = false; showTrackedArea = false; showBoundaryWalls = false; showCeiling = false; RaiseBoundaryVisualizationChanged(); base.Destroy(); } /// /// Raises an event to indicate that the visualization of the boundary has been changed by the boundary system. /// private void RaiseBoundaryVisualizationChanged() { if (!Application.isPlaying || boundaryEventData == null) { return; } boundaryEventData.Initialize(this, ShowFloor, ShowPlayArea, ShowTrackedArea, ShowBoundaryWalls, ShowBoundaryCeiling); HandleEvent(boundaryEventData, OnVisualizationChanged); } /// /// Event sent whenever the boundary visualization changes. /// private static readonly ExecuteEvents.EventFunction OnVisualizationChanged = delegate (IMixedRealityBoundaryHandler handler, BaseEventData eventData) { var boundaryEventData = ExecuteEvents.ValidateEventData(eventData); handler.OnBoundaryVisualizationChanged(boundaryEventData); }; #endregion IMixedRealityService Implementation #region IMixedRealtyEventSystem Implementation /// public override void HandleEvent(BaseEventData eventData, ExecuteEvents.EventFunction eventHandler) { base.HandleEvent(eventData, eventHandler); } /// /// Registers the GameObject to listen for boundary events. /// public override void Register(GameObject listener) { base.Register(listener); } /// /// UnRegisters the GameObject to listen for boundary events. /// /// public override void Unregister(GameObject listener) { base.Unregister(listener); } #endregion #region IMixedRealityEventSource Implementation /// bool IEqualityComparer.Equals(object x, object y) { // There shouldn't be other Boundary Managers to compare to. return false; } /// public int GetHashCode(object obj) { return Mathf.Abs(SourceName.GetHashCode()); } /// public uint SourceId { get; } = 0; /// public string SourceName { get; } = "Mixed Reality Boundary System"; #endregion IMixedRealityEventSource Implementation #region IMixedRealityBoundarySystem Implementation /// /// The thickness of three dimensional generated boundary objects. /// private const float boundaryObjectThickness = 0.005f; /// /// A small offset to avoid render conflicts, primarily with the floor. /// /// /// This offset is used to avoid consuming multiple physics layers. /// private const float boundaryObjectRenderOffset = 0.001f; private GameObject boundaryVisualizationParent; /// /// Parent GameObject which will encapsulate all of the teleportable boundary visualizations. /// private GameObject BoundaryVisualizationParent { get { if (boundaryVisualizationParent != null) { return boundaryVisualizationParent; } var visualizationParent = new GameObject("Boundary System Visualizations"); MixedRealityPlayspace.AddChild(visualizationParent.transform); return boundaryVisualizationParent = visualizationParent; } } /// /// Layer used to tell the (non-floor) boundary objects to not accept raycasts /// private readonly int ignoreRaycastLayerValue = 2; private MixedRealityBoundaryVisualizationProfile boundaryVisualizationProfile = null; /// public MixedRealityBoundaryVisualizationProfile BoundaryVisualizationProfile { get { if (boundaryVisualizationProfile == null) { boundaryVisualizationProfile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; } return boundaryVisualizationProfile; } } /// public ExperienceScale Scale { get; set; } /// public float BoundaryHeight { get; set; } = 3f; private bool showFloor = false; /// public bool ShowFloor { get { return showFloor; } set { if (showFloor != value) { showFloor = value; PropertyAction(value, currentFloorObject, () => GetFloorVisualization()); } } } private bool showPlayArea = false; private int floorPhysicsLayer; /// public int FloorPhysicsLayer { get { if (currentFloorObject != null) { floorPhysicsLayer = currentFloorObject.layer; } return floorPhysicsLayer; } set { floorPhysicsLayer = value; if (currentFloorObject != null) { currentFloorObject.layer = floorPhysicsLayer; } } } /// public bool ShowPlayArea { get { return showPlayArea; } set { if (showPlayArea != value) { showPlayArea = value; PropertyAction(value, currentPlayAreaObject, () => GetPlayAreaVisualization()); } } } private bool showTrackedArea = false; private int playAreaPhysicsLayer; /// public int PlayAreaPhysicsLayer { get { if (currentPlayAreaObject != null) { playAreaPhysicsLayer = currentPlayAreaObject.layer; } return playAreaPhysicsLayer; } set { playAreaPhysicsLayer = value; if (currentPlayAreaObject != null) { currentPlayAreaObject.layer = playAreaPhysicsLayer; } } } /// public bool ShowTrackedArea { get { return showTrackedArea; } set { if (showTrackedArea != value) { showTrackedArea = value; PropertyAction(value, currentTrackedAreaObject, () => GetTrackedAreaVisualization()); } } } private bool showBoundaryWalls = false; private int trackedAreaPhysicsLayer; /// public int TrackedAreaPhysicsLayer { get { if (currentTrackedAreaObject != null) { trackedAreaPhysicsLayer = currentTrackedAreaObject.layer; } return trackedAreaPhysicsLayer; } set { trackedAreaPhysicsLayer = value; if (currentTrackedAreaObject != null) { currentTrackedAreaObject.layer = trackedAreaPhysicsLayer; } } } /// public bool ShowBoundaryWalls { get { return showBoundaryWalls; } set { if (showBoundaryWalls != value) { showBoundaryWalls = value; PropertyAction(value, currentBoundaryWallObject, () => GetBoundaryWallVisualization()); } } } private bool showCeiling = false; private int boundaryWallsPhysicsLayer; /// public int BoundaryWallsPhysicsLayer { get { if (currentBoundaryWallObject != null) { boundaryWallsPhysicsLayer = currentBoundaryWallObject.layer; } return boundaryWallsPhysicsLayer; } set { boundaryWallsPhysicsLayer = value; if (currentBoundaryWallObject != null) { currentBoundaryWallObject.layer = boundaryWallsPhysicsLayer; } } } /// public bool ShowBoundaryCeiling { get { return showCeiling; } set { if (showCeiling != value) { showCeiling = value; PropertyAction(value, currentCeilingObject, () => GetBoundaryCeilingVisualization()); } } } private int ceilingPhysicsLayer; /// public int CeilingPhysicsLayer { get { if (currentCeilingObject != null) { ceilingPhysicsLayer = currentCeilingObject.layer; } return ceilingPhysicsLayer; } set { ceilingPhysicsLayer = value; if (currentCeilingObject != null) { currentFloorObject.layer = ceilingPhysicsLayer; } } } private void PropertyAction(bool value, GameObject boundaryObject, System.Action getVisualizationMethod, bool raiseEvent = true) { // If not done initializing, no need to raise the changed event or check the visualization. // These will both happen at the end of the initialization flow. if (!IsInitialized) { return; } if (value && (boundaryObject == null)) { getVisualizationMethod(); } if (boundaryObject != null) { boundaryObject.SetActive(value); } if (raiseEvent) { RaiseBoundaryVisualizationChanged(); } } /// /// Refreshes the current boundary visualizations without raising changed events. /// Used during the initialization flow. /// private void RefreshVisualization() { PropertyAction(ShowFloor, currentFloorObject, () => GetFloorVisualization(), false); PropertyAction(ShowPlayArea, currentPlayAreaObject, () => GetPlayAreaVisualization(), false); PropertyAction(ShowTrackedArea, currentTrackedAreaObject, () => GetTrackedAreaVisualization(), false); PropertyAction(ShowBoundaryWalls, currentBoundaryWallObject, () => GetBoundaryWallVisualization(), false); PropertyAction(ShowBoundaryCeiling, currentCeilingObject, () => GetBoundaryCeilingVisualization(), false); } /// public Edge[] Bounds { get; protected set; } = System.Array.Empty(); /// public float? FloorHeight { get; protected set; } = null; /// public bool Contains(Vector3 location, BoundaryType boundaryType = BoundaryType.TrackedArea) { if (!EdgeUtilities.IsValidPoint(location)) { // Invalid location. return false; } if (!FloorHeight.HasValue) { // No floor. return false; } // Handle the user teleporting (boundary moves with them). location = MixedRealityPlayspace.InverseTransformPoint(location); if (FloorHeight.Value > location.y || BoundaryHeight < location.y) { // Location below the floor or above the boundary height. return false; } // Boundary coordinates are always "on the floor" Vector2 point = new Vector2(location.x, location.z); if (boundaryType == BoundaryType.PlayArea) { // Check the inscribed rectangle. if (RectangularBounds != null) { return RectangularBounds.IsInsideBoundary(point); } } else if (boundaryType == BoundaryType.TrackedArea) { // Check the geometry return EdgeUtilities.IsInsideBoundary(Bounds, point); } // Not in either boundary type. return false; } /// public bool TryGetRectangularBoundsParams(out Vector2 center, out float angle, out float width, out float height) { if (RectangularBounds == null || !RectangularBounds.IsValid) { center = EdgeUtilities.InvalidPoint; angle = 0f; width = 0f; height = 0f; return false; } // Handle the user teleporting (boundary moves with them). Vector3 transformedCenter = MixedRealityPlayspace.TransformPoint( new Vector3(RectangularBounds.Center.x, 0f, RectangularBounds.Center.y)); center = new Vector2(transformedCenter.x, transformedCenter.z); angle = RectangularBounds.Angle; width = RectangularBounds.Width; height = RectangularBounds.Height; return true; } /// public GameObject GetFloorVisualization() { if (!Application.isPlaying) { return null; } if (currentFloorObject != null) { return currentFloorObject; } MixedRealityBoundaryVisualizationProfile profile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; if (profile == null) { return null; } if (!FloorHeight.HasValue) { // We were unable to locate the floor. return null; } Vector2 floorScale = profile.FloorScale; // Render the floor. currentFloorObject = GameObject.CreatePrimitive(PrimitiveType.Cube); currentFloorObject.name = "Boundary System Floor"; currentFloorObject.transform.localScale = new Vector3(floorScale.x, boundaryObjectThickness, floorScale.y); currentFloorObject.transform.Translate(new Vector3( MixedRealityPlayspace.Position.x, FloorHeight.Value - (currentFloorObject.transform.localScale.y * 0.5f), MixedRealityPlayspace.Position.z)); currentFloorObject.layer = FloorPhysicsLayer; currentFloorObject.GetComponent().sharedMaterial = profile.FloorMaterial; return currentFloorObject; } /// public GameObject GetPlayAreaVisualization() { if (!Application.isPlaying) { return null; } if (currentPlayAreaObject != null) { return currentPlayAreaObject; } MixedRealityBoundaryVisualizationProfile profile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; if (profile == null) { return null; } // Get the rectangular bounds. Vector2 center; float angle; float width; float height; if (!TryGetRectangularBoundsParams(out center, out angle, out width, out height)) { // No rectangular bounds, therefore cannot create the play area. return null; } // Render the rectangular bounds. if (!EdgeUtilities.IsValidPoint(center)) { // Invalid rectangle / play area not found return null; } currentPlayAreaObject = GameObject.CreatePrimitive(PrimitiveType.Quad); currentPlayAreaObject.name = "Play Area"; currentPlayAreaObject.layer = PlayAreaPhysicsLayer; currentPlayAreaObject.transform.Translate(new Vector3(center.x, boundaryObjectRenderOffset, center.y)); currentPlayAreaObject.transform.Rotate(new Vector3(90, -angle, 0)); currentPlayAreaObject.transform.localScale = new Vector3(width, height, 1.0f); currentPlayAreaObject.GetComponent().sharedMaterial = profile.PlayAreaMaterial; currentPlayAreaObject.transform.parent = BoundaryVisualizationParent.transform; return currentPlayAreaObject; } /// public GameObject GetTrackedAreaVisualization() { if (!Application.isPlaying) { return null; } if (currentTrackedAreaObject != null) { return currentTrackedAreaObject; } MixedRealityBoundaryVisualizationProfile profile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; if (profile == null) { return null; } if (Bounds.Length == 0) { // If we do not have boundary edges, we cannot render them. return null; } // Get the line vertices List lineVertices = new List(); for (int i = 0; i < Bounds.Length; i++) { lineVertices.Add(new Vector3(Bounds[i].PointA.x, 0f, Bounds[i].PointA.y)); } // Add the first vertex again to ensure the loop closes. lineVertices.Add(lineVertices[0]); // We use an empty object and attach a line renderer. currentTrackedAreaObject = new GameObject("Tracked Area") { layer = ignoreRaycastLayerValue }; currentTrackedAreaObject.AddComponent(); currentTrackedAreaObject.transform.Translate(new Vector3( MixedRealityPlayspace.Position.x, boundaryObjectRenderOffset, MixedRealityPlayspace.Position.z)); currentPlayAreaObject.layer = TrackedAreaPhysicsLayer; // Configure the renderer properties. float lineWidth = 0.01f; LineRenderer lineRenderer = currentTrackedAreaObject.GetComponent(); lineRenderer.sharedMaterial = profile.TrackedAreaMaterial; lineRenderer.useWorldSpace = false; lineRenderer.startWidth = lineWidth; lineRenderer.endWidth = lineWidth; lineRenderer.positionCount = lineVertices.Count; lineRenderer.SetPositions(lineVertices.ToArray()); currentTrackedAreaObject.transform.parent = BoundaryVisualizationParent.transform; return currentTrackedAreaObject; } /// public GameObject GetBoundaryWallVisualization() { if (!Application.isPlaying) { return null; } if (currentBoundaryWallObject != null) { return currentBoundaryWallObject; } MixedRealityBoundaryVisualizationProfile profile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; if (profile == null) { return null; } if (!FloorHeight.HasValue) { // We need a floor on which to place the walls. return null; } if (Bounds.Length == 0) { // If we do not have boundary edges, we cannot render walls. return null; } currentBoundaryWallObject = new GameObject("Tracked Area Walls") { layer = BoundaryWallsPhysicsLayer }; // Create and parent the child objects float wallDepth = boundaryObjectThickness; for (int i = 0; i < Bounds.Length; i++) { GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube); wall.name = $"Wall {i}"; wall.GetComponent().sharedMaterial = profile.BoundaryWallMaterial; wall.transform.localScale = new Vector3((Bounds[i].PointB - Bounds[i].PointA).magnitude, BoundaryHeight, wallDepth); wall.layer = ignoreRaycastLayerValue; // Position and rotate the wall. Vector2 mid = Vector2.Lerp(Bounds[i].PointA, Bounds[i].PointB, 0.5f); wall.transform.position = new Vector3(mid.x, (BoundaryHeight * 0.5f), mid.y); float rotationAngle = MathUtilities.GetAngleBetween(Bounds[i].PointB, Bounds[i].PointA); wall.transform.rotation = Quaternion.Euler(0.0f, -rotationAngle, 0.0f); wall.transform.parent = currentBoundaryWallObject.transform; } currentBoundaryWallObject.transform.parent = BoundaryVisualizationParent.transform; return currentBoundaryWallObject; } /// public GameObject GetBoundaryCeilingVisualization() { if (!Application.isPlaying) { return null; } if (currentCeilingObject != null) { return currentCeilingObject; } MixedRealityBoundaryVisualizationProfile profile = ConfigurationProfile as MixedRealityBoundaryVisualizationProfile; if (profile == null) { return null; } if (Bounds.Length == 0) { // If we do not have boundary edges, we cannot render a ceiling. return null; } // Get the smallest rectangle that contains the entire boundary. Bounds boundaryBoundingBox = new Bounds(); for (int i = 0; i < Bounds.Length; i++) { // The boundary geometry is a closed loop. As such, we can encapsulate only PointA of each Edge. boundaryBoundingBox.Encapsulate(new Vector3(Bounds[i].PointA.x, BoundaryHeight * 0.5f, Bounds[i].PointA.y)); } // Render the ceiling. float ceilingDepth = boundaryObjectThickness; currentCeilingObject = GameObject.CreatePrimitive(PrimitiveType.Cube); currentCeilingObject.name = "Ceiling"; currentCeilingObject.layer = ignoreRaycastLayerValue; currentCeilingObject.transform.localScale = new Vector3(boundaryBoundingBox.size.x, ceilingDepth, boundaryBoundingBox.size.z); currentCeilingObject.transform.Translate(new Vector3( boundaryBoundingBox.center.x, BoundaryHeight + (currentCeilingObject.transform.localScale.y * 0.5f), boundaryBoundingBox.center.z)); currentCeilingObject.GetComponent().sharedMaterial = profile.BoundaryCeilingMaterial; currentCeilingObject.layer = CeilingPhysicsLayer; currentCeilingObject.transform.parent = BoundaryVisualizationParent.transform; return currentCeilingObject; } #endregion IMixedRealityBoundarySystem Implementation /// /// The largest rectangle that is contained withing the play space geometry. /// protected InscribedRectangle RectangularBounds = null; private GameObject currentFloorObject = null; private GameObject currentPlayAreaObject = null; private GameObject currentTrackedAreaObject = null; private GameObject currentBoundaryWallObject = null; private GameObject currentCeilingObject = null; /// /// Retrieves the boundary geometry. /// /// A list of geometry points, or null if geometry was unavailable. protected abstract List GetBoundaryGeometry(); /// /// Updates the tracking space on the XR device. /// protected abstract void SetTrackingSpace(); /// /// Retrieves the boundary geometry and creates the boundary and inscribed play space volumes. /// private void CalculateBoundaryBounds() { // Reset the bounds Bounds = System.Array.Empty(); FloorHeight = null; RectangularBounds = null; // Get the boundary geometry. var boundaryGeometry = GetBoundaryGeometry(); if (boundaryGeometry != null && boundaryGeometry.Count > 0) { // Get the boundary geometry. var boundaryEdges = new List(0); // FloorHeight starts out as null. Use a suitably high value for the floor to ensure // that we do not accidentally set it too low. float floorHeight = float.MaxValue; for (int i = 0; i < boundaryGeometry.Count; i++) { Vector3 pointA = boundaryGeometry[i]; Vector3 pointB = boundaryGeometry[(i + 1) % boundaryGeometry.Count]; boundaryEdges.Add(new Edge(pointA, pointB)); floorHeight = Mathf.Min(floorHeight, boundaryGeometry[i].y); } FloorHeight = floorHeight; Bounds = boundaryEdges.ToArray(); CreateInscribedBounds(); } else { Debug.LogWarning("Failed to calculate boundary bounds."); } } /// /// Creates the two dimensional volume described by the largest rectangle that /// is contained withing the play space geometry and the configured height. /// private void CreateInscribedBounds() { // We always use the same seed so that from run to run, the inscribed bounds are consistent. RectangularBounds = new InscribedRectangle(Bounds, Mathf.Abs("Mixed Reality Toolkit".GetHashCode())); } } }