diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity index da4ecb2..0c4579f 100644 --- a/Assets/01_Scenes/MainScene.unity +++ b/Assets/01_Scenes/MainScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2cf024e997274265c132019e6fe3a12d89dee2c521de59ab13feb031d70b5aad -size 16710 +oid sha256:4fc817dc4f068c238bd6596617328a5eeb0606b743401b728d29399b42987bbd +size 15011 diff --git a/Assets/02_Scripts/Character.meta b/Assets/02_Scripts/Character.meta new file mode 100644 index 0000000..942cfea --- /dev/null +++ b/Assets/02_Scripts/Character.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 736e5279b21711442b1c23fbbdede16f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/Character/AvatarTypes.cs b/Assets/02_Scripts/Character/AvatarTypes.cs new file mode 100644 index 0000000..9673dbd --- /dev/null +++ b/Assets/02_Scripts/Character/AvatarTypes.cs @@ -0,0 +1,65 @@ +using UnityEngine; + +/// +/// 캐릭터가 지을 수 있는 감정. LLM 이 출력하는 인라인 태그(예: [joy])가 여기로 매핑된다. +/// VRM 1.0 의 표정 프리셋과 같은 구성이라, 나중에 VRM 으로 갈아타도 그대로 대응된다. +/// +public enum AvatarEmotion +{ + Neutral = 0, + Happy, + Angry, + Sad, + Relaxed, + Surprised, +} + +/// +/// 입 모양. TTS 오디오 분석 결과가 여기로 들어온다. +/// +/// 모음 5개만 두는 이유: uLipSync 기본 프로파일(A/I/U/E/O), VRM 규격(aa/ih/ou/ee/oh), +/// VRChat 비셈 15개 중 모음 부분이 모두 이 5개로 수렴한다. 최소 공통분모라 +/// 어떤 모델이든 매핑이 가능하다. 자음까지 필요해지면 그때 확장한다. +/// +public enum AvatarViseme +{ + Silence = 0, + A, + I, + U, + E, + O, +} + +/// +/// 캐릭터 표현 계층의 유일한 창구. +/// +/// 립싱크 / 감정 / 시선 코드는 이 인터페이스만 알고, 모델이 FBX 인지 VRM 인지 +/// 전혀 모른다. 덕분에 모델을 교체하거나 포맷을 바꿔도 상위 로직은 그대로다. +/// +/// 값 설정과 실제 반영은 분리되어 있다. 구현체가 LateUpdate 에서 한 번에 반영하므로 +/// 호출자는 한 프레임에 여러 번 불러도 안전하다. +/// +public interface ICharacterAvatar +{ + /// 캐릭터 루트. 위치 이동과 히트테스트 기준. + Transform Root { get; } + + /// 휴머노이드 Animator. 없으면 null. + Animator Animator { get; } + + /// 감정 표정 세기 (0~1). + void SetEmotion(AvatarEmotion emotion, float weight); + + /// 모든 감정을 0 으로. + void ClearEmotions(); + + /// 입 모양 세기 (0~1). + void SetViseme(AvatarViseme viseme, float weight); + + /// 모든 입 모양을 0 으로. 발화 종료 시 호출. + void ClearVisemes(); + + /// 눈 감김 (0=뜸, 1=감음). 자동 깜빡임이 켜져 있으면 그쪽이 덮어쓴다. + void SetBlink(float weight); +} diff --git a/Assets/02_Scripts/Character/AvatarTypes.cs.meta b/Assets/02_Scripts/Character/AvatarTypes.cs.meta new file mode 100644 index 0000000..6ada780 --- /dev/null +++ b/Assets/02_Scripts/Character/AvatarTypes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2deea8e1d55fbfa40b1004980c68d08f \ No newline at end of file diff --git a/Assets/02_Scripts/Character/BlendShapeAvatar.cs b/Assets/02_Scripts/Character/BlendShapeAvatar.cs new file mode 100644 index 0000000..c27e2c4 --- /dev/null +++ b/Assets/02_Scripts/Character/BlendShapeAvatar.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// 블렌드셰이프 하나를 가리키는 참조. +[Serializable] +public class BlendShapeBinding +{ + public SkinnedMeshRenderer renderer; + public int index = -1; + + [Tooltip("이 셰이프의 최대치. 대부분 100 이지만 과하게 벌어지는 모델은 낮춰서 조절한다")] + public float maxWeight = 100f; + + public bool IsValid => + renderer != null && renderer.sharedMesh != null && + index >= 0 && index < renderer.sharedMesh.blendShapeCount; +} + +/// +/// 표정/입모양 하나를 구성하는 블렌드셰이프 묶음. +/// 눈 감김처럼 좌우가 나뉜 경우(ARKit eyeBlinkLeft/Right)를 위해 여러 개를 담는다. +/// +[Serializable] +public class BlendShapeGroup +{ + public List bindings = new List(); + public bool HasAny => bindings != null && bindings.Count > 0; +} + +/// +/// FBX 캐릭터용 ICharacterAvatar 구현. +/// +/// FBX 는 블렌드셰이프 이름을 표준화하지 않으므로(VRChat 의 vrc.v_* , ARKit 52, +/// VRoid 의 Fcl_* , 일본어 あいうえお 등 관례만 있음) 모델마다 한 번 매핑을 잡아준다. +/// 인스펙터의 자동 감지 버튼이 흔한 관례를 찾아 대부분 채워준다. +/// +/// 값 설정과 반영을 분리해, 여러 시스템(립싱크·감정·깜빡임)이 같은 프레임에 +/// 각자 값을 써도 LateUpdate 에서 한 번에 합산 반영된다. +/// +public class BlendShapeAvatar : MonoBehaviour, ICharacterAvatar +{ + [Header("참조")] + [Tooltip("비우면 이 오브젝트의 Transform 사용")] + [SerializeField] Transform root; + + [Tooltip("비우면 자식에서 자동 탐색")] + [SerializeField] Animator animator; + + [Header("매핑 (인스펙터의 자동 감지 사용 권장)")] + [SerializeField] BlendShapeGroup[] emotionGroups; + [SerializeField] BlendShapeGroup[] visemeGroups; + [SerializeField] BlendShapeGroup blinkGroup = new BlendShapeGroup(); + + [Header("자동 깜빡임")] + [SerializeField] bool autoBlink = true; + [SerializeField] Vector2 blinkIntervalRange = new Vector2(2.5f, 6.5f); + [SerializeField] float blinkDuration = 0.12f; + + static readonly int EmotionCount = Enum.GetValues(typeof(AvatarEmotion)).Length; + static readonly int VisemeCount = Enum.GetValues(typeof(AvatarViseme)).Length; + + float[] emotionWeights; + float[] visemeWeights; + float blinkWeight; + + // 매 프레임 0 으로 되돌릴 대상. 중복 제거해 캐시해둔다. + readonly List touched = new List(); + + float nextBlinkTime; + float blinkStartedAt = -1f; + + public Transform Root => root != null ? root : transform; + public Animator Animator => animator; + + void Reset() + { + EnsureArrays(); + root = transform; + animator = GetComponentInChildren(); + } + + void OnValidate() => EnsureArrays(); + + void Awake() + { + EnsureArrays(); + + emotionWeights = new float[EmotionCount]; + visemeWeights = new float[VisemeCount]; + + if (animator == null) animator = GetComponentInChildren(); + + CacheTouched(); + ScheduleNextBlink(); + } + + void EnsureArrays() + { + if (emotionGroups == null || emotionGroups.Length != EmotionCount) + { + var next = new BlendShapeGroup[EmotionCount]; + for (int i = 0; i < EmotionCount; i++) + next[i] = (emotionGroups != null && i < emotionGroups.Length && emotionGroups[i] != null) + ? emotionGroups[i] : new BlendShapeGroup(); + emotionGroups = next; + } + + if (visemeGroups == null || visemeGroups.Length != VisemeCount) + { + var next = new BlendShapeGroup[VisemeCount]; + for (int i = 0; i < VisemeCount; i++) + next[i] = (visemeGroups != null && i < visemeGroups.Length && visemeGroups[i] != null) + ? visemeGroups[i] : new BlendShapeGroup(); + visemeGroups = next; + } + + blinkGroup ??= new BlendShapeGroup(); + } + + /// + /// 매 프레임 초기화해야 할 블렌드셰이프 목록을 만든다. + /// 초기화를 빼먹으면 이전 프레임 표정이 그대로 남아 겹친다. + /// + void CacheTouched() + { + touched.Clear(); + void Collect(BlendShapeGroup g) + { + if (g?.bindings == null) return; + foreach (var b in g.bindings) + { + if (b == null || !b.IsValid) continue; + bool dup = false; + foreach (var t in touched) + { + if (t.renderer == b.renderer && t.index == b.index) { dup = true; break; } + } + if (!dup) touched.Add(b); + } + } + + foreach (var g in emotionGroups) Collect(g); + foreach (var g in visemeGroups) Collect(g); + Collect(blinkGroup); + } + + // ---------------- ICharacterAvatar ---------------- + + public void SetEmotion(AvatarEmotion emotion, float weight) + { + if (emotionWeights == null) return; + emotionWeights[(int)emotion] = Mathf.Clamp01(weight); + } + + public void ClearEmotions() + { + if (emotionWeights == null) return; + Array.Clear(emotionWeights, 0, emotionWeights.Length); + } + + public void SetViseme(AvatarViseme viseme, float weight) + { + if (visemeWeights == null) return; + visemeWeights[(int)viseme] = Mathf.Clamp01(weight); + } + + public void ClearVisemes() + { + if (visemeWeights == null) return; + Array.Clear(visemeWeights, 0, visemeWeights.Length); + } + + public void SetBlink(float weight) => blinkWeight = Mathf.Clamp01(weight); + + // ---------------- 반영 ---------------- + + void LateUpdate() + { + if (autoBlink) UpdateAutoBlink(); + Apply(); + } + + void UpdateAutoBlink() + { + if (blinkStartedAt < 0f) + { + if (Time.time >= nextBlinkTime) blinkStartedAt = Time.time; + return; + } + + float t = (Time.time - blinkStartedAt) / Mathf.Max(0.01f, blinkDuration); + if (t >= 1f) + { + blinkWeight = 0f; + blinkStartedAt = -1f; + ScheduleNextBlink(); + return; + } + + // 0 -> 1 -> 0 삼각파. 감았다 뜨는 한 번의 동작. + blinkWeight = 1f - Mathf.Abs(t * 2f - 1f); + } + + void ScheduleNextBlink() + { + nextBlinkTime = Time.time + UnityEngine.Random.Range(blinkIntervalRange.x, blinkIntervalRange.y); + } + + void Apply() + { + // 1) 이번 프레임에 건드릴 셰이프를 전부 0 으로 + foreach (var b in touched) + { + if (b.IsValid) b.renderer.SetBlendShapeWeight(b.index, 0f); + } + + // 2) 감정 -> 입모양 -> 깜빡임 순으로 누적 + for (int i = 0; i < emotionGroups.Length; i++) + AddGroup(emotionGroups[i], emotionWeights[i]); + + for (int i = 0; i < visemeGroups.Length; i++) + AddGroup(visemeGroups[i], visemeWeights[i]); + + AddGroup(blinkGroup, blinkWeight); + } + + void AddGroup(BlendShapeGroup group, float weight) + { + if (group?.bindings == null || weight <= 0f) return; + + foreach (var b in group.bindings) + { + if (b == null || !b.IsValid) continue; + + float target = weight * b.maxWeight; + float current = b.renderer.GetBlendShapeWeight(b.index); + b.renderer.SetBlendShapeWeight(b.index, Mathf.Min(current + target, b.maxWeight)); + } + } + + /// 에디터 자동 감지가 매핑을 바꾼 뒤 캐시를 다시 만들 때 사용. + public void RebuildCache() + { + EnsureArrays(); + CacheTouched(); + } + + /// 매핑이 얼마나 채워졌는지 요약. 에디터 표시용. + public string DescribeMapping() + { + EnsureArrays(); + int e = 0, v = 0; + foreach (var g in emotionGroups) if (g.HasAny) e++; + foreach (var g in visemeGroups) if (g.HasAny) v++; + return $"감정 {e}/{EmotionCount - 1}, 입모양 {v}/{VisemeCount - 1}, 깜빡임 {(blinkGroup.HasAny ? "O" : "X")}"; + } +} diff --git a/Assets/02_Scripts/Character/BlendShapeAvatar.cs.meta b/Assets/02_Scripts/Character/BlendShapeAvatar.cs.meta new file mode 100644 index 0000000..1d6718f --- /dev/null +++ b/Assets/02_Scripts/Character/BlendShapeAvatar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f705a8a153d76242beee16d85f491f1 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/HeadLookAt.cs b/Assets/02_Scripts/Character/HeadLookAt.cs new file mode 100644 index 0000000..12489b5 --- /dev/null +++ b/Assets/02_Scripts/Character/HeadLookAt.cs @@ -0,0 +1,181 @@ +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; + + Transform head; + Transform neck; + Transform root; + + 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(); + + 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); + } +} diff --git a/Assets/02_Scripts/Character/HeadLookAt.cs.meta b/Assets/02_Scripts/Character/HeadLookAt.cs.meta new file mode 100644 index 0000000..bf38190 --- /dev/null +++ b/Assets/02_Scripts/Character/HeadLookAt.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4acef0f074253e24cbdf756b4f7f3359 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/ProceduralIdle.cs b/Assets/02_Scripts/Character/ProceduralIdle.cs new file mode 100644 index 0000000..b906002 --- /dev/null +++ b/Assets/02_Scripts/Character/ProceduralIdle.cs @@ -0,0 +1,134 @@ +using UnityEngine; + +/// +/// 애니메이션 클립 없이 T포즈를 자연스러운 대기 자세로 바꾼다. +/// +/// 왜 필요한가: VRM 은 모델만 담고 애니메이션은 담지 않는다. 사용자가 임의의 VRM 을 +/// 넣는 구조에서는 클립이 딸려 온다는 보장이 없으므로, 최소한의 대기 자세는 +/// 코드로 만들어 두어야 한다. 나중에 실제 클립을 넣으면 이 컴포넌트를 끄면 된다. +/// +/// 구현 노트 1: 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라 +/// 엉뚱하게 꺾인다. 캐릭터 루트의 축을 기준으로 월드 회전을 덧씌워 리그에 무관하게 만든다. +/// +/// 구현 노트 2: VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 실제 본으로 반영되므로 +/// 그보다 먼저 실행돼야 한다. HeadLookAt 과 같은 이유로 실행 순서를 앞당긴다. +/// +[DefaultExecutionOrder(-100)] +public class ProceduralIdle : MonoBehaviour +{ + [Header("참조")] + [Tooltip("비우면 자식에서 자동 탐색")] + [SerializeField] Animator animator; + + [Header("팔 내리기 (T포즈 해소)")] + [Tooltip("위팔을 몸쪽으로 내리는 각도. 70 전후가 자연스럽다")] + [Range(0f, 90f)] + [SerializeField] float upperArmDown = 70f; + + [Tooltip("아래팔을 조금 더 내려 팔이 늘어지게 한다")] + [Range(0f, 40f)] + [SerializeField] float lowerArmDown = 10f; + + [Tooltip("팔을 몸에서 살짝 띄운다. 0 이면 몸에 붙는다")] + [Range(0f, 20f)] + [SerializeField] float armOutward = 5f; + + [Header("호흡")] + [SerializeField] bool breathing = true; + + [Tooltip("가슴이 오르내리는 각도")] + [Range(0f, 5f)] + [SerializeField] float breathAmplitude = 1.2f; + + [Tooltip("한 번 호흡하는 데 걸리는 시간(초)")] + [SerializeField] float breathPeriod = 4f; + + [Header("체중 이동")] + [SerializeField] bool weightShift = true; + + [Tooltip("좌우로 기우는 각도")] + [Range(0f, 5f)] + [SerializeField] float swayAmplitude = 1.2f; + + [Tooltip("한 번 왕복하는 데 걸리는 시간(초). 호흡과 주기를 다르게 해야 기계적으로 안 보인다")] + [SerializeField] float swayPeriod = 7f; + + Transform root; + Transform hips, chest, leftUpperArm, rightUpperArm, leftLowerArm, rightLowerArm; + + float phaseOffset; + + void Awake() + { + root = transform; + if (animator == null) animator = GetComponentInChildren(); + + if (animator == null || !animator.isHuman) + { + Debug.LogWarning("[ProceduralIdle] 휴머노이드 Animator 가 없어 비활성화합니다."); + enabled = false; + return; + } + + hips = animator.GetBoneTransform(HumanBodyBones.Hips); + chest = animator.GetBoneTransform(HumanBodyBones.Chest) + ?? animator.GetBoneTransform(HumanBodyBones.Spine); + + leftUpperArm = animator.GetBoneTransform(HumanBodyBones.LeftUpperArm); + rightUpperArm = animator.GetBoneTransform(HumanBodyBones.RightUpperArm); + leftLowerArm = animator.GetBoneTransform(HumanBodyBones.LeftLowerArm); + rightLowerArm = animator.GetBoneTransform(HumanBodyBones.RightLowerArm); + + // 여러 캐릭터가 있어도 동시에 같은 박자로 숨쉬지 않게 한다. + phaseOffset = Random.Range(0f, 100f); + } + + void LateUpdate() + { + ApplyArms(); + ApplyBody(); + } + + /// + /// T포즈에서 팔은 좌우로 뻗어 있다. 캐릭터의 forward 축을 중심으로 돌려 내린다. + /// 왼팔은 -right 방향이라 +각도, 오른팔은 +right 방향이라 -각도가 아래로 향한다. + /// + void ApplyArms() + { + RotateArm(leftUpperArm, +upperArmDown, +armOutward); + RotateArm(rightUpperArm, -upperArmDown, -armOutward); + + // 위팔을 돌리면 아래팔도 따라오므로, 여기서는 추가분만 얹는다. + RotateArm(leftLowerArm, +lowerArmDown, 0f); + RotateArm(rightLowerArm, -lowerArmDown, 0f); + } + + void RotateArm(Transform bone, float downAngle, float outwardAngle) + { + if (bone == null) return; + + Quaternion delta = Quaternion.AngleAxis(downAngle, root.forward); + if (!Mathf.Approximately(outwardAngle, 0f)) + { + delta = Quaternion.AngleAxis(outwardAngle, root.up) * delta; + } + bone.rotation = delta * bone.rotation; + } + + void ApplyBody() + { + float t = Time.time + phaseOffset; + + if (breathing && chest != null && breathPeriod > 0.01f) + { + float breath = Mathf.Sin(t * Mathf.PI * 2f / breathPeriod); + chest.rotation = Quaternion.AngleAxis(-breath * breathAmplitude, root.right) * chest.rotation; + } + + if (weightShift && hips != null && swayPeriod > 0.01f) + { + float sway = Mathf.Sin(t * Mathf.PI * 2f / swayPeriod); + hips.rotation = Quaternion.AngleAxis(sway * swayAmplitude, root.forward) * hips.rotation; + } + } +} diff --git a/Assets/02_Scripts/Character/ProceduralIdle.cs.meta b/Assets/02_Scripts/Character/ProceduralIdle.cs.meta new file mode 100644 index 0000000..37b5ea8 --- /dev/null +++ b/Assets/02_Scripts/Character/ProceduralIdle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5a8a0a8eac6b4944b60be542869f768 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/VrmAvatar.cs b/Assets/02_Scripts/Character/VrmAvatar.cs new file mode 100644 index 0000000..994dfa9 --- /dev/null +++ b/Assets/02_Scripts/Character/VrmAvatar.cs @@ -0,0 +1,119 @@ +using UnityEngine; +using UniVRM10; + +/// +/// VRM 1.0 모델용 ICharacterAvatar 구현. +/// +/// FBX 와 달리 매핑 작업이 필요 없다. VRM 규격이 표정과 입모양의 이름을 +/// 강제하므로 어떤 VRM 모델이든 같은 코드로 동작한다. 런타임 로딩으로 +/// 사용자가 임의의 모델을 넣는 구조에서는 이 점이 필수적이다. +/// +public class VrmAvatar : MonoBehaviour, ICharacterAvatar +{ + [Tooltip("VRM 자체 자동 깜빡임/시선을 쓰지 않고 우리가 제어할지. " + + "우리 HeadLookAt 과 충돌하지 않게 기본은 켬")] + [SerializeField] bool overrideVrmAutoExpressions = true; + + Vrm10Instance instance; + + float[] emotionWeights; + float[] visemeWeights; + float blinkWeight; + + public Transform Root => transform; + public Animator Animator { get; private set; } + + /// 로더가 인스턴스를 만든 직후 호출한다. + public void Bind(Vrm10Instance vrm) + { + instance = vrm; + Animator = vrm != null ? vrm.GetComponent() : null; + + emotionWeights = new float[System.Enum.GetValues(typeof(AvatarEmotion)).Length]; + visemeWeights = new float[System.Enum.GetValues(typeof(AvatarViseme)).Length]; + + if (instance != null && overrideVrmAutoExpressions) + { + // VRM 이 스스로 깜빡이고 시선을 돌리면 우리 제어와 싸운다. + instance.LookAtTargetType = VRM10ObjectLookAt.LookAtTargetTypes.SpecifiedTransform; + } + } + + // ---------------- ICharacterAvatar ---------------- + + public void SetEmotion(AvatarEmotion emotion, float weight) + { + if (emotionWeights == null) return; + emotionWeights[(int)emotion] = Mathf.Clamp01(weight); + } + + public void ClearEmotions() + { + if (emotionWeights == null) return; + System.Array.Clear(emotionWeights, 0, emotionWeights.Length); + } + + public void SetViseme(AvatarViseme viseme, float weight) + { + if (visemeWeights == null) return; + visemeWeights[(int)viseme] = Mathf.Clamp01(weight); + } + + public void ClearVisemes() + { + if (visemeWeights == null) return; + System.Array.Clear(visemeWeights, 0, visemeWeights.Length); + } + + public void SetBlink(float weight) => blinkWeight = Mathf.Clamp01(weight); + + // ---------------- 반영 ---------------- + + void LateUpdate() + { + if (instance == null || instance.Runtime == null) return; + + var expr = instance.Runtime.Expression; + if (expr == null) return; + + // 감정. Neutral(0) 은 "표정 없음"이라 대응 키가 없다. + for (int i = 1; i < emotionWeights.Length; i++) + { + expr.SetWeight(ToKey((AvatarEmotion)i), emotionWeights[i]); + } + + // 입 모양. Silence(0) 도 마찬가지로 대응 키가 없다. + for (int i = 1; i < visemeWeights.Length; i++) + { + expr.SetWeight(ToKey((AvatarViseme)i), visemeWeights[i]); + } + + expr.SetWeight(ExpressionKey.Blink, blinkWeight); + } + + static ExpressionKey ToKey(AvatarEmotion e) + { + switch (e) + { + case AvatarEmotion.Happy: return ExpressionKey.Happy; + case AvatarEmotion.Angry: return ExpressionKey.Angry; + case AvatarEmotion.Sad: return ExpressionKey.Sad; + case AvatarEmotion.Relaxed: return ExpressionKey.Relaxed; + case AvatarEmotion.Surprised: return ExpressionKey.Surprised; + default: return ExpressionKey.Neutral; + } + } + + static ExpressionKey ToKey(AvatarViseme v) + { + switch (v) + { + case AvatarViseme.A: return ExpressionKey.Aa; + case AvatarViseme.I: return ExpressionKey.Ih; + case AvatarViseme.U: return ExpressionKey.Ou; + case AvatarViseme.E: return ExpressionKey.Ee; + case AvatarViseme.O: return ExpressionKey.Oh; + default: return ExpressionKey.Neutral; + } + } +} diff --git a/Assets/02_Scripts/Character/VrmAvatar.cs.meta b/Assets/02_Scripts/Character/VrmAvatar.cs.meta new file mode 100644 index 0000000..900939a --- /dev/null +++ b/Assets/02_Scripts/Character/VrmAvatar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f9c072d633024348ab13315da4ba2a4 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/VrmCharacterLoader.cs b/Assets/02_Scripts/Character/VrmCharacterLoader.cs new file mode 100644 index 0000000..8798a38 --- /dev/null +++ b/Assets/02_Scripts/Character/VrmCharacterLoader.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using UniGLTF; +using UniVRM10; + +/// +/// VRM 모델을 실행 중에 파일에서 불러온다. +/// +/// 모델을 빌드에 포함하지 않고 사용자가 직접 자기 파일을 넣게 하는 구조다. +/// 부스 등에서 판매되는 아바타 규약(VN3)은 대체로 모델 데이터의 재배포를 +/// 금지하므로, 앱이 모델을 유통하지 않는 이 방식이라야 배포가 가능해진다. +/// VSeeFace, VMagicMirror 등이 같은 구조를 쓴다. +/// +/// 탐색 순서 +/// 1. 이전에 사용자가 고른 경로 (PlayerPrefs) +/// 2. 실행 파일 옆 Models 폴더의 첫 .vrm +/// +public class VrmCharacterLoader : MonoBehaviour +{ + const string PrefKey = "MyCharacterAgent.ModelPath"; + + [Header("배치")] + [Tooltip("로드한 모델을 놓을 위치")] + [SerializeField] Vector3 spawnPosition = Vector3.zero; + + [Tooltip("VRM 1.0 규격상 모델은 +Z 를 향한다. 카메라 쪽으로 돌려세운다")] + [SerializeField] bool faceCamera = true; + + [Header("컴포넌트")] + [Tooltip("로드 후 시선 추적 컴포넌트를 붙일지")] + [SerializeField] bool addHeadLookAt = true; + + [Tooltip("클릭 판정용 콜라이더를 붙일지. 없으면 클릭 통과가 캐릭터를 인식하지 못한다")] + [SerializeField] bool addCollider = true; + + [Tooltip("애니메이션 클립이 없을 때 T포즈를 대기 자세로 바꿔주는 컴포넌트. " + + "실제 클립을 넣으면 끄면 된다")] + [SerializeField] bool addProceduralIdle = true; + + CancellationTokenSource cts; + + /// 현재 로드된 캐릭터. 없으면 null. + public ICharacterAvatar Current { get; private set; } + + /// 로드 성공 시 호출. 립싱크 등 후속 시스템이 구독한다. + public event Action Loaded; + + /// 로드 실패 시 사유를 전달한다. + public event Action LoadFailed; + + GameObject currentRoot; + + async void Start() + { + string path = ResolveInitialPath(); + if (string.IsNullOrEmpty(path)) + { + string dir = ModelsDirectory; + Debug.LogWarning($"[VrmCharacterLoader] 불러올 .vrm 이 없습니다. " + + $"다음 폴더에 넣어주세요: {dir}"); + LoadFailed?.Invoke($"{dir} 폴더에 .vrm 파일을 넣어주세요."); + return; + } + + await LoadFrom(path); + } + + void OnDestroy() + { + cts?.Cancel(); + cts?.Dispose(); + } + + /// 실행 파일 옆의 Models 폴더. 에디터에서는 프로젝트 루트 옆. + public static string ModelsDirectory + { + get + { +#if UNITY_EDITOR + string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; +#else + string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; +#endif + return Path.Combine(baseDir, "Models"); + } + } + + string ResolveInitialPath() + { + // 1) 사용자가 이전에 고른 파일 + string saved = PlayerPrefs.GetString(PrefKey, string.Empty); + if (!string.IsNullOrEmpty(saved) && File.Exists(saved)) return saved; + + // 2) Models 폴더의 첫 .vrm + try + { + string dir = ModelsDirectory; + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + return null; + } + + var files = Directory.GetFiles(dir, "*.vrm", SearchOption.TopDirectoryOnly); + if (files.Length > 0) + { + Array.Sort(files, StringComparer.OrdinalIgnoreCase); + return files[0]; + } + } + catch (Exception e) + { + Debug.LogError($"[VrmCharacterLoader] Models 폴더 접근 실패: {e.Message}"); + } + return null; + } + + /// 지정한 .vrm 파일을 불러온다. 트레이의 "모델 선택"에서도 호출한다. + public async Task LoadFrom(string path) + { + if (!File.Exists(path)) + { + string msg = $"파일이 없습니다: {path}"; + Debug.LogError("[VrmCharacterLoader] " + msg); + LoadFailed?.Invoke(msg); + return; + } + + cts?.Cancel(); + cts?.Dispose(); + cts = new CancellationTokenSource(); + + try + { + Debug.Log($"[VrmCharacterLoader] 로드 시작: {path}"); + + var vrm = await Vrm10.LoadPathAsync( + path, + canLoadVrm0X: true, // VRM 0.x 를 로드 시점에 1.0 으로 마이그레이션 + showMeshes: false, // 배치를 끝낸 뒤 한 번에 보여줘야 깜빡임이 없다 + awaitCaller: new RuntimeOnlyAwaitCaller(), + materialGenerator: null, // null 이면 현재 렌더 파이프라인에 맞는 생성기를 UniVRM 이 고른다 + ct: cts.Token); + + if (vrm == null) + { + string msg = "VRM 로드에 실패했습니다. 파일이 손상되었거나 VRM 형식이 아닐 수 있습니다."; + Debug.LogError("[VrmCharacterLoader] " + msg); + LoadFailed?.Invoke(msg); + return; + } + + Setup(vrm); + + PlayerPrefs.SetString(PrefKey, path); + PlayerPrefs.Save(); + + Debug.Log($"[VrmCharacterLoader] 로드 완료: {vrm.name}"); + } + catch (OperationCanceledException) + { + // 다른 모델로 교체하는 중. 정상 경로. + } + catch (Exception e) + { + Debug.LogError($"[VrmCharacterLoader] 로드 중 예외: {e}"); + LoadFailed?.Invoke(e.Message); + } + } + + void Setup(Vrm10Instance vrm) + { + // 이전 모델 정리 + if (currentRoot != null) Destroy(currentRoot); + + currentRoot = vrm.gameObject; + currentRoot.transform.SetParent(transform, false); + currentRoot.transform.localPosition = spawnPosition; + currentRoot.transform.localRotation = faceCamera + ? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다 + : Quaternion.identity; + + var avatar = currentRoot.AddComponent(); + avatar.Bind(vrm); + Current = avatar; + + if (addCollider) FitCapsule(currentRoot); + if (addHeadLookAt && currentRoot.GetComponent() == null) + { + currentRoot.AddComponent(); + } + if (addProceduralIdle && currentRoot.GetComponent() == null) + { + currentRoot.AddComponent(); + } + + // 배치가 끝난 뒤에 표시. showMeshes:false 로 로드한 이유. + var runtime = vrm.GetComponent(); + if (runtime != null) runtime.ShowMeshes(); + + Loaded?.Invoke(avatar); + } + + /// 렌더러 전체를 감싸는 캡슐 콜라이더. 클릭 히트테스트용. + static void FitCapsule(GameObject go) + { + var renderers = go.GetComponentsInChildren(true); + if (renderers.Length == 0) return; + + Bounds world = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) world.Encapsulate(renderers[i].bounds); + + var col = go.GetComponent(); + if (col == null) col = go.AddComponent(); + + Vector3 scale = go.transform.lossyScale; + float sx = Mathf.Approximately(scale.x, 0f) ? 1f : Mathf.Abs(scale.x); + float sy = Mathf.Approximately(scale.y, 0f) ? 1f : Mathf.Abs(scale.y); + float sz = Mathf.Approximately(scale.z, 0f) ? 1f : Mathf.Abs(scale.z); + + col.direction = 1; // Y 축 + col.center = go.transform.InverseTransformPoint(world.center); + col.height = world.size.y / sy; + col.radius = Mathf.Max(world.size.x / sx, world.size.z / sz) * 0.5f; + } +} diff --git a/Assets/02_Scripts/Character/VrmCharacterLoader.cs.meta b/Assets/02_Scripts/Character/VrmCharacterLoader.cs.meta new file mode 100644 index 0000000..d52a23c --- /dev/null +++ b/Assets/02_Scripts/Character/VrmCharacterLoader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 49c1dd5cf50ef024cac9a0acfc058af0 \ No newline at end of file diff --git a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs index 88a7126..c6f1aee 100644 --- a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs +++ b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs @@ -139,22 +139,10 @@ bool HitTest(out RaycastHit hit) { hit = default; if (hitTestCamera == null) return false; - if (!Win32.GetCursorPos(out var pt)) return false; - if (!Win32.GetWindowRect(window.Hwnd, out var rc)) return false; + // 좌표 변환은 DesktopCursor 에 모아뒀다. 시선 추적도 같은 변환을 쓴다. + if (!DesktopCursor.TryGetScreenPosition(window.Hwnd, out Vector2 sp)) return false; - int w = rc.Width, h = rc.Height; - if (w <= 0 || h <= 0) return false; - - // 창 기준 좌표 (좌상단 원점, Y 아래로) - int localX = pt.x - rc.left; - int localY = pt.y - rc.top; - if (localX < 0 || localY < 0 || localX >= w || localY >= h) return false; - - // Unity 화면 좌표 (좌하단 원점, Y 위로) - float ux = localX * (Screen.width / (float)w); - float uy = (h - localY) * (Screen.height / (float)h); - - var ray = hitTestCamera.ScreenPointToRay(new Vector3(ux, uy, 0f)); + var ray = hitTestCamera.ScreenPointToRay(new Vector3(sp.x, sp.y, 0f)); return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers); } diff --git a/Assets/02_Scripts/Desktop/DesktopCursor.cs b/Assets/02_Scripts/Desktop/DesktopCursor.cs new file mode 100644 index 0000000..ec3f122 --- /dev/null +++ b/Assets/02_Scripts/Desktop/DesktopCursor.cs @@ -0,0 +1,52 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// OS 커서 위치를 Unity 화면 좌표로 변환한다. +/// +/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않으므로 Unity 의 Mouse.position 을 +/// 신뢰할 수 없다. 또 창 밖 좌표는 Unity 가 아예 모른다. 그래서 GetCursorPos 로 +/// OS 커서를 직접 읽고 창 사각형 기준으로 환산한다. +/// +/// 히트테스트와 시선 추적이 같은 변환을 쓰므로 여기 한 곳에만 둔다. +/// +public static class DesktopCursor +{ + /// + /// 커서의 Unity 화면 좌표(좌하단 원점)를 구한다. 커서가 창 밖이면 false. + /// + public static bool TryGetScreenPosition(System.IntPtr hwnd, out Vector2 screenPos) + { + screenPos = default; + +#if !UNITY_EDITOR && UNITY_STANDALONE_WIN + if (hwnd == System.IntPtr.Zero) return false; + if (!Win32.GetCursorPos(out var pt)) return false; + if (!Win32.GetWindowRect(hwnd, out var rc)) return false; + + int w = rc.Width, h = rc.Height; + if (w <= 0 || h <= 0) return false; + + // 창 기준 좌표 (좌상단 원점, Y 아래로) + int localX = pt.x - rc.left; + int localY = pt.y - rc.top; + if (localX < 0 || localY < 0 || localX >= w || localY >= h) return false; + + // Unity 화면 좌표로. 창 크기와 백버퍼 크기가 어긋날 경우를 대비해 비율 환산. + screenPos = new Vector2( + localX * (Screen.width / (float)w), + (h - localY) * (Screen.height / (float)h)); + return true; +#else + // 에디터에서는 일반 마우스 입력으로 대체해 미리보기가 가능하게 한다. + var mouse = Mouse.current; + if (mouse == null) return false; + + Vector2 p = mouse.position.ReadValue(); + if (p.x < 0f || p.y < 0f || p.x >= Screen.width || p.y >= Screen.height) return false; + + screenPos = p; + return true; +#endif + } +} diff --git a/Assets/02_Scripts/Desktop/DesktopCursor.cs.meta b/Assets/02_Scripts/Desktop/DesktopCursor.cs.meta new file mode 100644 index 0000000..c25f936 --- /dev/null +++ b/Assets/02_Scripts/Desktop/DesktopCursor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4fb0f7484cbf394fbdcc4d532b4ed07 \ No newline at end of file diff --git a/Assets/02_Scripts/Desktop/TransparentWindow.cs b/Assets/02_Scripts/Desktop/TransparentWindow.cs index 9d4b41f..86cc32e 100644 --- a/Assets/02_Scripts/Desktop/TransparentWindow.cs +++ b/Assets/02_Scripts/Desktop/TransparentWindow.cs @@ -21,10 +21,7 @@ public class TransparentWindow : MonoBehaviour [Tooltip("작업표시줄 / Alt+Tab 에서 숨김 (WS_EX_TOOLWINDOW)")] [SerializeField] bool hideFromTaskbar = true; - [Header("검증용")] - [Tooltip("런타임 큐브 생성. 씬에 AlphaTestCube 가 있으면 그쪽이 우선한다")] - [SerializeField] bool spawnTestCube = false; - + [Header("렌더링")] [Tooltip("URP 포스트프로세싱 비활성화")] [SerializeField] bool forceDisablePostProcessing = true; @@ -32,7 +29,6 @@ public class TransparentWindow : MonoBehaviour [SerializeField] bool quitOnEscape = true; Camera cam; - Transform testCube; /// 플레이어 창 핸들. 준비되기 전에는 Zero. public System.IntPtr Hwnd { get; private set; } = System.IntPtr.Zero; @@ -55,11 +51,6 @@ void Awake() } Application.runInBackground = true; - - // 씬에 큐브가 있으면 그것을 쓰고, 없을 때만 런타임 생성 - var inScene = GameObject.Find("AlphaTestCube"); - if (inScene != null) testCube = inScene.transform; - else if (spawnTestCube) CreateTestCube(); } void Start() @@ -73,8 +64,6 @@ void Start() void Update() { - if (testCube != null) testCube.Rotate(new Vector3(30f, 45f, 15f) * Time.deltaTime); - // 포커스가 있을 때만 동작하는 보조 탈출구 if (quitOnEscape) { @@ -83,14 +72,6 @@ void Update() } } - void CreateTestCube() - { - var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); - cube.name = "AlphaTestCube"; - cube.transform.position = transform.position + transform.forward * 5f; - testCube = cube.transform; - } - #if !UNITY_EDITOR && UNITY_STANDALONE_WIN IEnumerator ApplyWindowStyle() { diff --git a/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs b/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs new file mode 100644 index 0000000..4544566 --- /dev/null +++ b/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +/// +/// BlendShapeAvatar 의 매핑을 흔한 관례에서 자동으로 찾아 채운다. +/// +/// FBX 는 블렌드셰이프 이름을 표준화하지 않지만 사실상의 관례가 몇 가지 있다. +/// - VRChat 비셈: vrc.v_aa, vrc.v_ih, vrc.v_ou, vrc.v_E, vrc.v_oh +/// - VRoid: Fcl_MTH_A, Fcl_ALL_Joy, Fcl_EYE_Close +/// - ARKit 52: eyeBlinkLeft, eyeBlinkRight +/// - 일본어: あ い う え お, まばたき, 笑い +/// 대부분 모델이 이 중 하나를 따르므로 클릭 한 번으로 상당수가 채워진다. +/// 안 맞는 것만 손으로 지정하면 된다. +/// +[CustomEditor(typeof(BlendShapeAvatar))] +public class BlendShapeAvatarEditor : Editor +{ + struct ShapeRef + { + public SkinnedMeshRenderer renderer; + public int index; + public string normalized; + } + + // 정규화(소문자화 + 구분자 제거) 후 비교할 후보들. 앞쪽이 우선순위가 높다. + static readonly string[][] VisemeCandidates = + { + null, // Silence: 매핑 없음 + new[] { "vrcvaa", "aa", "a", "あ", "fclmtha", "mtha", "visemeaa" }, // A + new[] { "vrcvih", "ih", "i", "い", "fclmthi", "mthi", "visemeih" }, // I + new[] { "vrcvou", "ou", "u", "う", "fclmthu", "mthu", "visemeou" }, // U + new[] { "vrcve", "ee", "e", "え", "fclmthe", "mthe", "visemeee" }, // E + new[] { "vrcvoh", "oh", "o", "お", "fclmtho", "mtho", "visemeoh" }, // O + }; + + static readonly string[][] EmotionCandidates = + { + null, // Neutral: 매핑 없음 + new[] { "fclalljoy", "joy", "happy", "smile", "笑い" }, // Happy + new[] { "fclallangry", "angry", "anger", "怒り" }, // Angry + new[] { "fclallsorrow", "sorrow", "sad", "悲しみ", "悲しい" }, // Sad + new[] { "fclallfun", "fun", "relaxed", "calm" }, // Relaxed + new[] { "fclallsurprised", "surprised", "surprise", "驚き", "びっくり" }, // Surprised + }; + + static readonly string[] BlinkSingle = + { "fcleyeclose", "blink", "まばたき", "eyeclose", "eyesclosed", "vrcblink" }; + + static readonly string[] BlinkArkitPair = { "eyeblinkleft", "eyeblinkright" }; + + string report; + + public override void OnInspectorGUI() + { + var avatar = (BlendShapeAvatar)target; + + EditorGUILayout.HelpBox( + "FBX 모델의 블렌드셰이프 이름은 표준이 아니라 관례입니다.\n" + + "자동 감지로 대부분 채운 뒤, 비어 있는 항목만 직접 지정하세요.", + MessageType.Info); + + EditorGUILayout.LabelField("현재 매핑", avatar.DescribeMapping()); + + EditorGUILayout.Space(); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("블렌드셰이프 자동 감지", GUILayout.Height(28))) + { + report = AutoDetect(avatar); + } + if (GUILayout.Button("매핑 초기화", GUILayout.Width(100), GUILayout.Height(28))) + { + if (EditorUtility.DisplayDialog("매핑 초기화", "모든 매핑을 지웁니다. 계속할까요?", "지우기", "취소")) + { + ClearAll(); + report = "매핑을 초기화했습니다."; + } + } + } + + if (GUILayout.Button("이 모델의 블렌드셰이프 이름 전부 출력")) + { + DumpNames(avatar); + } + + if (!string.IsNullOrEmpty(report)) + { + EditorGUILayout.Space(); + EditorGUILayout.HelpBox(report, MessageType.None); + } + + EditorGUILayout.Space(); + DrawDefaultInspector(); + } + + static List CollectShapes(BlendShapeAvatar avatar) + { + var list = new List(); + var renderers = avatar.GetComponentsInChildren(true); + + foreach (var r in renderers) + { + if (r.sharedMesh == null) continue; + for (int i = 0; i < r.sharedMesh.blendShapeCount; i++) + { + list.Add(new ShapeRef + { + renderer = r, + index = i, + normalized = Normalize(r.sharedMesh.GetBlendShapeName(i)), + }); + } + } + return list; + } + + /// 비교를 위해 소문자화하고 구분자를 제거한다. "vrc.v_aa" -> "vrcvaa" + static string Normalize(string raw) + { + if (string.IsNullOrEmpty(raw)) return string.Empty; + + // 메시 이름이 접두어로 붙는 경우가 있어 마지막 구간만 쓴다. "Body.vrc.v_aa" 같은 형태 대비 + var sb = new System.Text.StringBuilder(raw.Length); + foreach (char c in raw) + { + if (c == '.' || c == '_' || c == '-' || c == ' ') continue; + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + static bool TryMatch(List shapes, string[] candidates, out ShapeRef hit) + { + hit = default; + if (candidates == null) return false; + + // 1) 정확히 일치하는 것 우선 + foreach (var c in candidates) + { + foreach (var s in shapes) + { + if (s.normalized == c) { hit = s; return true; } + } + } + + // 2) 부분 일치. 짧은 후보("a" 등)는 "angry" 같은 데 걸려 오탐이 심해 제외한다. + foreach (var c in candidates) + { + if (c.Length < 3) continue; + foreach (var s in shapes) + { + if (s.normalized.Contains(c)) { hit = s; return true; } + } + } + return false; + } + + string AutoDetect(BlendShapeAvatar avatar) + { + var shapes = CollectShapes(avatar); + if (shapes.Count == 0) + { + return "블렌드셰이프를 가진 SkinnedMeshRenderer 를 찾지 못했습니다.\n" + + "이 컴포넌트가 캐릭터 루트에 붙어 있는지 확인하세요."; + } + + Undo.RecordObject(avatar, "Auto Detect BlendShapes"); + var so = serializedObject; + so.Update(); + + var found = new List(); + var missing = new List(); + + // 감정 + var emotionProp = so.FindProperty("emotionGroups"); + emotionProp.arraySize = EmotionCandidates.Length; + for (int i = 1; i < EmotionCandidates.Length; i++) + { + string label = ((AvatarEmotion)i).ToString(); + if (TryMatch(shapes, EmotionCandidates[i], out var hit)) + { + SetSingleBinding(emotionProp.GetArrayElementAtIndex(i), hit); + found.Add($"{label} → {hit.renderer.sharedMesh.GetBlendShapeName(hit.index)}"); + } + else + { + ClearBindings(emotionProp.GetArrayElementAtIndex(i)); + missing.Add(label); + } + } + + // 입 모양 + var visemeProp = so.FindProperty("visemeGroups"); + visemeProp.arraySize = VisemeCandidates.Length; + for (int i = 1; i < VisemeCandidates.Length; i++) + { + string label = "입:" + ((AvatarViseme)i); + if (TryMatch(shapes, VisemeCandidates[i], out var hit)) + { + SetSingleBinding(visemeProp.GetArrayElementAtIndex(i), hit); + found.Add($"{label} → {hit.renderer.sharedMesh.GetBlendShapeName(hit.index)}"); + } + else + { + ClearBindings(visemeProp.GetArrayElementAtIndex(i)); + missing.Add(label); + } + } + + // 깜빡임: 한 개짜리를 먼저 찾고, 없으면 ARKit 좌우 쌍을 시도한다. + var blinkProp = so.FindProperty("blinkGroup"); + if (TryMatch(shapes, BlinkSingle, out var blinkHit)) + { + SetSingleBinding(blinkProp, blinkHit); + found.Add($"깜빡임 → {blinkHit.renderer.sharedMesh.GetBlendShapeName(blinkHit.index)}"); + } + else + { + var pair = new List(); + foreach (var c in BlinkArkitPair) + { + if (TryMatch(shapes, new[] { c }, out var h)) pair.Add(h); + } + + if (pair.Count > 0) + { + SetBindings(blinkProp, pair); + found.Add($"깜빡임 → ARKit 좌우 {pair.Count}개"); + } + else + { + ClearBindings(blinkProp); + missing.Add("깜빡임"); + } + } + + so.ApplyModifiedProperties(); + avatar.RebuildCache(); + EditorUtility.SetDirty(avatar); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"블렌드셰이프 {shapes.Count}개를 검사했습니다."); + sb.AppendLine(); + sb.AppendLine($"찾음 ({found.Count})"); + foreach (var f in found) sb.AppendLine(" " + f); + + if (missing.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"못 찾음 ({missing.Count}) — 아래에서 직접 지정하세요"); + sb.AppendLine(" " + string.Join(", ", missing)); + } + return sb.ToString(); + } + + static void SetSingleBinding(SerializedProperty group, ShapeRef shape) + { + SetBindings(group, new List { shape }); + } + + static void SetBindings(SerializedProperty group, List shapes) + { + var bindings = group.FindPropertyRelative("bindings"); + bindings.arraySize = shapes.Count; + + for (int i = 0; i < shapes.Count; i++) + { + var b = bindings.GetArrayElementAtIndex(i); + b.FindPropertyRelative("renderer").objectReferenceValue = shapes[i].renderer; + b.FindPropertyRelative("index").intValue = shapes[i].index; + b.FindPropertyRelative("maxWeight").floatValue = 100f; + } + } + + static void ClearBindings(SerializedProperty group) + { + group.FindPropertyRelative("bindings").arraySize = 0; + } + + void ClearAll() + { + var so = serializedObject; + so.Update(); + + var emo = so.FindProperty("emotionGroups"); + for (int i = 0; i < emo.arraySize; i++) ClearBindings(emo.GetArrayElementAtIndex(i)); + + var vis = so.FindProperty("visemeGroups"); + for (int i = 0; i < vis.arraySize; i++) ClearBindings(vis.GetArrayElementAtIndex(i)); + + ClearBindings(so.FindProperty("blinkGroup")); + + so.ApplyModifiedProperties(); + ((BlendShapeAvatar)target).RebuildCache(); + } + + static void DumpNames(BlendShapeAvatar avatar) + { + var renderers = avatar.GetComponentsInChildren(true); + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"[BlendShapeAvatar] {avatar.name} 의 블렌드셰이프 목록"); + + foreach (var r in renderers) + { + if (r.sharedMesh == null || r.sharedMesh.blendShapeCount == 0) continue; + sb.AppendLine($"--- {r.name} ({r.sharedMesh.blendShapeCount}개) ---"); + for (int i = 0; i < r.sharedMesh.blendShapeCount; i++) + sb.AppendLine($" [{i}] {r.sharedMesh.GetBlendShapeName(i)}"); + } + Debug.Log(sb.ToString()); + } +} diff --git a/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs.meta b/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs.meta new file mode 100644 index 0000000..cf923ce --- /dev/null +++ b/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6caf6011db2e0c2418d8b40bb8b2f8c1 \ No newline at end of file diff --git a/Assets/02_Scripts/Editor/CharacterSetupMenu.cs b/Assets/02_Scripts/Editor/CharacterSetupMenu.cs new file mode 100644 index 0000000..c58a834 --- /dev/null +++ b/Assets/02_Scripts/Editor/CharacterSetupMenu.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; + +/// +/// 씬에 배치한 FBX 캐릭터에 필요한 컴포넌트를 한 번에 붙이고, 흔한 함정을 진단한다. +/// 부스에서 받은 VRChat 아바타는 특히 리그 타입과 셰이더에서 걸리는 경우가 많다. +/// +public static class CharacterSetupMenu +{ + [MenuItem("Tools/Desktop Overlay/5. Setup Selected FBX As Character")] + public static void SetupCharacter() + { + var go = Selection.activeGameObject; + if (go == null || EditorUtility.IsPersistent(go)) + { + EditorUtility.DisplayDialog("캐릭터 셋업", + "씬에 배치된 캐릭터 오브젝트를 선택한 뒤 실행하세요.\n" + + "(프로젝트 창의 FBX 에셋이 아니라, 씬으로 끌어다 놓은 인스턴스입니다)", + "확인"); + return; + } + + var log = new StringBuilder(); + log.AppendLine($"[캐릭터 셋업] {go.name}"); + log.AppendLine(); + + Undo.RegisterFullObjectHierarchyUndo(go, "Setup Character"); + + // 1) 휴머노이드 리그 확인 — 시선 추적과 애니메이션의 전제 + var animator = go.GetComponentInChildren(); + if (animator == null) + { + log.AppendLine("✗ Animator 가 없습니다."); + log.AppendLine(" FBX 임포트 설정 > Rig > Animation Type = Humanoid 로 바꾸고 Apply 하세요."); + } + else if (!animator.isHuman) + { + log.AppendLine("✗ Animator 는 있지만 휴머노이드가 아닙니다."); + log.AppendLine(" FBX 임포트 설정 > Rig > Animation Type = Humanoid 로 바꾸고 Apply 하세요."); + log.AppendLine(" (시선 추적이 머리 본을 찾지 못합니다)"); + } + else + { + log.AppendLine("✓ 휴머노이드 리그 확인"); + } + + // 2) 블렌드셰이프 확인 — 표정/립싱크의 전제 + int shapeCount = 0; + var renderers = go.GetComponentsInChildren(true); + foreach (var r in renderers) + { + if (r.sharedMesh != null) shapeCount += r.sharedMesh.blendShapeCount; + } + + if (shapeCount == 0) + { + log.AppendLine("✗ 블렌드셰이프가 하나도 없습니다. 표정과 립싱크가 불가능합니다."); + log.AppendLine(" FBX 임포트 설정 > Model > Import BlendShapes 가 켜져 있는지 확인하세요."); + } + else + { + log.AppendLine($"✓ 블렌드셰이프 {shapeCount}개 ({renderers.Length}개 메시)"); + } + + // 3) 셰이더 진단 — VRChat 아바타는 Built-in RP 용 셰이더를 쓰므로 URP 에서 분홍색이 된다 + var shaders = new HashSet(); + foreach (var r in go.GetComponentsInChildren(true)) + { + foreach (var m in r.sharedMaterials) + { + if (m != null && m.shader != null) shaders.Add(m.shader.name); + } + } + + var nonUrp = new List(); + foreach (var s in shaders) + { + if (!s.StartsWith("Universal Render Pipeline/") && + !s.StartsWith("Shader Graphs/") && + !s.StartsWith("VRM10/Universal Render Pipeline/")) + { + nonUrp.Add(s); + } + } + + if (nonUrp.Count > 0) + { + log.AppendLine($"⚠ URP 가 아닌 셰이더 {nonUrp.Count}종 — 분홍색으로 보일 수 있습니다:"); + foreach (var s in nonUrp) log.AppendLine(" " + s); + log.AppendLine(" 해결: Window > Rendering > Render Pipeline Converter 로 일괄 변환하거나,"); + log.AppendLine(" 머티리얼 셰이더를 Universal Render Pipeline/Lit 으로 직접 바꾸세요."); + } + else if (shaders.Count > 0) + { + log.AppendLine("✓ 모든 셰이더가 URP 호환"); + } + + // 4) 컴포넌트 부착 + var avatar = go.GetComponent(); + if (avatar == null) + { + avatar = Undo.AddComponent(go); + log.AppendLine("+ BlendShapeAvatar 추가 (인스펙터에서 '자동 감지'를 눌러 매핑하세요)"); + } + + if (go.GetComponent() == null) + { + Undo.AddComponent(go); + log.AppendLine("+ HeadLookAt 추가"); + } + + // 5) 히트테스트용 콜라이더 — 이게 없으면 클릭 통과가 캐릭터를 인식하지 못한다 + var col = go.GetComponent(); + if (col == null) + { + col = Undo.AddComponent(go); + log.AppendLine("+ CapsuleCollider 추가 (클릭 판정용)"); + } + FitCapsule(go, col); + + EditorUtility.SetDirty(go); + Debug.Log(log.ToString()); + + Selection.activeObject = avatar; + } + + /// 렌더러 전체를 감싸도록 캡슐 콜라이더 크기를 맞춘다. + static void FitCapsule(GameObject go, CapsuleCollider col) + { + var renderers = go.GetComponentsInChildren(true); + if (renderers.Length == 0) return; + + Bounds world = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) world.Encapsulate(renderers[i].bounds); + + Vector3 localCenter = go.transform.InverseTransformPoint(world.center); + Vector3 scale = go.transform.lossyScale; + + // lossyScale 로 나눠 로컬 크기로 환산 (0 나누기 방지) + float sx = Mathf.Approximately(scale.x, 0f) ? 1f : Mathf.Abs(scale.x); + float sy = Mathf.Approximately(scale.y, 0f) ? 1f : Mathf.Abs(scale.y); + float sz = Mathf.Approximately(scale.z, 0f) ? 1f : Mathf.Abs(scale.z); + + col.direction = 1; // Y 축 + col.center = localCenter; + col.height = world.size.y / sy; + col.radius = Mathf.Max(world.size.x / sx, world.size.z / sz) * 0.5f; + col.isTrigger = false; + } +} diff --git a/Assets/02_Scripts/Editor/CharacterSetupMenu.cs.meta b/Assets/02_Scripts/Editor/CharacterSetupMenu.cs.meta new file mode 100644 index 0000000..ec51bf3 --- /dev/null +++ b/Assets/02_Scripts/Editor/CharacterSetupMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1be40d32a0484804391ad2b06df735a6 \ No newline at end of file diff --git a/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef b/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef new file mode 100644 index 0000000..f5994a6 --- /dev/null +++ b/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef @@ -0,0 +1,25 @@ +{ + "name": "MyCharacterAgent.Editor", + "rootNamespace": "", + "references": [ + "MyCharacterAgent", + "UniGLTF", + "UniGLTF.Utils", + "UniHumanoid", + "VRM10", + "Unity.InputSystem", + "Unity.RenderPipelines.Universal.Runtime", + "Unity.RenderPipelines.Core.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef.meta b/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef.meta new file mode 100644 index 0000000..071336c --- /dev/null +++ b/Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 68f8234bbfc2e454ab268c0c72b4590f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs b/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs new file mode 100644 index 0000000..a43a9e9 --- /dev/null +++ b/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; + +/// +/// VRM 런타임 로딩에 필요한 셰이더를 빌드에 강제로 포함시킨다. +/// +/// 왜 필요한가: Unity 는 어떤 머티리얼도 참조하지 않는 셰이더를 빌드에서 제거한다. +/// 에디터에서는 프로젝트의 모든 셰이더를 찾을 수 있어 문제가 드러나지 않지만, +/// 빌드된 플레이어에서는 Shader.Find 가 null 을 돌려주고 +/// "ArgumentNullException: Parameter name: Shader" 로 로드가 실패한다. +/// +/// 일반적인 게임이라면 씬에 쓰인 머티리얼을 보고 Unity 가 알아서 판단하지만, +/// 우리는 실행 중에 임의의 VRM 을 불러오므로 어떤 셰이더가 필요할지 빌드 시점에 +/// 알 수 없다. 그래서 Always Included Shaders 에 명시적으로 등록한다. +/// +public static class VrmShaderBuildSetup +{ + // UrpVrm10MaterialDescriptorGenerator / BuiltInVrm10MaterialDescriptorGenerator 가 + // 참조하는 셰이더들. 현재 URP 를 쓰지만 파이프라인을 바꿔도 되도록 둘 다 넣는다. + static readonly string[] RequiredShaders = + { + "VRM10/Universal Render Pipeline/MToon10", // URP 용 MToon (지금 쓰는 것) + "VRM10/MToon10", // Built-in 용 MToon + "Universal Render Pipeline/Lit", // MToon 이 아닌 PBR 머티리얼 + "Universal Render Pipeline/Unlit", + "UniGLTF/UniUnlit", + }; + + [MenuItem("Tools/Desktop Overlay/6. Include VRM Shaders In Build")] + public static void IncludeShaders() + { + var graphicsSettings = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset"); + if (graphicsSettings == null || graphicsSettings.Length == 0) + { + Debug.LogError("[VrmShaderBuildSetup] GraphicsSettings.asset 을 열지 못했습니다."); + return; + } + + var so = new SerializedObject(graphicsSettings[0]); + var list = so.FindProperty("m_AlwaysIncludedShaders"); + if (list == null) + { + Debug.LogError("[VrmShaderBuildSetup] m_AlwaysIncludedShaders 프로퍼티를 찾지 못했습니다."); + return; + } + + // 이미 들어있는 것 수집 + var existing = new HashSet(); + for (int i = 0; i < list.arraySize; i++) + { + var s = list.GetArrayElementAtIndex(i).objectReferenceValue as Shader; + if (s != null) existing.Add(s); + } + + var added = new List(); + var already = new List(); + var notFound = new List(); + + foreach (var name in RequiredShaders) + { + var shader = Shader.Find(name); + if (shader == null) + { + notFound.Add(name); + continue; + } + + if (existing.Contains(shader)) + { + already.Add(name); + continue; + } + + list.InsertArrayElementAtIndex(list.arraySize); + list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = shader; + existing.Add(shader); + added.Add(name); + } + + so.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + + var sb = new StringBuilder("[VrmShaderBuildSetup] Always Included Shaders 갱신\n"); + if (added.Count > 0) + { + sb.AppendLine($"추가됨 ({added.Count})"); + foreach (var s in added) sb.AppendLine(" + " + s); + } + if (already.Count > 0) + { + sb.AppendLine($"이미 있음 ({already.Count})"); + foreach (var s in already) sb.AppendLine(" = " + s); + } + if (notFound.Count > 0) + { + sb.AppendLine($"찾지 못함 ({notFound.Count}) — 해당 파이프라인을 안 쓰면 정상입니다"); + foreach (var s in notFound) sb.AppendLine(" ? " + s); + } + sb.AppendLine(); + sb.AppendLine("이제 다시 빌드하세요."); + + Debug.Log(sb.ToString()); + } + + [MenuItem("Tools/Desktop Overlay/7. Log Always Included Shaders")] + public static void LogIncluded() + { + var graphicsSettings = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset"); + var so = new SerializedObject(graphicsSettings[0]); + var list = so.FindProperty("m_AlwaysIncludedShaders"); + + var sb = new StringBuilder($"[VrmShaderBuildSetup] Always Included Shaders ({list.arraySize}개)\n"); + for (int i = 0; i < list.arraySize; i++) + { + var s = list.GetArrayElementAtIndex(i).objectReferenceValue as Shader; + sb.AppendLine($" [{i}] {(s != null ? s.name : "")}"); + } + Debug.Log(sb.ToString()); + } +} diff --git a/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs.meta b/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs.meta new file mode 100644 index 0000000..52cfd83 --- /dev/null +++ b/Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b59197c6c6086994b942818da6e28005 \ No newline at end of file diff --git a/Assets/02_Scripts/MyCharacterAgent.asmdef b/Assets/02_Scripts/MyCharacterAgent.asmdef new file mode 100644 index 0000000..dfe9a6b --- /dev/null +++ b/Assets/02_Scripts/MyCharacterAgent.asmdef @@ -0,0 +1,22 @@ +{ + "name": "MyCharacterAgent", + "rootNamespace": "", + "references": [ + "UniGLTF", + "UniGLTF.Utils", + "UniHumanoid", + "VRM10", + "Unity.InputSystem", + "Unity.RenderPipelines.Universal.Runtime", + "Unity.RenderPipelines.Core.Runtime" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/02_Scripts/MyCharacterAgent.asmdef.meta b/Assets/02_Scripts/MyCharacterAgent.asmdef.meta new file mode 100644 index 0000000..ce889e3 --- /dev/null +++ b/Assets/02_Scripts/MyCharacterAgent.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2c6b9b063edcfc447bb977238a16cb11 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/manifest.json b/Packages/manifest.json index 960c184..b89d636 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -44,6 +44,8 @@ "com.unity.modules.video": "1.0.0", "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", - "com.unity.modules.xr": "1.0.0" + "com.unity.modules.xr": "1.0.0", + "com.vrmc.gltf": "https://github.com/vrm-c/UniVRM.git?path=/Packages/UniGLTF#v0.131.2", + "com.vrmc.vrm": "https://github.com/vrm-c/UniVRM.git?path=/Packages/VRM10#v0.131.2" } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 7551e9f..301d793 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -74,7 +74,7 @@ }, "com.unity.mathematics": { "version": "1.3.3", - "depth": 2, + "depth": 1, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" @@ -193,6 +193,29 @@ }, "url": "https://packages.unity.com" }, + "com.vrmc.gltf": { + "version": "https://github.com/vrm-c/UniVRM.git?path=/Packages/UniGLTF#v0.131.2", + "depth": 0, + "source": "git", + "dependencies": { + "com.unity.modules.animation": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.test-framework": "1.4.6", + "com.unity.mathematics": "1.2.6" + }, + "hash": "a4711bbf8c4d10659d3e5568c2e3d7d595005e51" + }, + "com.vrmc.vrm": { + "version": "https://github.com/vrm-c/UniVRM.git?path=/Packages/VRM10#v0.131.2", + "depth": 0, + "source": "git", + "dependencies": { + "com.unity.timeline": "1.7.6", + "com.vrmc.gltf": "0.131.2" + }, + "hash": "a4711bbf8c4d10659d3e5568c2e3d7d595005e51" + }, "com.unity.modules.accessibility": { "version": "1.0.0", "depth": 0, diff --git a/ProjectSettings/GraphicsSettings.asset b/ProjectSettings/GraphicsSettings.asset index e1b67fb..06cfbe2 100644 --- a/ProjectSettings/GraphicsSettings.asset +++ b/ProjectSettings/GraphicsSettings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6736f33dc62c35b0e7a88e275242080bc7a1d94c91154d18fceb332b661a6bce -size 2615 +oid sha256:dbd11bf2e53f0aee865692f357cc53f81dccb9051ff73fc1095233620e6a942b +size 2970