Creator Diary

As we were publishing "Placing an Interface in Mixed Reality", we were already considering refinements to the placement approach. The greatest problem faced by the sphere-cast approach is a long, narrow interface. Such interfaces create unnecessarily large bounding spheres. We ran into this problem immediately when we began experimenting with a wider interface for one of our screens.

The prototype version of this interface was over a meter in radius, which created a placement sphere with a diameter of over two meters. As you can imagine, it's almost impossible to find a suitable position for a sphere this large in an average room, even though the rectangular dimensions of the interface would fit comfortably over a kitchen table.

It was time for us to switch to a cylinder-cast solution, which fits tall and wide interfaces much more comfortably.

The modifications to the code were not extensive; the main work is in constructing a suitable test cylinder in Unity. Unity's cylinder primitive has a capsule collider by default for some reason, and we want to test with an actual cylindrical mesh. In our own implementation we use a prefab for this, but in the code we've shared below the code to construct the object is included.

We're excited today to share this entire placement class with you. Hopefully this can help jump start your XR development! If you find the included code useful, tweet at @MonocleSociety and tell us how your XR project is going!

using System;
using UnityEngine;

namespace MonocleSociety
{
    /// <summary>
    /// This behaviour attempts to position itself in front of the camera at a fixed offset and at head height.
    /// The object will constantly rotate toward the camera and reposition itself to head height as the camera moves.
    /// It avoids intersecting the world mesh on placement by constructing a bounding cylinder around all child elements
    /// including 2D UI and casting the cylinder from the camera into the world
    /// </summary>
    public class WorldSpaceUI : MonoBehaviour
    {
        #region Inspector

        [SerializeField] private Vector3 _headPlacementOffset = new Vector3(0f, 0f, 1.5f);
        
        [Tooltip("A speed scalar that determines how fast the interface adjusts its y position.")]
        [SerializeField] private float _heightSpeed = 3f;
        
        [Tooltip("A speed scalar that determines how fast the interface rotates.")]
        [SerializeField] private float _rotateSpeed = 1.0f;
        
        [Tooltip("The degrees the player's head can be out of alignment before turning.")]
        [SerializeField] private float _horizontalToleranceDeg = 30.0f;
        
        [Tooltip("The degrees the player's head can be out of alignment before changing height.")]
        [SerializeField] private float _verticalToleranceDeg = 8.0f;
        
        [Header("Optional")]
        [Tooltip("A camera to point toward. If not set, will use the main camera.")]
        [SerializeField] private Camera _camera;
        
        #endregion
        
        #region MonoBehaviour

        void Start()
        {
            if (!_camera)
                _camera = Camera.main;
            
            CalculateBoundingDimensions();
            ResetPosition();
        }

        void OnDisable()
        {
            if (_sweepTestCylinder)
            {
                Destroy(_sweepTestCylinder);
                _sweepTestCylinder = null;
            }
        }

        void FixedUpdate()
        {
            var myXform = transform;
            var myPosition = myXform.position;
            var myRotation = myXform.rotation;
            var cameraPosition = _camera.transform.position;
            
            // Get the desired position. If it's more than _verticalToleranceDeg away in the camera's view,
            // start moving toward it.
            var desiredPosition = new Vector3(myPosition.x, cameraPosition.y + _headPlacementOffset.y, myPosition.z);
            var angleToDesiredPosDeg = Math.Abs(Vector3.Angle(myPosition - cameraPosition, desiredPosition - cameraPosition));
            if (angleToDesiredPosDeg >= _verticalToleranceDeg)
            {
                _targetPosition = desiredPosition;
            }

            // Get the desired rotation. If it's more than _horizontalToleranceDeg away in the camera's view,
            // start rotating toward it.
            var desiredRotation = GetDesiredRotation(myPosition, cameraPosition);
            var angleToDesiredRotDeg = Math.Abs(Quaternion.Angle(myRotation, desiredRotation));
            if (angleToDesiredRotDeg >= _horizontalToleranceDeg)
            {
                _targetRotation = desiredRotation;
            }
            
            // Update position. Not a true slerp; uses a scalar to damp movement
            var posSpeed = Time.deltaTime * _heightSpeed;
            myXform.position = Vector3.SlerpUnclamped(myPosition, _targetPosition, posSpeed);
            
            // Update rotation. Not a true slerp; uses a scalar to damp rotation
            float rotSpeed = Time.deltaTime * _rotateSpeed;
            myXform.rotation = Quaternion.Slerp(myRotation, _targetRotation, rotSpeed);
        }
        
        #endregion

