캐릭터 로드
This commit is contained in:
314
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs
Normal file
314
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user