Files
MyCharacterAgent/Assets/02_Scripts/Character/HeadLookAt.cs
2026-08-26 09:47:39 +09:00

203 lines
7.3 KiB
C#

using UnityEngine;
/// <summary>
/// 캐릭터가 마우스 커서(또는 지정한 대상)를 눈으로 좇게 한다.
/// 표정·립싱크와 함께 "살아있다"는 인상을 만드는 핵심 요소.
///
/// 구현 노트: Animator 가 포즈를 쓴 뒤에 덮어써야 하므로 LateUpdate 에서 동작한다.
/// 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라 엉뚱한
/// 방향으로 꺾인다. 그래서 캐릭터 루트의 up/right 축을 기준으로 월드 회전을
/// 덧씌우는 방식을 쓴다. 리그 구조와 무관하게 동작한다.
/// </summary>
// 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;
Transform head;
Transform neck;
Transform root;
// 기준(정지) 자세. 회전 누적을 막기 위해 매 프레임 여기로 되돌린 뒤 적용한다.
Quaternion restHead, restNeck;
Vector2 currentAngles; // x = yaw, y = pitch
float lastSeenTime = -999f;
void Awake()
{
root = transform;
if (animator == null) animator = GetComponentInChildren<Animator>();
if (viewCamera == null) viewCamera = Camera.main;
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
}
// 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;
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))
{
lastSeenTime = Time.time;
desired = ComputeAngles(targetPos);
}
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();
}
bool TryGetTargetPosition(out Vector3 worldPos)
{
worldPos = default;
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;
// 머리와 같은 깊이 평면에 커서를 투영한다.
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);
}
}