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 }