        /// <summary>
        /// Do a new sweep test and place the UI at a new position, for example when the home button is pressed
        /// </summary>
        public void ResetPosition()
        {
            var cameraXform = _camera.transform;
            var cameraPosition = cameraXform.position;
            
            // The placement direction is the level (XZ) head direction plus a placement offset
            var direction = cameraXform.forward;
            direction.y = 0;
            direction += Quaternion.LookRotation(direction) * _headPlacementOffset;
            direction.Normalize();
            
            // Cast out from the camera until hitting max distance or the world mesh
            var distance = _headPlacementOffset.magnitude;

            // If the UI contains any rigid bodies, the placement sweep could hit it
            // Move it out of the way of the sweep test
            var xform = transform;
            transform.position = new Vector3(0, cameraPosition.y - 10000, 0);
            
            // Create a cylinder to sweep with
            if (_sweepTestCylinder == null)
            {
                // Construct the cylinder from scratch. This can all be replaced with an equivalent prefab.
                _sweepTestCylinder = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
                
                // Replace the default capsule collider with an actual cylinder
                Destroy(_sweepTestCylinder.GetComponent<CapsuleCollider>());
                var meshCollider = _sweepTestCylinder.AddComponent<MeshCollider>();
                meshCollider.sharedMesh = _sweepTestCylinder.GetComponent<MeshFilter>().sharedMesh;
                meshCollider.convex = true;
                meshCollider.isTrigger = true;

                // Add a kinematic rigid body for the sweep
                var rigidbody = _sweepTestCylinder.AddComponent<Rigidbody>();
                rigidbody.isKinematic = true;
                rigidbody.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
                rigidbody.useGravity = false;
                
                // Scale the cylinder to 1,1,1
                _sweepTestCylinder.transform.localScale = new Vector3(
                    _boundingCylinderRadius * 2, // Primitive cylinder radius is 0.5
                    _boundingCylinderHeight / 2, // Primitive cylinder has height 2
                    _boundingCylinderRadius * 2);
            }

            _sweepTestCylinder.SetActive(true);

            _sweepTestCylinder.transform.position = cameraPosition;
            
            var cylinderBody = _sweepTestCylinder.GetComponent<Rigidbody>();
            if(cylinderBody.SweepTest(direction, out var hitInfo, distance, QueryTriggerInteraction.Ignore))
            {
                distance = hitInfo.distance;
            }
            
            // Calculate target position and rotation
            _targetPosition = cameraPosition + distance * direction;
            // We don't do up/down collision for head height so set the y position no matter where we hit
            _targetPosition.y = cameraPosition.y + _headPlacementOffset.y;
            
            _targetRotation = GetDesiredRotation(_targetPosition, cameraPosition);
            
            // Move to the target
            xform.position = _targetPosition;
            xform.rotation = _targetRotation;
            
            // Deactivate the cylinder to hide it
            _sweepTestCylinder.SetActive(false);
        }

        private void CalculateBoundingDimensions()
        {
            // We assume here that we've been spawned initially unrotated. If we are rotated, we'd have to
            // account for this as it affects the bounding dimensions.
            
            // Initial bounds at our current position with no size
            var combinedBounds = new Bounds(transform.position, Vector3.zero);
            
            // Add bounds of all child renderers
            var renderers = GetComponentsInChildren<Renderer>(true);
            foreach (var render in renderers)
            {
                combinedBounds.Encapsulate(render.bounds);
            }
            
            // Add bounds of all child colliders
            var colliders = GetComponentsInChildren<Collider>(true);
            foreach (var collider in colliders)
            {
                combinedBounds.Encapsulate(collider.bounds);
            }

            // Add bounds of all child rect transforms (2D UI)
            var rectXforms = GetComponentsInChildren<RectTransform>(true);
            foreach (var rect in rectXforms)
            {
                Vector3[] corners = new Vector3[4];
                rect.GetWorldCorners(corners);
                foreach (var v in corners)
                {
                    combinedBounds.Encapsulate(v);
                }
            }
            
            var extents = combinedBounds.extents;
            _boundingCylinderHeight = extents.y * 2;
            _boundingCylinderRadius = Math.Max(extents.x, extents.y);
        }

        /// <summary>
        /// A rotation pointing away from the camera on the XZ plane
        /// (this is the only way a UI Canvas can be oriented and have its elements visible)
        /// </summary>
        private Quaternion GetDesiredRotation(Vector3 myPosition, Vector3 cameraPosition)
        {
            var direction = myPosition - cameraPosition;
            direction.y = 0;
            return Quaternion.LookRotation(direction);
        }

        private float _boundingCylinderHeight;
        private float _boundingCylinderRadius;
        private Vector3 _targetPosition;
        private Quaternion _targetRotation;
        private GameObject _sweepTestCylinder;
    }
}