eye-therapy-2 / Assets / Scripts / Eye Simulation / EyeSimulator.cs
EyeSimulator.cs
Raw
using UnityEngine;
using UnityEngine.InputSystem;
using Random = UnityEngine.Random;
using GD.MinMaxSlider;

public class EyeSimulator : MonoBehaviour
{
    #region Editor Variables
    [Header("General settings")]
    [SerializeField] bool useMacroSaccades = true;
    [SerializeField] bool useMicroSaccades = true;
    [Tooltip("The more nervous, the more often you do micro-and macrosaccades.")]
    [SerializeField] [Range(0, 10)] float nervousness;
    [SerializeField] [Min(1)] float viewDistance = 100f;
    [Tooltip("Cross eye correction factor")]
    [SerializeField] [Range(0, 5)] float crossEyeCorrection = 1.0f;

    [Header("Field of view")]
    [Tooltip("Maximum horizontal eye angle away from nose")]
    [SerializeField] float maxEyeHorizAngle = 35;
    [Tooltip("Maximum horizontal eye angle towards nose")]
    [SerializeField] float maxEyeHorizAngleTowardsNose = 35;
    [Tooltip("Eyes vertical angle range (x - up, y - down)")]
    [SerializeField] [MinMaxSlider(-90f, 90f)]
    Vector2 eyeVerticalRange = new Vector2(-25f, 30f);

    [Header("Sight lines")]
    [SerializeField] bool drawSightLines = true;
    [SerializeField] LineRenderer leftLaser = null;
    [SerializeField] LineRenderer rightLaser = null;

    [Header("Eye movement")]
    [SerializeField] bool bothEyesRotation = true;
    [SerializeField] float eyeRotationSpeed = 10f;
    [SerializeField] InputActionReference leftEyeRotationAction;
    [SerializeField] InputActionReference leftEyeResetAction;
    [SerializeField] InputActionReference rightEyeRotationAction;
    [SerializeField] InputActionReference rightEyeResetAction;
    [SerializeField] InputActionReference singleBothRotationSwitchAction;

    [Header("Eye spawning")]
    [SerializeField] InputActionReference leftEyePositionAction;
    [SerializeField] InputActionReference rightEyePositionAction;
    [SerializeField] GameObject eyePrefab;
    [SerializeField] [Min(0f)] float eyeScale = 1f;
    #endregion

    #region Variables
    public float EyeDistance { get; private set; } = 0.064f;
    public float EyeDistanceScale { get; private set; } = 1f;
    public float ViewDistance => viewDistance;
    public Vector3 TargetDirection { get; private set; } = Vector3.zero;
    public Vector2 LeftEyeRotation { get; private set; } = Vector2.zero;
    public Vector2 RightEyeRotation { get; private set; } = Vector2.zero;


    float timeToMicroSaccade;
    float timeToMacroSaccade;
    float timeOfEyeMovementStart;

    float leftMaxSpeedHoriz;
    float leftHorizDuration;
    float leftMaxSpeedVert;
    float leftVertDuration;
    float leftCurrentSpeedX;
    float leftCurrentSpeedY;

    float rightMaxSpeedHoriz;
    float rightHorizDuration;
    float rightMaxSpeedVert;
    float rightVertDuration;
    float rightCurrentSpeedX;
    float rightCurrentSpeedY;

    float startLeftEyeHorizDuration;
    float startLeftEyeVertDuration;
    float startLeftEyeMaxSpeedHoriz;
    float startLeftEyeMaxSpeedVert;

    float startRightEyeHorizDuration;
    float startRightEyeVertDuration;
    float startRightEyeMaxSpeedHoriz;
    float startRightEyeMaxSpeedVert;

    Quaternion leftEyeRootFromAnchorQ;
    Quaternion rightEyeRootFromAnchorQ;
    Quaternion leftAnchorFromEyeRootQ;
    Quaternion rightAnchorFromEyeRootQ;
    Vector3 currentLeftEyeLocalEuler;
    Vector3 currentRightEyeLocalEuler;
    Vector3 macroSaccadeTargetLocal;
    Vector3 microSaccadeTargetLocal;
    Transform leftEyeAnchor = null;
    Transform rightEyeAnchor = null;
    Transform target;
    #endregion

