캐릭터 로드
This commit is contained in:
181
Assets/02_Scripts/Character/HeadLookAt.cs
Normal file
181
Assets/02_Scripts/Character/HeadLookAt.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
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;
|
||||
|
||||
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>();
|
||||
|
||||
ResolveBones();
|
||||
}
|
||||
|
||||
void ResolveBones()
|
||||
{
|
||||
if (headOverride != null)
|
||||
{
|
||||
head = headOverride;
|
||||
}
|
||||
else if (animator != null && animator.isHuman)
|
||||
{
|
||||
head = animator.GetBoneTransform(HumanBodyBones.Head);
|
||||
neck = animator.GetBoneTransform(HumanBodyBones.Neck); // 없는 리그도 있다
|
||||
}
|
||||
|
||||
if (head == null)
|
||||
{
|
||||
Debug.LogWarning("[HeadLookAt] 머리 본을 찾지 못했습니다. " +
|
||||
"FBX 임포트 설정에서 Animation Type = Humanoid 인지 확인하거나 " +
|
||||
"Head Override 를 직접 지정하세요.");
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user