Files
MyCharacterAgent/Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs
2026-08-26 03:39:36 +09:00

315 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
/// <summary>
/// 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
/// - 일본어: あ い う え お, まばたき, 笑い
/// 대부분 모델이 이 중 하나를 따르므로 클릭 한 번으로 상당수가 채워진다.
/// 안 맞는 것만 손으로 지정하면 된다.
/// </summary>
[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<ShapeRef> CollectShapes(BlendShapeAvatar avatar)
{
var list = new List<ShapeRef>();
var renderers = avatar.GetComponentsInChildren<SkinnedMeshRenderer>(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;
}
/// <summary>비교를 위해 소문자화하고 구분자를 제거한다. "vrc.v_aa" -> "vrcvaa"</summary>
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<ShapeRef> 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<string>();
var missing = new List<string>();
// 감정
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<ShapeRef>();
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<ShapeRef> { shape });
}
static void SetBindings(SerializedProperty group, List<ShapeRef> 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<SkinnedMeshRenderer>(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());
}
}