    private void OnEnable()
    {
        leftEyePositionAction.action.Enable();
        rightEyePositionAction.action.Enable();
        leftEyeRotationAction.action.Enable();
        leftEyeResetAction.action.Enable();
        rightEyeRotationAction.action.Enable();
        rightEyeResetAction.action.Enable();
        singleBothRotationSwitchAction.action.Enable();
    }

    private void OnDisable()
    {
        leftEyePositionAction.action.Disable();
        rightEyePositionAction.action.Disable();
        leftEyeRotationAction.action.Disable();
        leftEyeResetAction.action.Disable();
        rightEyeRotationAction.action.Disable();
        rightEyeResetAction.action.Disable();
        singleBothRotationSwitchAction.action.Disable();
    }

    private void Awake()
    {
        singleBothRotationSwitchAction.action.performed += ctx => bothEyesRotation = !bothEyesRotation;
        leftEyeResetAction.action.performed += ctx => LeftEyeRotation = Vector2.zero;
        rightEyeResetAction.action.performed += ctx =>
        {
            if (bothEyesRotation) target.position = transform.position + transform.forward * viewDistance;
            else RightEyeRotation = Vector2.zero;
        };
        TargetDirection = transform.forward;
        target = new GameObject("Eye Target").transform;
        target.position = transform.position + TargetDirection * 10;
    }

    private void Start()
    {
        SpawnEyes();
    }

    private void Update()
    {
        ProcessManualMovementInput();
        UpdateEyeMovement();
        if (drawSightLines) DrawSightLines();
    }

    private void FixedUpdate()
    {
        CheckMacroSaccades();
        CheckMicroSaccades();
    }

    private void ProcessManualMovementInput()
    {
        if (bothEyesRotation)
        {
            Vector2 eyeDir = rightEyeRotationAction.action.ReadValue<Vector2>();
            if (eyeDir.sqrMagnitude > 1) eyeDir.Normalize();

            Vector3 currentTargetAngles = Quaternion.LookRotation(transform.InverseTransformDirection(target.position - transform.position)).eulerAngles;
            Vector3 eyeTargetAngles = new Vector3(ClampVertEyeAngle(currentTargetAngles.x - eyeDir.y * eyeRotationSpeed * Time.deltaTime),
                                                  ClampLeftHorizEyeAngle(currentTargetAngles.y + eyeDir.x * eyeRotationSpeed * Time.deltaTime), 0);
            TargetDirection = Quaternion.AngleAxis(eyeTargetAngles.y, transform.up) * Quaternion.AngleAxis(eyeTargetAngles.x, transform.right) * transform.forward;

            if (Physics.Raycast(transform.position, TargetDirection, out RaycastHit hit, viewDistance))
            {
                target.position = hit.point;
            }
            else
            {
                target.position = transform.position + TargetDirection * viewDistance;
            }

        }
        else
        {
            Vector2 leftEyeDir = leftEyeRotationAction.action.ReadValue<Vector2>();
            if (leftEyeDir.sqrMagnitude > 1) leftEyeDir.Normalize();
            LeftEyeRotation += leftEyeDir * eyeRotationSpeed * Time.deltaTime;
            LeftEyeRotation = new Vector2(Mathf.Clamp(LeftEyeRotation.x, -60, 60),
                                          Mathf.Clamp(LeftEyeRotation.y, -60, 60));
            
            Vector2 rightEyeDir = rightEyeRotationAction.action.ReadValue<Vector2>();
            if (rightEyeDir.sqrMagnitude > 1) rightEyeDir.Normalize();
            RightEyeRotation += rightEyeDir * eyeRotationSpeed * Time.deltaTime;
            RightEyeRotation = new Vector2(Mathf.Clamp(RightEyeRotation.x, -60, 60),
                                           Mathf.Clamp(RightEyeRotation.y, -60, 60));
        }
    }

