203 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|