Back to portfolio

Unity · XR · HvA × Organisation Insights

Immersive Organisation

A mixed-reality experience that lets users navigate organisational structures and HR processes in immersive space. I built the player locomotion system (tuned for motion comfort), all spatial UI panels, and the gaze-triggered effects wired to every interactive element.

UnityXRC#UI/UXLocomotionGaze InteractionMixed Reality
Post by Organisation Insights

Overview

My role

Locomotion & UI

Engine

Unity (XR Toolkit)

Platform

Mixed Reality

Type

Industry project

Commissioned by Organisation Insights in collaboration with the Hogeschool van Amsterdam's Immersive Tech programme. The goal was to replace traditional slide decks with something users could physically step inside — a spatial representation of an organisation's folders, processes, and HR steps in mixed reality.

My work covered two interconnected areas: locomotion (making smooth movement feel comfortable in headset) and all UI — the spatial panels users interact with, and the gaze-triggered effects tied to every interactive element.

XR environment — raycast controllers in the mixed-reality space

XR environment — raycast controllers and particle effects

Spatial UI panel — Folders, Processes, Steps columns

Spatial UI — Folders, Processes, and Steps panels in 3D space

Player Locomotion — Motion Comfort

Smooth continuous movement in XR can cause motion sickness because the visual system perceives motion that the inner ear doesn't feel. The movement direction reference is key: moving relative to the head (where you look) is generally more comfortable than moving relative to the controller (where you point).

Head-relative

used

Forward is where the player looks. The most natural mode — body and visual flow align, reducing the sensory mismatch that causes discomfort.

Hand-relative

Forward is where the controller points. Can feel unintuitive when looking in a different direction than you're steering.

The DynamicMoveProviderblends both hands' movement poses weighted by each hand's input magnitude, so each hand can independently use a different reference. The per-hand direction is configurable in the Inspector without touching code.

DynamicMoveProvider.cs — ComputeDesiredMove()
public class DynamicMoveProvider : ContinuousMoveProvider
{
    // Each hand can independently use head-relative or
    // controller-relative movement direction.
    public enum MovementDirection { HeadRelative, HandRelative }

    [SerializeField] Transform m_HeadTransform;
    [SerializeField] MovementDirection m_LeftHandMovementDirection;
    [SerializeField] MovementDirection m_RightHandMovementDirection;

    protected override Vector3 ComputeDesiredMove(Vector2 input)
    {
        if (input == Vector2.zero)
            return base.ComputeDesiredMove(input);

        // Resolve the forward source per hand based on preference
        switch (m_LeftHandMovementDirection)
        {
            case MovementDirection.HeadRelative:
                m_LeftMovementPose = m_HeadTransform.GetWorldPose();
                break;
            case MovementDirection.HandRelative:
                m_LeftMovementPose = m_LeftControllerTransform
                                         .GetWorldPose();
                break;
        }

        // Blend the two poses weighted by each hand's input magnitude
        var leftHandValue  = leftHandMoveInput.ReadValue();
        var rightHandValue = rightHandMoveInput.ReadValue();
        var total = leftHandValue.sqrMagnitude
                  + rightHandValue.sqrMagnitude;
        var blend = total > Mathf.Epsilon
                  ? leftHandValue.sqrMagnitude / total
                  : 0.5f;

        m_CombinedTransform.SetPositionAndRotation(
            Vector3.Lerp(m_RightMovementPose.position,
                         m_LeftMovementPose.position, blend),
            Quaternion.Slerp(m_RightMovementPose.rotation,
                             m_LeftMovementPose.rotation, blend));

        return base.ComputeDesiredMove(input);
    }
}

Spatial UI

The main interface is a three-column spatial panel — Folders, Processes, Steps — that users can interact with using controller raycasts. All UI panels exist in 3D space rather than being screen-overlays, so users physically navigate between content areas.

StepManager.cs — step progression
public class StepManager : MonoBehaviour
{
    [Serializable]
    class Step
    {
        public GameObject stepObject;
        public string     buttonText;
    }

    [SerializeField] TextMeshProUGUI m_StepButtonTextField;
    [SerializeField] List<Step>      m_StepList;
    int m_CurrentStepIndex = 0;

    public void Next()
    {
        // Hide current step panel, advance index, show next
        m_StepList[m_CurrentStepIndex].stepObject.SetActive(false);
        m_CurrentStepIndex = (m_CurrentStepIndex + 1) % m_StepList.Count;
        m_StepList[m_CurrentStepIndex].stepObject.SetActive(true);
        m_StepButtonTextField.text =
            m_StepList[m_CurrentStepIndex].buttonText;
    }
}

The StepManagerdrives the HR process step panels — each "Next" call hides the current step object and activates the next, keeping the content progression tied directly to what's visible in 3D space.

Gaze-Triggered Effects

Every interactive UI element has feedback effects that activate when the user gazes at it. A CalloutGazeController measures whether the gaze direction is facing the element (dot product against a threshold), then fires events that the Callout listens to — revealing tooltips after a dwell period.

CalloutGazeController.cs
public class CalloutGazeController : MonoBehaviour
{
    [SerializeField] Transform m_GazeTransform;

    // Dot product threshold: 1 = exact forward, 0 = 90 degrees.
    // 0.85 means the controller must be roughly within 30° of
    // the user's gaze to trigger.
    [Range(0f, 1f)]
    [SerializeField] float m_FacingThreshold = 0.85f;

    [SerializeField] UnityEvent m_FacingEntered;
    [SerializeField] UnityEvent m_FacingExited;

    void Update()
    {
        CheckLargeMovement(); // suppress during fast head turns

        float dot = Vector3.Dot(
            m_GazeTransform.forward,
            (transform.position - m_GazeTransform.position).normalized
        );

        if (dot > m_FacingThreshold && !m_IsFacing)
            FacingEntered();   // fires → shows callout
        else if (dot < m_FacingThreshold && m_IsFacing)
            FacingExited();    // fires → hides callout
    }
}
Callout.cs — dwell-based tooltip reveal
public class Callout : MonoBehaviour
{
    [SerializeField] Transform  m_LazyTooltip;
    [SerializeField] GameObject m_Curve;
    [SerializeField] float      m_DwellTime = 1f; // seconds to hold gaze

    public void GazeHoverStart()
    {
        m_Gazing = true;
        m_StartCo = StartCoroutine(StartDelay());
    }

    public void GazeHoverEnd()
    {
        m_Gazing = false;
        StartCoroutine(EndDelay());
    }

    IEnumerator StartDelay()
    {
        yield return new WaitForSeconds(m_DwellTime);
        if (m_Gazing)
        {
            m_LazyTooltip.gameObject.SetActive(true);
            m_Curve.SetActive(true);
        }
    }
}

Gaze detection

Dot product of the gaze forward and the vector to the UI element. Threshold 0.85 means the element must be within ~30° of the user's direct line of sight.

Dwell time

Tooltip only appears after the user holds their gaze for 1 second — prevents accidental triggers while scanning the environment.

Large movement suppression

Fast head rotation during locomotion temporarily disables gaze events (0.25s cooldown) so tooltips don't flicker when moving through the space.

Team

HvA students

Federico Ippolito
Milena Żukiewicz
Lizanne van Rhijn
Kim van Rijn (Locomotion & UI)

Industry partner

Kim van Vliet
Jeroen Scheper
Richard Vonk

Organisation Insights

Back to portfolioOrganisation Insights post →