    private void SpawnEyes()
    {
        GameObject leftEye = Instantiate(eyePrefab, transform);
        leftEye.name = "Left Eye";
        leftEyeAnchor = leftEye.transform;
        
        GameObject rightEye = Instantiate(eyePrefab, transform);
        rightEye.name = "Right Eye";
        rightEyeAnchor = rightEye.transform;
        
        rightEyeAnchor.localScale = leftEyeAnchor.localScale = Vector3.one * eyeScale;

        Quaternion inverse = Quaternion.Inverse(transform.rotation);
        leftEyeRootFromAnchorQ = inverse * leftEyeAnchor.rotation;
        rightEyeRootFromAnchorQ = inverse * rightEyeAnchor.rotation;
        leftAnchorFromEyeRootQ = Quaternion.Inverse(leftEyeRootFromAnchorQ);
        rightAnchorFromEyeRootQ = Quaternion.Inverse(rightEyeRootFromAnchorQ);

        Invoke("PollEyesPosition", 0.5f);
    }

    /// <summary>
    /// Reads eyes positions from headset
    /// </summary>
    private void PollEyesPosition()
    {
        if (leftEyeAnchor && rightEyeAnchor)
        {
            leftEyeAnchor.localPosition = leftEyePositionAction.action.ReadValue<Vector3>();
            rightEyeAnchor.localPosition = rightEyePositionAction.action.ReadValue<Vector3>();

            EyeDistance = Vector3.Distance(leftEyeAnchor.position, rightEyeAnchor.position);
            EyeDistanceScale = EyeDistance / 0.064f;

            if (leftEyeAnchor.position == rightEyeAnchor.position) Invoke("PollEyesPosition", 3f);
        }
    }

    private void DrawSightLines()
    {
        float distance = Vector3.Distance(transform.position, target.position);

        Vector3 leftDirection = (leftEyeAnchor.parent.rotation * leftEyeAnchor.localRotation * leftAnchorFromEyeRootQ) * Vector3.forward;
        leftLaser.SetPosition(0, leftEyeAnchor.position);
        leftLaser.SetPosition(1, leftEyeAnchor.position + leftDirection * distance * EyeDistanceScale);

        Vector3 rightDirection = (rightEyeAnchor.parent.rotation * rightEyeAnchor.localRotation * rightAnchorFromEyeRootQ) * Vector3.forward;
        rightLaser.SetPosition(0, rightEyeAnchor.position);
        rightLaser.SetPosition(1, rightEyeAnchor.position + rightDirection * distance * EyeDistanceScale);
    }

    // The code below is based on Realistic Eye Movements by Tore Knabe
    // https://assetstore.unity.com/packages/tools/animation/realistic-eye-movements-29168
    // http://tore-knabe.com tore.knabe@gmail.com

    private void CheckMacroSaccades()
    {
        if (!useMacroSaccades) return;

        timeToMacroSaccade -= Time.fixedDeltaTime;
        if (timeToMacroSaccade <= 0)
        {
            const float kMacroSaccadeAngle = 10;
            float angleVert = Random.Range(-kMacroSaccadeAngle * 0.3f, kMacroSaccadeAngle * 0.4f);
            float angleHoriz = Random.Range(-kMacroSaccadeAngle, kMacroSaccadeAngle);
            SetMacroSaccadeTarget(transform.TransformPoint(Quaternion.Euler(angleVert, angleHoriz, 0)
                                                           * transform.InverseTransformPoint(target.position)));
            timeToMacroSaccade = Random.Range(5.0f, 8.0f) / (1.0f + nervousness);
        }
    }

    private void CheckMicroSaccades()
    {
        if (!useMicroSaccades) return;

        timeToMicroSaccade -= Time.fixedDeltaTime;
        if (timeToMicroSaccade <= 0)
        {
            const float kMicroSaccadeAngle = 3;
            float angleVert = Random.Range(-kMicroSaccadeAngle * 0.5f, kMicroSaccadeAngle * 0.6f);
            float angleHoriz = Random.Range(-kMicroSaccadeAngle, kMicroSaccadeAngle);
            SetMicroSaccadeTarget(transform.TransformPoint(Quaternion.Euler(angleVert, angleHoriz, 0)
                                                           * transform.InverseTransformPoint(target.TransformPoint(macroSaccadeTargetLocal))));
        }
    }

    private float NormalizeAngle(float angle)
    {
        int factor = (int)(angle / 360);
        angle -= factor * 360;
        if (angle > 180) return angle - 360;
        if (angle < -180) return angle + 360;
        return angle;
    }

