using UnityEngine; /// /// 캐릭터가 마우스 커서(또는 지정한 대상)를 눈으로 좇게 한다. /// 표정·립싱크와 함께 "살아있다"는 인상을 만드는 핵심 요소. /// /// 구현 노트: Animator 가 포즈를 쓴 뒤에 덮어써야 하므로 LateUpdate 에서 동작한다. /// 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라 엉뚱한 /// 방향으로 꺾인다. 그래서 캐릭터 루트의 up/right 축을 기준으로 월드 회전을 /// 덧씌우는 방식을 쓴다. 리그 구조와 무관하게 동작한다. /// // VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 Runtime.Process() 로 실제 본에 // 반영된다. 우리가 건드리는 것은 그 앞단의 컨트롤 리그 본이므로, 반드시 그보다 // 먼저 실행돼야 한다. 순서를 안 정하면 LateUpdate 간 순서가 보장되지 않아 // 모델에 따라 적용되기도 하고 안 되기도 한다. [DefaultExecutionOrder(-100)] public class HeadLookAt : MonoBehaviour { [Header("참조")] [Tooltip("비우면 자식에서 자동 탐색")] [SerializeField] Animator animator; [Tooltip("휴머노이드가 아니거나 자동 탐색이 실패할 때 직접 지정")] [SerializeField] Transform headOverride; [Tooltip("비우면 Camera.main")] [SerializeField] Camera viewCamera; [Tooltip("커서 좌표 변환에 필요한 창 핸들 제공자. 비우면 씬에서 탐색")] [SerializeField] TransparentWindow window; [Header("대상")] [SerializeField] bool followCursor = true; [Tooltip("followCursor 가 꺼져 있을 때 바라볼 대상")] [SerializeField] Transform explicitTarget; [Header("제한")] [Tooltip("좌우로 돌아갈 수 있는 최대 각도")] [SerializeField] float maxYaw = 65f; [Tooltip("위아래로 돌아갈 수 있는 최대 각도")] [SerializeField] float maxPitch = 30f; [Tooltip("클수록 빠르게 따라간다")] [SerializeField] float responsiveness = 8f; [Tooltip("목이 나눠 가질 회전 비율. 나머지는 머리가 담당한다. 0.3~0.5 가 자연스럽다")] [Range(0f, 1f)] [SerializeField] float neckShare = 0.35f; [Tooltip("대상을 놓쳤을 때 정면으로 돌아가기까지의 유예 시간(초)")] [SerializeField] float returnDelay = 1.5f; [Header("주의 범위")] [Tooltip("이 거리 안에서는 완전히 쳐다본다. 캐릭터의 화면상 키를 1 로 하는 배수. " + "픽셀이 아니라 배수라서 캐릭터 크기나 해상도가 달라져도 체감이 유지된다")] [SerializeField] float attentionRadius = 0.8f; [Tooltip("반경 바깥으로 이만큼 더 멀어지는 동안 서서히 정면으로 돌아간다")] [SerializeField] float attentionFalloff = 0.7f; Transform head; Transform neck; Transform root; // 기준(정지) 자세. 회전 누적을 막기 위해 매 프레임 여기로 되돌린 뒤 적용한다. Quaternion restHead, restNeck; // 주의 반경의 기준 단위. 캐릭터가 화면에서 차지하는 높이(픽셀). float characterScreenHeight = 200f; Vector2 currentAngles; // x = yaw, y = pitch float lastSeenTime = -999f; void Awake() { root = transform; if (animator == null) animator = GetComponentInChildren(); if (viewCamera == null) viewCamera = Camera.main; if (window == null) window = FindFirstObjectByType(); } // Awake 가 아니라 첫 LateUpdate 에서 해석한다. VRM 컨트롤 리그가 만들어지는 // 시점에 Animator.avatar 가 교체되므로, 그보다 먼저 캐시하면 곧 무효가 될 // 실제 메시 본을 잡게 된다. bool resolved; bool warned; bool TryResolveBones() { if (headOverride != null) { head = headOverride; } else if (animator != null && animator.isHuman) { head = animator.GetBoneTransform(HumanBodyBones.Head); neck = animator.GetBoneTransform(HumanBodyBones.Neck); // 없는 리그도 있다 } if (head != null) { restHead = head.localRotation; if (neck != null) restNeck = neck.localRotation; // 캐릭터는 고정 깊이 평면 위에 있으므로 화면상 크기가 변하지 않는다. 한 번만 잰다. if (viewCamera != null && CharacterScreenBounds.TryMeasure(viewCamera, root, out Vector2 offMin, out Vector2 offMax)) { characterScreenHeight = Mathf.Max(1f, offMax.y - offMin.y); } resolved = true; return true; } if (!warned) { warned = true; Debug.LogWarning("[HeadLookAt] 머리 본을 찾지 못했습니다. " + "휴머노이드 리그인지, Head Override 지정이 필요한지 확인하세요."); } return false; } void LateUpdate() { if (!resolved && !TryResolveBones()) return; if (head == null) return; Vector2 desired; if (TryGetTargetPosition(out Vector3 targetPos, out float attention)) { lastSeenTime = Time.time; desired = ComputeAngles(targetPos) * attention; } else if (Time.time - lastSeenTime < returnDelay) { // 커서가 잠깐 창 밖으로 나간 정도로는 바로 정면으로 돌리지 않는다. desired = currentAngles; } else { desired = Vector2.zero; } float t = 1f - Mathf.Exp(-responsiveness * Time.deltaTime); // 프레임레이트 독립 currentAngles = Vector2.Lerp(currentAngles, desired, t); ApplyRotation(); } /// 0 = 관심 없음(정면), 1 = 완전히 쳐다봄 bool TryGetTargetPosition(out Vector3 worldPos, out float attention) { worldPos = default; attention = 1f; if (!followCursor) { if (explicitTarget == null) return false; worldPos = explicitTarget.position; return true; } if (viewCamera == null) return false; System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero; if (!DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 screenPos)) return false; // 커서가 얼마나 가까운지로 관심도를 정한다. 멀면 굳이 쳐다보지 않는다. Vector2 headScreen = viewCamera.WorldToScreenPoint(head.position); float distance = Vector2.Distance(screenPos, headScreen); float inner = attentionRadius * characterScreenHeight; float outer = inner + Mathf.Max(1f, attentionFalloff * characterScreenHeight); attention = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(inner, outer, distance)); // 머리와 같은 깊이 평면에 커서를 투영한다. Vector3 camForward = viewCamera.transform.forward; float depth = Vector3.Dot(head.position - viewCamera.transform.position, camForward); if (depth <= 0.01f) return false; worldPos = viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, depth)); return true; } Vector2 ComputeAngles(Vector3 targetPos) { Vector3 local = root.InverseTransformDirection(targetPos - head.position); if (local.sqrMagnitude < 0.000001f) return Vector2.zero; local.Normalize(); float yaw = Mathf.Atan2(local.x, local.z) * Mathf.Rad2Deg; float pitch = Mathf.Asin(Mathf.Clamp(local.y, -1f, 1f)) * Mathf.Rad2Deg; return new Vector2( Mathf.Clamp(yaw, -maxYaw, maxYaw), Mathf.Clamp(pitch, -maxPitch, maxPitch)); } void ApplyRotation() { float yaw = currentAngles.x; float pitch = currentAngles.y; // pitch 가 양수면 대상이 위쪽. Unity 에서 right 축 +회전은 고개를 숙이므로 부호를 뒤집는다. float neckPart = neck != null ? neckShare : 0f; float headPart = 1f - neckPart; // 매 프레임 기준 자세로 되돌린다. 이걸 빼면 델타가 누적되어 목이 계속 돌아간다. // (Animator 가 포즈를 써주면 불필요하지만, 클립이 없는 지금은 되돌릴 주체가 없다) head.localRotation = restHead; if (neck != null) neck.localRotation = restNeck; if (neck != null) { neck.rotation = Delta(yaw * neckPart, pitch * neckPart) * neck.rotation; } // 목을 먼저 돌렸으므로 head.rotation 에는 그 결과가 이미 반영돼 있다. head.rotation = Delta(yaw * headPart, pitch * headPart) * head.rotation; } Quaternion Delta(float yaw, float pitch) { return Quaternion.AngleAxis(yaw, root.up) * Quaternion.AngleAxis(-pitch, root.right); } }