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