    private float ClampLeftHorizEyeAngle(float angle)
    {
        float normalizedAngle = NormalizeAngle(angle);
        bool isTowardsNose = normalizedAngle > 0;
        float maxAngle = isTowardsNose ? maxEyeHorizAngleTowardsNose : maxEyeHorizAngle;
        return Mathf.Clamp(normalizedAngle, -maxAngle, maxAngle);
    }

    private float ClampRightHorizEyeAngle(float angle)
    {
        float normalizedAngle = NormalizeAngle(angle);
        bool isTowardsNose = normalizedAngle < 0;
        float maxAngle = isTowardsNose ? maxEyeHorizAngleTowardsNose : maxEyeHorizAngle;
        return Mathf.Clamp(normalizedAngle, -maxAngle, maxAngle);
    }

    private float ClampVertEyeAngle(float angle)
    {
        return Mathf.Clamp(NormalizeAngle(angle), eyeVerticalRange.x, eyeVerticalRange.y);
    }

    private void SetMacroSaccadeTarget(Vector3 targetGlobal)
    {
        macroSaccadeTargetLocal = target.InverseTransformPoint(targetGlobal);
        SetMicroSaccadeTarget(targetGlobal);
        timeToMicroSaccade += 0.75f;
    }

    private void SetMicroSaccadeTarget(Vector3 targetGlobal)
    {
        if (leftEyeAnchor == null || rightEyeAnchor == null) return;

        microSaccadeTargetLocal = target.InverseTransformPoint(targetGlobal);

        Vector3 targetLeftEyeLocalAngles = Quaternion.LookRotation(transform.InverseTransformDirection(targetGlobal - leftEyeAnchor.position)).eulerAngles;
        targetLeftEyeLocalAngles = new Vector3(ClampVertEyeAngle(targetLeftEyeLocalAngles.x),
                                               ClampLeftHorizEyeAngle(targetLeftEyeLocalAngles.y),
                                               targetLeftEyeLocalAngles.z);

        float leftHorizDistance = Mathf.Abs(Mathf.DeltaAngle(currentLeftEyeLocalEuler.y, targetLeftEyeLocalAngles.y));

        // From "Realistic Avatar and Head Animation Using a Neurobiological Model of Visual Attention", Itti, Dhavale, Pighin
        leftMaxSpeedHoriz = 473 * (1 - Mathf.Exp(-leftHorizDistance / 7.8f));

        // From "Eyes Alive", Lee, Badler
        const float D0 = 0.025f;
        const float d = 0.00235f;
        leftHorizDuration = D0 + d * leftHorizDistance;

        float leftVertDistance = Mathf.Abs(Mathf.DeltaAngle(currentLeftEyeLocalEuler.x, targetLeftEyeLocalAngles.x));
        leftMaxSpeedVert = 473 * (1 - Mathf.Exp(-leftVertDistance / 7.8f));
        leftVertDuration = D0 + d * leftVertDistance;


        Vector3 targetRightEyeLocalAngles = Quaternion.LookRotation(transform.InverseTransformDirection(targetGlobal - rightEyeAnchor.position)).eulerAngles;
        targetRightEyeLocalAngles = new Vector3(ClampVertEyeAngle(targetRightEyeLocalAngles.x),
                                                ClampRightHorizEyeAngle(targetRightEyeLocalAngles.y),
                                                targetRightEyeLocalAngles.z);

        float rightHorizDistance = Mathf.Abs(Mathf.DeltaAngle(currentRightEyeLocalEuler.y, targetRightEyeLocalAngles.y));
        rightMaxSpeedHoriz = 473 * (1 - Mathf.Exp(-rightHorizDistance / 7.8f));
        rightHorizDuration = D0 + d * rightHorizDistance;

        float rightVertDistance = Mathf.Abs(Mathf.DeltaAngle(currentRightEyeLocalEuler.x, targetRightEyeLocalAngles.x));
        rightMaxSpeedVert = 473 * (1 - Mathf.Exp(-rightVertDistance / 7.8f));
        rightVertDuration = D0 + d * rightVertDistance;

        leftMaxSpeedHoriz = rightMaxSpeedHoriz = Mathf.Max(leftMaxSpeedHoriz, rightMaxSpeedHoriz);
        leftMaxSpeedVert = rightMaxSpeedVert = Mathf.Max(leftMaxSpeedVert, rightMaxSpeedVert);
        leftHorizDuration = rightHorizDuration = Mathf.Max(leftHorizDuration, rightHorizDuration);
        leftVertDuration = rightVertDuration = Mathf.Max(leftVertDuration, rightVertDuration);

        timeToMicroSaccade = Random.Range(0.8f, 1.75f) / (1.0f + 0.4f * nervousness);

        // For letting the eyes keep tracking the target after they saccaded to it
        {
            startLeftEyeHorizDuration = leftHorizDuration;
            startLeftEyeVertDuration = leftVertDuration;
            startLeftEyeMaxSpeedHoriz = leftMaxSpeedHoriz;
            startLeftEyeMaxSpeedVert = leftMaxSpeedVert;

            startRightEyeHorizDuration = rightHorizDuration;
            startRightEyeVertDuration = rightVertDuration;
            startRightEyeMaxSpeedHoriz = rightMaxSpeedHoriz;
            startRightEyeMaxSpeedVert = rightMaxSpeedVert;

            timeOfEyeMovementStart = Time.time;
        }
    }

