캐릭터 로드

This commit is contained in:
2026-08-26 03:39:36 +09:00
parent 8d1881749c
commit 7204afae54
31 changed files with 1750 additions and 41 deletions

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6caf6011db2e0c2418d8b40bb8b2f8c1

View File

@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEngine;
/// <summary>
/// 씬에 배치한 FBX 캐릭터에 필요한 컴포넌트를 한 번에 붙이고, 흔한 함정을 진단한다.
/// 부스에서 받은 VRChat 아바타는 특히 리그 타입과 셰이더에서 걸리는 경우가 많다.
/// </summary>
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<Animator>();
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<SkinnedMeshRenderer>(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<string>();
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
{
foreach (var m in r.sharedMaterials)
{
if (m != null && m.shader != null) shaders.Add(m.shader.name);
}
}
var nonUrp = new List<string>();
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<BlendShapeAvatar>();
if (avatar == null)
{
avatar = Undo.AddComponent<BlendShapeAvatar>(go);
log.AppendLine("+ BlendShapeAvatar 추가 (인스펙터에서 '자동 감지'를 눌러 매핑하세요)");
}
if (go.GetComponent<HeadLookAt>() == null)
{
Undo.AddComponent<HeadLookAt>(go);
log.AppendLine("+ HeadLookAt 추가");
}
// 5) 히트테스트용 콜라이더 — 이게 없으면 클릭 통과가 캐릭터를 인식하지 못한다
var col = go.GetComponent<CapsuleCollider>();
if (col == null)
{
col = Undo.AddComponent<CapsuleCollider>(go);
log.AppendLine("+ CapsuleCollider 추가 (클릭 판정용)");
}
FitCapsule(go, col);
EditorUtility.SetDirty(go);
Debug.Log(log.ToString());
Selection.activeObject = avatar;
}
/// <summary>렌더러 전체를 감싸도록 캡슐 콜라이더 크기를 맞춘다.</summary>
static void FitCapsule(GameObject go, CapsuleCollider col)
{
var renderers = go.GetComponentsInChildren<Renderer>(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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1be40d32a0484804391ad2b06df735a6

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 68f8234bbfc2e454ab268c0c72b4590f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,122 @@
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEngine;
/// <summary>
/// VRM 런타임 로딩에 필요한 셰이더를 빌드에 강제로 포함시킨다.
///
/// 왜 필요한가: Unity 는 어떤 머티리얼도 참조하지 않는 셰이더를 빌드에서 제거한다.
/// 에디터에서는 프로젝트의 모든 셰이더를 찾을 수 있어 문제가 드러나지 않지만,
/// 빌드된 플레이어에서는 Shader.Find 가 null 을 돌려주고
/// "ArgumentNullException: Parameter name: Shader" 로 로드가 실패한다.
///
/// 일반적인 게임이라면 씬에 쓰인 머티리얼을 보고 Unity 가 알아서 판단하지만,
/// 우리는 실행 중에 임의의 VRM 을 불러오므로 어떤 셰이더가 필요할지 빌드 시점에
/// 알 수 없다. 그래서 Always Included Shaders 에 명시적으로 등록한다.
/// </summary>
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<Shader>();
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<string>();
var already = new List<string>();
var notFound = new List<string>();
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 : "<null>")}");
}
Debug.Log(sb.ToString());
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b59197c6c6086994b942818da6e28005