using UnityEngine; using UnityEditor; using VRM; using System.Collections.Generic; using System.Linq; using System.Reflection; using System; namespace KindScript { public class VRMFaceAssetMaker : EditorWindow { private BlendShapeAvatar blendShapeAvatar; private List skinnedMeshRenderers = new List(); private Vector2 scrollPosition; private bool createVRMPresets = true; private bool createARKitPresets = true; // 언어 설정 private enum Language { English, Japanese, Korean } private Language currentLanguage = Language.Korean; private static readonly Dictionary> Translations = new Dictionary> { // 타이틀 {"title", new Dictionary { {Language.English, "VRM BlendShape Settings"}, {Language.Japanese, "VRM BlendShape 設定"}, {Language.Korean, "VRM BlendShape 설정"} }}, // BlendShape Avatar 관련 {"avatar_section", new Dictionary { {Language.English, "BlendShape Avatar"}, {Language.Japanese, "BlendShape アバター"}, {Language.Korean, "BlendShape 아바타"} }}, {"create_new", new Dictionary { {Language.English, "Create New"}, {Language.Japanese, "新規作成"}, {Language.Korean, "새로 만들기"} }}, // Renderer 관련 {"renderer_section", new Dictionary { {Language.English, "Skinned Mesh Renderers"}, {Language.Japanese, "スキンメッシュレンダラー"}, {Language.Korean, "스킨드 메시 렌더러"} }}, {"renderer_label", new Dictionary { {Language.English, "Renderer"}, {Language.Japanese, "レンダラー"}, {Language.Korean, "렌더러"} }}, {"add_renderer", new Dictionary { {Language.English, "+ Add Renderer"}, {Language.Japanese, "+ レンダラー追加"}, {Language.Korean, "+ 렌더러 추가"} }}, {"remove", new Dictionary { {Language.English, "Remove"}, {Language.Japanese, "削除"}, {Language.Korean, "제거"} }}, // 프리셋 관련 {"preset_section", new Dictionary { {Language.English, "Select Presets to Create"}, {Language.Japanese, "作成するプリセットを選択"}, {Language.Korean, "생성할 프리셋 선택"} }}, {"vrm_preset", new Dictionary { {Language.English, "VRM Basic Presets"}, {Language.Japanese, "VRM 基本プリセット"}, {Language.Korean, "VRM 기본 프리셋"} }}, {"vrm_preset_desc", new Dictionary { {Language.English, "Basic expressions (A,I,U,E,O)\nEmotions (Joy, Angry, Sorrow)\nEye movements"}, {Language.Japanese, "基本表情 (あ,い,う,え,お)\n感情 (喜び,怒り,悲しみ)\n視線移動"}, {Language.Korean, "기본 표정 (아,이,우,에,오)\n감정 표현 (기쁨, 화남, 슬픔)\n시선 이동"} }}, {"arkit_preset", new Dictionary { {Language.English, "ARKit Presets"}, {Language.Japanese, "ARKit プリセット"}, {Language.Korean, "ARKit 프리셋"} }}, {"arkit_preset_desc", new Dictionary { {Language.English, "52 ARKit BlendShapes\nEyes, Eyebrows, Mouth, Cheeks etc."}, {Language.Japanese, "52個のARKit BlendShape\n目、眉、口、頬など"}, {Language.Korean, "52개의 ARKit 블렌드쉐입\n눈, 눈썹, 입, 볼 등"} }}, {"create_button", new Dictionary { {Language.English, "Create BlendShape Clips"}, {Language.Japanese, "BlendShape クリップを作成"}, {Language.Korean, "BlendShape 클립 생성"} }}, // 에러 메시지 {"error_no_avatar", new Dictionary { {Language.English, "Please select a BlendShape Avatar."}, {Language.Japanese, "BlendShape アバターを選択してください。"}, {Language.Korean, "BlendShape 아바타를 선택해주세요."} }}, {"error_no_renderer", new Dictionary { {Language.English, "Please set all Skinned Mesh Renderers."}, {Language.Japanese, "すべてのスキンメッシュレンダラーを設定してください。"}, {Language.Korean, "모든 스킨드 메시 렌더러를 설정해주세요."} }}, // 로그 메시지 {"log_clip_exists", new Dictionary { {Language.English, "Clip already exists: "}, {Language.Japanese, "クリップが既に存在します:"}, {Language.Korean, "클립이 이미 존재합니다: "} }}, {"log_clip_created", new Dictionary { {Language.English, "Clip created: "}, {Language.Japanese, "クリップを作成しました:"}, {Language.Korean, "클립이 생성되었습니다: "} }}, {"log_blendshape_not_found", new Dictionary { {Language.English, "BlendShape not found in any mesh: "}, {Language.Japanese, "どのメッシュにもBlendShapeが見つかりません:"}, {Language.Korean, "어떤 메시에서도 블렌드쉐입을 찾을 수 없습니다: "} }}, // 확인 대화상자 메시지 {"confirm_create", new Dictionary { {Language.English, "Are you sure you want to create BlendShape clips?"}, {Language.Japanese, "BlendShapeクリップを作成してもよろしいですか?"}, {Language.Korean, "BlendShape 클립을 생성하시겠습니까?"} }}, {"confirm_yes", new Dictionary { {Language.English, "Create"}, {Language.Japanese, "作成"}, {Language.Korean, "생성"} }}, {"confirm_no", new Dictionary { {Language.English, "Cancel"}, {Language.Japanese, "キャンセル"}, {Language.Korean, "취소"} }}, {"creation_complete", new Dictionary { {Language.English, "BlendShape clips have been created successfully!\n\nCreated Clips: {0}"}, {Language.Japanese, "BlendShapeクリップの作成が完了しました!\n\n作成されたクリップ:{0}"}, {Language.Korean, "BlendShape 클립이 성공적으로 생성되었습니다!\n\n생성된 클립: {0}"} }}, {"creation_title", new Dictionary { {Language.English, "Creation Complete"}, {Language.Japanese, "作成完了"}, {Language.Korean, "생성 완료"} }}, {"guide_docs", new Dictionary { {Language.English, "Documentation"}, {Language.Japanese, "ドキュメント"}, {Language.Korean, "문서"} }}, {"guide_video", new Dictionary { {Language.English, "Video Tutorial"}, {Language.Japanese, "ビデオチュートリアル"}, {Language.Korean, "비디오 튜토리얼"} }} }; // 가이드 URL 추가 private static readonly Dictionary GuideUrls = new Dictionary { { Language.English, ( "https://vrm.dev/en/docs/univrm/blendshape/univrm_blendshape/", "https://www.youtube.com/watch?v=example_en") }, { Language.Japanese, ( "https://vrm.dev/docs/univrm/blendshape/univrm_blendshape/", "https://www.youtube.com/watch?v=example_jp") }, { Language.Korean, ( "https://vrm.dev/ko/docs/univrm/blendshape/univrm_blendshape/", "https://www.youtube.com/watch?v=example_kr") } }; private string GetText(string key) { if (Translations.ContainsKey(key)) { return Translations[key][currentLanguage]; } return $"Missing:{key}"; } [MenuItem("Tools/VRM/Create BlendShape Assets")] static void ShowWindow() { var window = GetWindow("VRM BlendShape Maker"); // 최소 창 크기 설정 (너비, 높이) window.minSize = new Vector2(500, 450); } void OnGUI() { // 최소 창 크기 설정 float minWindowWidth = 300; float currentWidth = EditorGUIUtility.currentViewWidth; float usableWidth = Mathf.Max(minWindowWidth, currentWidth); // 스크롤 시작 scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); // 전체 여백 설정 EditorGUILayout.BeginVertical(new GUIStyle { padding = new RectOffset(10, 10, 10, 10) }); // 언어 선택 드롭다운과 가이드 버튼을 같은 행에 배치 EditorGUILayout.BeginHorizontal(); // 언어 선택 부분을 텍스트 길이에 맞게 동적으로 설정 EditorGUILayout.BeginHorizontal(); var languageLabelContent = new GUIContent("Language"); float languageLabelWidth = EditorStyles.label.CalcSize(languageLabelContent).x + 5; EditorGUILayout.LabelField(languageLabelContent, GUILayout.Width(languageLabelWidth)); // 현재 선택된 언어의 텍스트 길이 계산 var enumContent = new GUIContent(currentLanguage.ToString()); float enumWidth = EditorStyles.popup.CalcSize(enumContent).x + 30; // 드롭다운 화살표를 위한 여유 공간 currentLanguage = (Language)EditorGUILayout.EnumPopup(currentLanguage, GUILayout.Width(enumWidth)); EditorGUILayout.EndHorizontal(); GUILayout.FlexibleSpace(); // 가이드 버튼들 var buttonStyle = new GUIStyle(GUI.skin.button); buttonStyle.padding = new RectOffset(4, 4, 4, 4); buttonStyle.fixedHeight = EditorGUIUtility.singleLineHeight + 5; var urls = GuideUrls[currentLanguage]; // 문서 버튼의 내용과 크기 계산 var docsContent = new GUIContent(GetText("guide_docs"), EditorGUIUtility.IconContent("_Help").image); var docsSize = buttonStyle.CalcSize(docsContent); // 비디오 버튼의 내용과 크기 계산 - 아이콘 변경 var videoContent = new GUIContent(GetText("guide_video"), EditorGUIUtility.IconContent("d_PlayButton").image); var videoSize = buttonStyle.CalcSize(videoContent); // 두 버튼 중 더 큰 너비를 사용 float guideButtonWidth = Mathf.Max(docsSize.x, videoSize.x); // 문서 버튼 if (GUILayout.Button(docsContent, buttonStyle, GUILayout.Width(guideButtonWidth))) { Application.OpenURL(urls.docs); } GUILayout.Space(5); // 비디오 버튼 if (GUILayout.Button(videoContent, buttonStyle, GUILayout.Width(guideButtonWidth))) { Application.OpenURL(urls.video); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(15); // 상단 여백 증가 // 타이틀 섹션 GUILayout.Label(GetText("title"), EditorStyles.boldLabel); EditorGUILayout.Space(10); // 타이틀 아래 여백 증가 // BlendShape Avatar 섹션 using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField(GetText("avatar_section"), EditorStyles.boldLabel); EditorGUILayout.Space(5); // 섹션 내부 여백 증가 using (new EditorGUILayout.HorizontalScope()) { // Avatar 라벨을 고정 너비로 설정 EditorGUILayout.LabelField("Avatar", GUILayout.Width(45)); // Avatar 필드가 남은 공간을 차지하되, 버튼을 위한 공간 확보 blendShapeAvatar = EditorGUILayout.ObjectField( GUIContent.none, blendShapeAvatar, typeof(BlendShapeAvatar), true, GUILayout.ExpandWidth(true)) as BlendShapeAvatar; // 버튼과 Avatar 필드 사이의 여백을 최소화 GUILayout.Space(2); // 버튼은 고정된 크기 유지 if (blendShapeAvatar == null) { if (GUILayout.Button(GetText("create_new"), GUILayout.Width(80))) { CreateNewBlendShapeAvatar(); } } } } EditorGUILayout.Space(15); // 섹션 간 여백 증가 // Skinned Mesh Renderers 섹션 using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField(GetText("renderer_section"), EditorStyles.boldLabel); EditorGUILayout.Space(2); for (int i = 0; i < skinnedMeshRenderers.Count; i++) { using (new EditorGUILayout.HorizontalScope()) { // 렌더러 라벨의 내용 미리 계산 var labelContent = new GUIContent($"{GetText("renderer_label")} {i + 1}"); // 라벨의 필요한 너비 계산 (여유 공간 10 추가) float labelWidth = EditorStyles.label.CalcSize(labelContent).x + 10; // 라벨을 계산된 너비로 표시 EditorGUILayout.LabelField(labelContent, GUILayout.Width(labelWidth)); // Object 필드가 남은 공간을 차지하되 최소 너비 보장 skinnedMeshRenderers[i] = EditorGUILayout.ObjectField( GUIContent.none, skinnedMeshRenderers[i], typeof(SkinnedMeshRenderer), true, GUILayout.MinWidth(150), GUILayout.ExpandWidth(true) ) as SkinnedMeshRenderer; // 제거 버튼의 텍스트 너비 계산 var removeContent = new GUIContent(GetText("remove")); float removeWidth = GUI.skin.button.CalcSize(removeContent).x + 10; // 약간의 간격 추가 GUILayout.Space(2); // 제거 버튼을 텍스트 크기에 맞춰 표시 if (GUILayout.Button(removeContent, GUILayout.Width(removeWidth))) { skinnedMeshRenderers.RemoveAt(i); i--; } } } EditorGUILayout.Space(2); // 렌더러 추가 버튼 using (new EditorGUILayout.HorizontalScope()) { float addRendererButtonWidth = Mathf.Clamp(usableWidth - 40, 150, 300); GUILayout.FlexibleSpace(); if (GUILayout.Button(GetText("add_renderer"), GUILayout.Width(addRendererButtonWidth))) { skinnedMeshRenderers.Add(null); } GUILayout.FlexibleSpace(); } } EditorGUILayout.Space(15); // 섹션 간 여백 증가 // 프리셋 섹션은 항상 수직으로 표시 using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField(GetText("preset_section"), EditorStyles.boldLabel); EditorGUILayout.Space(2); // VRM 프리셋 박스 using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { createVRMPresets = EditorGUILayout.ToggleLeft( new GUIContent(GetText("vrm_preset")), createVRMPresets); if (createVRMPresets) { EditorGUI.indentLevel++; var content = new GUIContent(GetText("vrm_preset_desc")); float width = usableWidth - 40; var height = EditorStyles.miniLabel.CalcHeight(content, width); EditorGUILayout.LabelField(content, EditorStyles.miniLabel, GUILayout.Height(height)); EditorGUI.indentLevel--; } } // ARKit 프리셋 박스 using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { createARKitPresets = EditorGUILayout.ToggleLeft( new GUIContent(GetText("arkit_preset")), createARKitPresets); if (createARKitPresets) { EditorGUI.indentLevel++; var content = new GUIContent(GetText("arkit_preset_desc")); float width = usableWidth - 40; var height = EditorStyles.miniLabel.CalcHeight(content, width); EditorGUILayout.LabelField(content, EditorStyles.miniLabel, GUILayout.Height(height)); EditorGUI.indentLevel--; } } } EditorGUILayout.Space(15); // 생성 버튼 using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); using (new EditorGUI.DisabledGroupScope(!IsValid())) { float createButtonWidth = Mathf.Clamp(usableWidth - 40, 150, 300); if (GUILayout.Button(GetText("create_button"), GUILayout.Width(createButtonWidth), GUILayout.Height(30))) { CreateBlendShapeClips(); } } GUILayout.FlexibleSpace(); } EditorGUILayout.EndVertical(); // 스크롤 종료 EditorGUILayout.EndScrollView(); } private bool IsValid() { return blendShapeAvatar != null && skinnedMeshRenderers.Count > 0 && !skinnedMeshRenderers.Any(smr => smr == null) && (createVRMPresets || createARKitPresets); } void CreateBlendShapeClips() { if (blendShapeAvatar == null) { EditorUtility.DisplayDialog("Error", GetText("error_no_avatar"), "OK"); return; } if (skinnedMeshRenderers.Count == 0 || skinnedMeshRenderers.Any(smr => smr == null)) { EditorUtility.DisplayDialog("Error", GetText("error_no_renderer"), "OK"); return; } // 확인 대화상자 표시 bool proceed = EditorUtility.DisplayDialog( GetText("title"), GetText("confirm_create"), GetText("confirm_yes"), GetText("confirm_no") ); if (!proceed) return; // 생성 통계 int createdClips = 0; try { EditorUtility.DisplayProgressBar(GetText("title"), GetText("create_button"), 0f); string avatarPath = AssetDatabase.GetAssetPath(blendShapeAvatar); string saveFolder = System.IO.Path.GetDirectoryName(avatarPath); // VRM 프리셋 생성 if (createVRMPresets) { EditorUtility.DisplayProgressBar(GetText("title"), "VRM Presets", 0.3f); var vrmPresets = new[] { (BlendShapePreset.A, "A"), (BlendShapePreset.I, "I"), (BlendShapePreset.U, "U"), (BlendShapePreset.E, "E"), (BlendShapePreset.O, "O"), (BlendShapePreset.Blink, "Blink"), (BlendShapePreset.Joy, "Joy"), (BlendShapePreset.Angry, "Angry"), (BlendShapePreset.Sorrow, "Sorrow"), (BlendShapePreset.Fun, "Fun"), (BlendShapePreset.LookUp, "LookUp"), (BlendShapePreset.LookDown, "LookDown"), (BlendShapePreset.LookLeft, "LookLeft"), (BlendShapePreset.LookRight, "LookRight"), (BlendShapePreset.Blink_L, "Blink_L"), (BlendShapePreset.Blink_R, "Blink_R") }; foreach (var (preset, name) in vrmPresets) { bool created = CreatePresetClip(preset, name); if (created) createdClips++; } } // ARKit 프리셋 생성 if (createARKitPresets) { EditorUtility.DisplayProgressBar(GetText("title"), "ARKit Presets", 0.6f); var arkitPresets = new[] { "browInnerUp", "browDownLeft", "browDownRight", "browOuterUpLeft", "browOuterUpRight", "eyeLookUpLeft", "eyeLookUpRight", "eyeLookDownLeft", "eyeLookDownRight", "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", "eyeBlinkLeft", "eyeBlinkRight", "eyeSquintLeft", "eyeSquintRight", "eyeWideLeft", "eyeWideRight", "cheekPuff", "cheekSquintLeft", "cheekSquintRight", "noseSneerLeft", "noseSneerRight", "jawOpen", "jawForward", "jawLeft", "jawRight", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", "mouthRollUpper", "mouthRollLower", "mouthShrugUpper", "mouthShrugLower", "mouthClose", "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", "mouthDimpleLeft", "mouthDimpleRight", "mouthUpperUpLeft", "mouthUpperUpRight", "mouthLowerDownLeft", "mouthLowerDownRight", "mouthPressLeft", "mouthPressRight", "mouthStretchLeft", "mouthStretchRight", "tongueOut" }; foreach (var name in arkitPresets) { bool created = CreatePresetClip(BlendShapePreset.Unknown, name); if (created) createdClips++; } } EditorUtility.SetDirty(blendShapeAvatar); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); // 완료 메시지 표시 - 스킵된 클립 정보 제거 EditorUtility.DisplayDialog( GetText("creation_title"), string.Format(GetText("creation_complete"), createdClips), "OK" ); } catch (Exception e) { Debug.LogError($"Error creating BlendShape clips: {e.Message}\n{e.StackTrace}"); EditorUtility.DisplayDialog("Error", e.Message, "OK"); } finally { EditorUtility.ClearProgressBar(); } } private bool CreatePresetClip(BlendShapePreset preset, string blendShapeName) { string avatarPath = AssetDatabase.GetAssetPath(blendShapeAvatar); string saveFolder = System.IO.Path.GetDirectoryName(avatarPath); var path = $"{saveFolder}/{blendShapeName.ToLower()}.asset"; // 이미 존재하는 클립 확인 var existingClip = AssetDatabase.LoadAssetAtPath(path); if (existingClip != null) { return false; } // 새로운 BlendShapeClip 생성 var clip = ScriptableObject.CreateInstance(); clip.BlendShapeName = blendShapeName; clip.Preset = preset; var bindings = new List(); foreach (var skinnedMeshRenderer in skinnedMeshRenderers) { // SkinnedMeshRenderer에서 블렌드쉐입 인덱스 찾기 int blendShapeIndex = -1; Mesh mesh = skinnedMeshRenderer.sharedMesh; // 블렌드쉐입 이름 비교 시 대소문자 구분 없이 포함 여부 확인 for (int i = 0; i < mesh.blendShapeCount; i++) { string shapeName = mesh.GetBlendShapeName(i); if (shapeName.ToLower().Contains(blendShapeName.ToLower())) { blendShapeIndex = i; break; } } if (blendShapeIndex != -1) { Transform avatarRoot = skinnedMeshRenderer.transform.root; string relativePath = ""; Transform current = skinnedMeshRenderer.transform; while (current != avatarRoot && current != null) { if (string.IsNullOrEmpty(relativePath)) relativePath = current.name; else relativePath = current.name + "/" + relativePath; current = current.parent; } float weight = preset != BlendShapePreset.Unknown ? 0f : 100f; bindings.Add(new BlendShapeBinding { RelativePath = relativePath, Index = blendShapeIndex, Weight = weight }); } } if (bindings.Count > 0) { clip.Values = bindings.ToArray(); AssetDatabase.CreateAsset(clip, path); var clips = new List(); if (blendShapeAvatar.Clips != null) { clips.AddRange(blendShapeAvatar.Clips); } clips.Add(clip); blendShapeAvatar.Clips = clips; return true; } return false; } private void CreateNewBlendShapeAvatar() { // 새로운 BlendShapeAvatar 생성 var avatar = ScriptableObject.CreateInstance(); // ProjectWindowUtil을 사용하여 현재 프로젝트 창 위치에 에셋 생성 ProjectWindowUtil.CreateAsset(avatar, "BlendShapeAvatar.asset"); // 생성된 에셋을 현재 선택된 BlendShapeAvatar로 설정 blendShapeAvatar = avatar; } } }