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")}"; } }