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