    private void UpdateEyeMovement()
    {
        Vector3 eyeTargetGlobal = target.TransformPoint(microSaccadeTargetLocal);

        // Prevent cross-eyes
        {
            Vector3 eyeCenterToTarget = eyeTargetGlobal - transform.position;
            float distance = eyeCenterToTarget.magnitude / EyeDistanceScale;
            float corrDistMax = 0.6f;
            float corrDistMin = 0.2f;

            if (distance < corrDistMax)
            {
                float modifiedDistance = corrDistMin + distance * (corrDistMax - corrDistMin) / corrDistMax;
                modifiedDistance = crossEyeCorrection * (modifiedDistance - distance) + distance;
                eyeTargetGlobal = transform.position + EyeDistanceScale * modifiedDistance * (eyeCenterToTarget / distance);
            }
        }

        // After the eyes saccaded to the new POI, adjust eye duration and speed so they keep tracking the target quickly enough.
        {
            const float kEyeDurationForTracking = 0.005f;
            const float kEyeMaxSpeedForTracking = 600;

            float timeSinceLeftEyeHorizInitiatedMovementStop = Time.time - (timeOfEyeMovementStart + 1.5f * startLeftEyeHorizDuration);
            if (timeSinceLeftEyeHorizInitiatedMovementStop > 0)
            {
                leftHorizDuration = kEyeDurationForTracking + startLeftEyeHorizDuration / (1 + timeSinceLeftEyeHorizInitiatedMovementStop);
                leftMaxSpeedHoriz = kEyeMaxSpeedForTracking - startLeftEyeMaxSpeedHoriz / (1 + timeSinceLeftEyeHorizInitiatedMovementStop);
            }

            float timeSinceLeftEyeVertInitiatedMovementStop = Time.time - (timeOfEyeMovementStart + 1.5f * startLeftEyeVertDuration);
            if (timeSinceLeftEyeVertInitiatedMovementStop > 0)
            {
                leftVertDuration = kEyeDurationForTracking + startLeftEyeVertDuration / (1 + timeSinceLeftEyeVertInitiatedMovementStop);
                leftMaxSpeedVert = kEyeMaxSpeedForTracking - startLeftEyeMaxSpeedVert / (1 + timeSinceLeftEyeVertInitiatedMovementStop);
            }

            float timeSinceRightEyeHorizInitiatedMovementStop = Time.time - (timeOfEyeMovementStart + 1.5f * startRightEyeHorizDuration);
            if (timeSinceRightEyeHorizInitiatedMovementStop > 0)
            {
                rightHorizDuration = kEyeDurationForTracking + startRightEyeHorizDuration / (1 + timeSinceRightEyeHorizInitiatedMovementStop);
                rightMaxSpeedHoriz = kEyeMaxSpeedForTracking - startRightEyeMaxSpeedHoriz / (1 + timeSinceRightEyeHorizInitiatedMovementStop);
            }

            float timeSinceRightEyeVertInitiatedMovementStop = Time.time - (timeOfEyeMovementStart + 1.5f * startRightEyeVertDuration);
            if (timeSinceRightEyeVertInitiatedMovementStop > 0)
            {
                rightVertDuration = kEyeDurationForTracking + startRightEyeVertDuration / (1 + timeSinceRightEyeVertInitiatedMovementStop);
                rightMaxSpeedVert = kEyeMaxSpeedForTracking - startRightEyeMaxSpeedVert / (1 + timeSinceRightEyeVertInitiatedMovementStop);
            }
        }

        float deltaTime = Mathf.Max(0.0001f, Time.deltaTime);

        Vector3 desiredLeftEyeTargetAngles = Quaternion.LookRotation(transform.InverseTransformDirection(eyeTargetGlobal - leftEyeAnchor.position)).eulerAngles;
        Vector3 leftEyeTargetAngles = new Vector3(ClampVertEyeAngle(desiredLeftEyeTargetAngles.x),
                                                  ClampLeftHorizEyeAngle(desiredLeftEyeTargetAngles.y), 0);
        currentLeftEyeLocalEuler = new Vector3(ClampVertEyeAngle(Mathf.SmoothDampAngle(currentLeftEyeLocalEuler.x,
                                                                                       leftEyeTargetAngles.x,
                                                                                       ref leftCurrentSpeedX,
                                                                                       leftVertDuration,
                                                                                       leftMaxSpeedVert,
                                                                                       deltaTime)),
                                               ClampLeftHorizEyeAngle(Mathf.SmoothDampAngle(currentLeftEyeLocalEuler.y,
                                                                                            leftEyeTargetAngles.y,
                                                                                            ref leftCurrentSpeedY,
                                                                                            leftHorizDuration,
                                                                                            leftMaxSpeedHoriz,
                                                                                            deltaTime)),
                                               leftEyeTargetAngles.z);
        leftEyeAnchor.localRotation = Quaternion.AngleAxis(LeftEyeRotation.x, transform.up) * Quaternion.AngleAxis(-LeftEyeRotation.y, transform.right) *
                                      Quaternion.Inverse(leftEyeAnchor.parent.rotation) * transform.rotation * Quaternion.Euler(currentLeftEyeLocalEuler) * leftEyeRootFromAnchorQ;

        Vector3 desiredRightEyeTargetAngles = Quaternion.LookRotation(transform.InverseTransformDirection(eyeTargetGlobal - rightEyeAnchor.position)).eulerAngles;
        Vector3 rightEyeTargetAngles = new Vector3(ClampVertEyeAngle(desiredRightEyeTargetAngles.x),
                                                   ClampRightHorizEyeAngle(desiredRightEyeTargetAngles.y), 0);
        currentRightEyeLocalEuler = new Vector3(ClampVertEyeAngle(Mathf.SmoothDampAngle(currentRightEyeLocalEuler.x,
                                                                                        rightEyeTargetAngles.x,
                                                                                        ref rightCurrentSpeedX,
                                                                                        rightVertDuration,
                                                                                        rightMaxSpeedVert,
                                                                                        deltaTime)),
                                                ClampRightHorizEyeAngle(Mathf.SmoothDampAngle(currentRightEyeLocalEuler.y,
                                                                                              rightEyeTargetAngles.y,
                                                                                              ref rightCurrentSpeedY,
                                                                                              rightHorizDuration,
                                                                                              rightMaxSpeedHoriz,
                                                                                              deltaTime)),
                                                rightEyeTargetAngles.z);
        rightEyeAnchor.localRotation = Quaternion.AngleAxis(RightEyeRotation.x, transform.up) * Quaternion.AngleAxis(-RightEyeRotation.y, transform.right) *
                                       Quaternion.Inverse(rightEyeAnchor.parent.rotation) * transform.rotation * Quaternion.Euler(currentRightEyeLocalEuler) * rightEyeRootFromAnchorQ;
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        if (leftEyeAnchor && rightEyeAnchor)
        {
            rightEyeAnchor.localScale = leftEyeAnchor.localScale = Vector3.one * eyeScale;
        }
    }

    private void OnDrawGizmos()
    {
        if (target)
        {
            Gizmos.color = Color.magenta;
            Gizmos.DrawSphere(target.position, 0.2f);
        }
    }
#endif
}