using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace Streamingle.Prop.Editor { /// /// 개별 프리펩 표시용 데이터 /// public class PrefabDisplayItem { public string prefabName; // 프리펩 이름 public string prefabPath; // 프리펩 경로 public string folderName; // 폴더 이름 public PropInfo parentProp; // 부모 PropInfo 참조 public string thumbnailPath; // 썸네일 경로 public string DisplayName => prefabName; } /// /// 프랍 브라우저 에디터 윈도우 /// public class PropBrowserWindow : EditorWindow { private const string PROP_PATH = "Assets/ResourcesData/Prop"; private const string DATABASE_PATH = "Assets/Resources/Settings/PropDatabase.asset"; private const string THUMBNAIL_FOLDER = "Thumbnail"; private const int THUMBNAIL_SIZE = 128; private const int THUMBNAIL_CAPTURE_SIZE = 512; private const int GRID_PADDING = 10; private PropDatabase _database; private List _filteredProps; private List _displayItems; // 개별 프리펩 표시용 private Dictionary _thumbnailCache = new Dictionary(); private Vector2 _scrollPosition; private string _searchFilter = ""; private int _viewMode = 0; // 0: 그리드, 1: 리스트 private static readonly string[] VIEW_MODE_OPTIONS = { "그리드", "리스트" }; private static readonly Color SELECTED_COLOR = new Color(0.3f, 0.6f, 1f, 0.3f); private static readonly Color HOVER_COLOR = new Color(1f, 1f, 1f, 0.1f); [MenuItem("Streamingle/Prop Browser %#p")] public static void ShowWindow() { var window = GetWindow("프랍 브라우저"); window.minSize = new Vector2(400, 500); window.Show(); } private void OnEnable() { LoadOrCreateDatabase(); } private void OnDisable() { ClearThumbnailCache(); } private void OnGUI() { DrawToolbar(); DrawContent(); } private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); // 새로고침 버튼 if (GUILayout.Button("새로고침", EditorStyles.toolbarButton, GUILayout.Width(60))) { RefreshPropList(); } GUILayout.Space(10); // 검색 EditorGUILayout.LabelField("검색:", GUILayout.Width(35)); string newSearch = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField, GUILayout.Width(150)); if (newSearch != _searchFilter) { _searchFilter = newSearch; ApplyFilter(); } if (GUILayout.Button("X", EditorStyles.toolbarButton, GUILayout.Width(20))) { _searchFilter = ""; ApplyFilter(); GUI.FocusControl(null); } GUILayout.FlexibleSpace(); // 뷰 모드 _viewMode = GUILayout.Toolbar(_viewMode, VIEW_MODE_OPTIONS, EditorStyles.toolbarButton, GUILayout.Width(100)); EditorGUILayout.EndHorizontal(); // 정보 바 EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); int totalPrefabCount = _database?.props.Sum(p => Math.Max(1, p.prefabPaths.Count)) ?? 0; int filteredCount = _displayItems?.Count ?? 0; string countText = string.IsNullOrEmpty(_searchFilter) ? $"총 {totalPrefabCount}개 프리펩" : $"{filteredCount}/{totalPrefabCount}개 프리펩"; EditorGUILayout.LabelField(countText); GUILayout.FlexibleSpace(); // 썸네일 생성 if (GUILayout.Button("썸네일 생성", GUILayout.Width(80))) { ShowThumbnailMenu(); } // 웹 업로드 if (GUILayout.Button("웹 업로드", GUILayout.Width(80))) { ShowWebUploadMenu(); } // 폴더 열기 if (GUILayout.Button("폴더", GUILayout.Width(40))) { EditorUtility.RevealInFinder(PROP_PATH); } EditorGUILayout.EndHorizontal(); } private void ShowThumbnailMenu() { var menu = new GenericMenu(); menu.AddItem(new GUIContent("모든 프리펩 썸네일 생성 (개별)"), false, () => GenerateAllPrefabThumbnails()); menu.AddItem(new GUIContent("썸네일 없는 프리펩만 생성"), false, () => GenerateMissingPrefabThumbnails()); menu.AddSeparator(""); menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/모든 폴더"), false, () => GenerateAllThumbnails()); menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/선택한 폴더"), false, () => GenerateThumbnailsForSelected()); menu.ShowAsContext(); } private void DrawContent() { if (_database == null || _displayItems == null) { EditorGUILayout.HelpBox("데이터베이스를 로드할 수 없습니다. 새로고침을 눌러주세요.", MessageType.Warning); return; } if (_displayItems.Count == 0) { EditorGUILayout.HelpBox("프리펩이 없습니다.", MessageType.Info); return; } _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); if (_viewMode == 0) { DrawGridView(); } else { DrawListView(); } EditorGUILayout.EndScrollView(); } private void DrawGridView() { float windowWidth = position.width - 30; float itemWidth = THUMBNAIL_SIZE + GRID_PADDING; float itemHeight = THUMBNAIL_SIZE + 40; // 폴더명 표시를 위해 높이 증가 int columnsCount = Mathf.Max(1, (int)(windowWidth / itemWidth)); int rowCount = Mathf.CeilToInt((float)_displayItems.Count / columnsCount); float gridHeight = rowCount * itemHeight; Rect gridRect = GUILayoutUtility.GetRect(windowWidth, gridHeight); gridRect.x += GRID_PADDING; for (int i = 0; i < _displayItems.Count; i++) { int row = i / columnsCount; int col = i % columnsCount; Rect itemRect = new Rect( gridRect.x + col * itemWidth, gridRect.y + row * itemHeight, THUMBNAIL_SIZE, itemHeight ); DrawGridItem(_displayItems[i], itemRect); } } private void DrawGridItem(PrefabDisplayItem item, Rect rect) { // 호버 효과 if (rect.Contains(Event.current.mousePosition)) { EditorGUI.DrawRect(rect, HOVER_COLOR); Repaint(); } // 썸네일 var thumbnailRect = new Rect(rect.x + 2, rect.y + 2, THUMBNAIL_SIZE - 4, THUMBNAIL_SIZE - 4); var thumbnail = GetThumbnailForItem(item); if (thumbnail != null) { GUI.DrawTexture(thumbnailRect, thumbnail, ScaleMode.ScaleToFit); } else { EditorGUI.DrawRect(thumbnailRect, new Color(0.2f, 0.2f, 0.2f)); // 프리펩 미리보기 if (!string.IsNullOrEmpty(item.prefabPath)) { var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab != null) { var preview = AssetPreview.GetAssetPreview(prefab); if (preview != null) { GUI.DrawTexture(thumbnailRect, preview, ScaleMode.ScaleToFit); } else { AssetPreview.SetPreviewTextureCacheSize(256); GUI.Label(thumbnailRect, "Loading...", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { alignment = TextAnchor.MiddleCenter }); } } } else { GUI.Label(thumbnailRect, "No\nPrefab", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { wordWrap = true, alignment = TextAnchor.MiddleCenter }); } } // 폴더 이름 배지 (프리펩 이름과 다를 경우) if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName) { var folderBadgeRect = new Rect(rect.x + 4, rect.y + THUMBNAIL_SIZE - 18, THUMBNAIL_SIZE - 8, 14); EditorGUI.DrawRect(folderBadgeRect, new Color(0, 0, 0, 0.7f)); GUI.Label(folderBadgeRect, item.folderName, new GUIStyle(EditorStyles.miniLabel) { alignment = TextAnchor.MiddleCenter, normal = { textColor = new Color(1, 1, 1, 0.9f) }, fontSize = 9, clipping = TextClipping.Clip }); } // 프리펩 이름 var labelRect = new Rect(rect.x, rect.y + THUMBNAIL_SIZE, THUMBNAIL_SIZE, 35); GUI.Label(labelRect, item.prefabName, new GUIStyle(EditorStyles.miniLabel) { alignment = TextAnchor.UpperCenter, wordWrap = true, clipping = TextClipping.Clip }); // 클릭 이벤트 if (Event.current.type == EventType.MouseDown && rect.Contains(Event.current.mousePosition)) { if (Event.current.clickCount == 2) { // 더블클릭: 프리펩 선택 SelectPrefabInProject(item); } else if (Event.current.button == 0) { // 싱글클릭: 드래그 시작 HandleDragStartForItem(item); } else if (Event.current.button == 1) { // 우클릭: 컨텍스트 메뉴 ShowContextMenuForItem(item); } Event.current.Use(); } } private void DrawListView() { foreach (var item in _displayItems) { DrawListItem(item); } } private void DrawListItem(PrefabDisplayItem item) { EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); // 썸네일 (작게) var thumbnail = GetThumbnailForItem(item); var thumbRect = GUILayoutUtility.GetRect(50, 50, GUILayout.Width(50), GUILayout.Height(50)); if (thumbnail != null) { GUI.DrawTexture(thumbRect, thumbnail, ScaleMode.ScaleToFit); } else { EditorGUI.DrawRect(thumbRect, new Color(0.2f, 0.2f, 0.2f)); // 프리펩 미리보기 if (!string.IsNullOrEmpty(item.prefabPath)) { var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab != null) { var preview = AssetPreview.GetAssetPreview(prefab); if (preview != null) { GUI.DrawTexture(thumbRect, preview, ScaleMode.ScaleToFit); } } } } GUILayout.Space(10); // 프리펩 정보 EditorGUILayout.BeginVertical(); EditorGUILayout.LabelField(item.prefabName, EditorStyles.boldLabel); // 폴더 이름 (다르면 표시) if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName) { EditorGUILayout.LabelField($"폴더: {item.folderName}", EditorStyles.miniLabel); } EditorGUILayout.EndVertical(); GUILayout.FlexibleSpace(); // 버튼들 EditorGUILayout.BeginVertical(); if (GUILayout.Button("선택", GUILayout.Width(50))) { SelectPrefabInProject(item); } if (GUILayout.Button("...", GUILayout.Width(50))) { ShowContextMenuForItem(item); } EditorGUILayout.EndVertical(); EditorGUILayout.EndHorizontal(); // 드래그 처리 var lastRect = GUILayoutUtility.GetLastRect(); if (Event.current.type == EventType.MouseDown && lastRect.Contains(Event.current.mousePosition)) { if (Event.current.button == 0) { HandleDragStartForItem(item); } } } private void HandleDragStart(PropInfo propInfo) { if (propInfo.prefabPaths.Count == 0) return; var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); if (prefab == null) return; DragAndDrop.PrepareStartDrag(); DragAndDrop.objectReferences = new UnityEngine.Object[] { prefab }; DragAndDrop.paths = new string[] { propInfo.MainPrefabPath }; DragAndDrop.StartDrag(propInfo.propName); } private void HandleDragStartForItem(PrefabDisplayItem item) { if (string.IsNullOrEmpty(item.prefabPath)) return; var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab == null) return; DragAndDrop.PrepareStartDrag(); DragAndDrop.objectReferences = new UnityEngine.Object[] { prefab }; DragAndDrop.paths = new string[] { item.prefabPath }; DragAndDrop.StartDrag(item.prefabName); } private void SelectPrefabInProject(PrefabDisplayItem item) { if (!string.IsNullOrEmpty(item.prefabPath)) { var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab != null) { Selection.activeObject = prefab; EditorGUIUtility.PingObject(prefab); return; } } // 프리펩이 없으면 폴더 선택 if (item.parentProp != null) { var folder = AssetDatabase.LoadAssetAtPath(item.parentProp.folderPath); if (folder != null) { Selection.activeObject = folder; EditorGUIUtility.PingObject(folder); } } } private void ShowContextMenuForItem(PrefabDisplayItem item) { var menu = new GenericMenu(); menu.AddItem(new GUIContent("프로젝트에서 선택"), false, () => SelectPrefabInProject(item)); if (item.parentProp != null) { menu.AddItem(new GUIContent("폴더 열기"), false, () => RevealInFinder(item.parentProp)); } menu.AddSeparator(""); menu.AddItem(new GUIContent("씬에 배치"), false, () => InstantiatePrefabFromItem(item)); menu.AddSeparator(""); menu.AddItem(new GUIContent("이 프리펩 썸네일 생성"), false, () => GenerateThumbnailForItem(item)); menu.ShowAsContext(); } private void InstantiatePrefabFromItem(PrefabDisplayItem item) { if (string.IsNullOrEmpty(item.prefabPath)) { EditorUtility.DisplayDialog("오류", "프리펩이 없습니다.", "확인"); return; } var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab == null) { EditorUtility.DisplayDialog("오류", "프리펩을 로드할 수 없습니다.", "확인"); return; } var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; if (instance != null) { if (SceneView.lastActiveSceneView != null) { instance.transform.position = SceneView.lastActiveSceneView.pivot; } Selection.activeGameObject = instance; Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prop"); } } private Texture2D GetThumbnailForItem(PrefabDisplayItem item) { if (string.IsNullOrEmpty(item.thumbnailPath)) return null; if (_thumbnailCache.TryGetValue(item.thumbnailPath, out var cached)) return cached; var texture = AssetDatabase.LoadAssetAtPath(item.thumbnailPath); if (texture != null) { _thumbnailCache[item.thumbnailPath] = texture; } return texture; } private void ShowContextMenu(PropInfo propInfo) { var menu = new GenericMenu(); menu.AddItem(new GUIContent("프로젝트에서 선택"), false, () => SelectPropInProject(propInfo)); menu.AddItem(new GUIContent("폴더 열기"), false, () => RevealInFinder(propInfo)); menu.AddSeparator(""); // 프리펩 목록 if (propInfo.prefabPaths.Count > 0) { foreach (var prefabPath in propInfo.prefabPaths) { string prefabName = Path.GetFileNameWithoutExtension(prefabPath); menu.AddItem(new GUIContent($"프리펩/{prefabName}"), false, () => { var prefab = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefab != null) { Selection.activeObject = prefab; EditorGUIUtility.PingObject(prefab); } }); } } else { menu.AddDisabledItem(new GUIContent("프리펩 없음")); } menu.AddSeparator(""); // 모델 목록 if (propInfo.modelPaths.Count > 0) { foreach (var modelPath in propInfo.modelPaths) { string modelName = Path.GetFileName(modelPath); menu.AddItem(new GUIContent($"모델/{modelName}"), false, () => { var model = AssetDatabase.LoadAssetAtPath(modelPath); if (model != null) { Selection.activeObject = model; EditorGUIUtility.PingObject(model); } }); } } menu.AddSeparator(""); menu.AddItem(new GUIContent("씬에 배치"), false, () => InstantiatePrefab(propInfo)); menu.AddSeparator(""); menu.AddItem(new GUIContent("썸네일 생성"), false, () => GenerateThumbnail(propInfo)); menu.ShowAsContext(); } private void SelectPropInProject(PropInfo propInfo) { if (propInfo.prefabPaths.Count > 0) { var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); if (prefab != null) { Selection.activeObject = prefab; EditorGUIUtility.PingObject(prefab); return; } } // 프리펩이 없으면 폴더 선택 var folder = AssetDatabase.LoadAssetAtPath(propInfo.folderPath); if (folder != null) { Selection.activeObject = folder; EditorGUIUtility.PingObject(folder); } } private void RevealInFinder(PropInfo propInfo) { EditorUtility.RevealInFinder(propInfo.folderPath); } private void InstantiatePrefab(PropInfo propInfo) { if (propInfo.prefabPaths.Count == 0) { EditorUtility.DisplayDialog("오류", "프리펩이 없습니다.", "확인"); return; } var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); if (prefab == null) { EditorUtility.DisplayDialog("오류", "프리펩을 로드할 수 없습니다.", "확인"); return; } var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; if (instance != null) { // 씬 뷰 중앙에 배치 if (SceneView.lastActiveSceneView != null) { instance.transform.position = SceneView.lastActiveSceneView.pivot; } Selection.activeGameObject = instance; Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prop"); } } private Texture2D GetThumbnail(PropInfo propInfo) { if (string.IsNullOrEmpty(propInfo.thumbnailPath)) return null; if (_thumbnailCache.TryGetValue(propInfo.thumbnailPath, out var cached)) return cached; var texture = AssetDatabase.LoadAssetAtPath(propInfo.thumbnailPath); if (texture != null) { _thumbnailCache[propInfo.thumbnailPath] = texture; } return texture; } private void ClearThumbnailCache() { _thumbnailCache.Clear(); } private void LoadOrCreateDatabase() { _database = AssetDatabase.LoadAssetAtPath(DATABASE_PATH); if (_database == null) { // 폴더 생성 if (!AssetDatabase.IsValidFolder("Assets/Resources")) AssetDatabase.CreateFolder("Assets", "Resources"); if (!AssetDatabase.IsValidFolder("Assets/Resources/Settings")) AssetDatabase.CreateFolder("Assets/Resources", "Settings"); _database = CreateInstance(); AssetDatabase.CreateAsset(_database, DATABASE_PATH); AssetDatabase.SaveAssets(); } RefreshPropList(); } private void RefreshPropList() { if (_database == null) return; _database.props.Clear(); if (!Directory.Exists(PROP_PATH)) { UnityEngine.Debug.LogWarning($"Prop 폴더가 없습니다: {PROP_PATH}"); ApplyFilter(); return; } // Prop 하위 폴더들 검색 (Glb, Prop Prefab 제외) var propFolders = Directory.GetDirectories(PROP_PATH) .Where(f => !f.EndsWith("Glb") && !f.EndsWith("Prop Prefab")) .ToList(); foreach (var folderPath in propFolders) { string propName = Path.GetFileName(folderPath); string assetPath = folderPath.Replace("\\", "/"); // Assets/ 형식으로 변환 int assetsIndex = assetPath.IndexOf("Assets/"); if (assetsIndex >= 0) { assetPath = assetPath.Substring(assetsIndex); } var propInfo = new PropInfo { propName = propName, folderPath = assetPath }; // 파일 스캔 ScanPropFiles(propInfo, folderPath); _database.props.Add(propInfo); } // 이름순 정렬 _database.props = _database.props.OrderBy(p => p.propName).ToList(); EditorUtility.SetDirty(_database); AssetDatabase.SaveAssets(); ApplyFilter(); UnityEngine.Debug.Log($"[PropBrowser] {_database.props.Count}개 프랍 스캔 완료"); } private void ScanPropFiles(PropInfo propInfo, string folderPath) { var allFiles = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories) .Where(f => !f.EndsWith(".meta")) .ToList(); foreach (var file in allFiles) { string ext = Path.GetExtension(file).ToLower(); string assetPath = file.Replace("\\", "/"); int assetsIndex = assetPath.IndexOf("Assets/"); if (assetsIndex >= 0) { assetPath = assetPath.Substring(assetsIndex); } if (ext == ".prefab") { // Prefab 폴더 안에 있는 프리팹만 수집 (Particle 등 다른 폴더 제외) string parentFolder = Path.GetFileName(Path.GetDirectoryName(file)); if (parentFolder.Equals("Prefab", StringComparison.OrdinalIgnoreCase)) { propInfo.prefabPaths.Add(assetPath); } } else if (ext == ".glb" || ext == ".fbx" || ext == ".obj") { propInfo.modelPaths.Add(assetPath); } else if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".tga" || ext == ".psd") { propInfo.textureCount++; // 썸네일 찾기 (thumbnail 또는 _thumb 포함) string fileName = Path.GetFileNameWithoutExtension(file).ToLower(); if (fileName.Contains("thumbnail") || fileName.Contains("_thumb")) { propInfo.thumbnailPath = assetPath; } } else if (ext == ".mat") { propInfo.materialCount++; } } // 프리펩 이름순 정렬 propInfo.prefabPaths = propInfo.prefabPaths.OrderBy(p => Path.GetFileName(p)).ToList(); } private void ApplyFilter() { if (_database == null) { _filteredProps = new List(); _displayItems = new List(); return; } // 기존 필터링 if (string.IsNullOrEmpty(_searchFilter)) { _filteredProps = new List(_database.props); } else { string filter = _searchFilter.ToLower(); _filteredProps = _database.props .Where(p => p.propName.ToLower().Contains(filter) || p.prefabPaths.Any(pp => Path.GetFileNameWithoutExtension(pp).ToLower().Contains(filter))) .ToList(); } // 개별 프리펩 단위로 표시 아이템 생성 _displayItems = new List(); foreach (var prop in _filteredProps) { if (prop.prefabPaths.Count > 0) { // 각 프리펩을 개별 아이템으로 추가 foreach (var prefabPath in prop.prefabPaths) { string prefabName = Path.GetFileNameWithoutExtension(prefabPath); // 검색 필터 추가 적용 if (!string.IsNullOrEmpty(_searchFilter)) { string filter = _searchFilter.ToLower(); if (!prefabName.ToLower().Contains(filter) && !prop.propName.ToLower().Contains(filter)) continue; } // 개별 프리펩 썸네일 경로 찾기 string prefabThumbnailPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png"; string prefabThumbnailFullPath = prefabThumbnailPath.Replace("Assets/", Application.dataPath + "/"); // 개별 썸네일이 없으면 폴더 썸네일 사용 string thumbnailToUse = File.Exists(prefabThumbnailFullPath) ? prefabThumbnailPath : prop.thumbnailPath; _displayItems.Add(new PrefabDisplayItem { prefabName = prefabName, prefabPath = prefabPath, folderName = prop.propName, parentProp = prop, thumbnailPath = thumbnailToUse }); } } else { // 프리펩이 없는 폴더도 표시 _displayItems.Add(new PrefabDisplayItem { prefabName = prop.propName, prefabPath = null, folderName = prop.propName, parentProp = prop, thumbnailPath = prop.thumbnailPath }); } } // 이름순 정렬 _displayItems = _displayItems.OrderBy(d => d.prefabName).ToList(); } #region Thumbnail Generation /// /// 개별 프리펩에 대한 썸네일 생성 /// private bool GenerateThumbnailForItem(PrefabDisplayItem item) { if (string.IsNullOrEmpty(item.prefabPath) || item.parentProp == null) { UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩 경로가 없습니다."); return false; } var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); if (prefab == null) { UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩을 로드할 수 없습니다."); return false; } // 썸네일 폴더 경로 string thumbnailFolderPath = $"{item.parentProp.folderPath}/{THUMBNAIL_FOLDER}"; string thumbnailFileName = $"{item.prefabName}_thumbnail.png"; string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}"; // 폴더 생성 if (!AssetDatabase.IsValidFolder(thumbnailFolderPath)) { AssetDatabase.CreateFolder(item.parentProp.folderPath, THUMBNAIL_FOLDER); } var previewTexture = GeneratePreviewTexture(prefab); if (previewTexture == null) { UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 미리보기를 생성할 수 없습니다."); return false; } try { byte[] pngData = previewTexture.EncodeToPNG(); string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/"); string directory = Path.GetDirectoryName(fullPath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } File.WriteAllBytes(fullPath, pngData); DestroyImmediate(previewTexture); AssetDatabase.ImportAsset(thumbnailPath); // item의 썸네일 경로 업데이트 item.thumbnailPath = thumbnailPath; UnityEngine.Debug.Log($"[PropBrowser] {item.prefabName}: 썸네일 생성 완료"); return true; } catch (Exception ex) { UnityEngine.Debug.LogError($"[PropBrowser] {item.prefabName}: 썸네일 저장 실패 - {ex.Message}"); return false; } } /// /// 모든 프리펩에 대해 개별 썸네일 생성 /// private void GenerateAllPrefabThumbnails() { // 모든 프리펩 아이템 수집 (필터 없이) var allItems = new List(); foreach (var prop in _database.props) { foreach (var prefabPath in prop.prefabPaths) { allItems.Add(new PrefabDisplayItem { prefabName = Path.GetFileNameWithoutExtension(prefabPath), prefabPath = prefabPath, folderName = prop.propName, parentProp = prop }); } } if (allItems.Count == 0) { EditorUtility.DisplayDialog("알림", "생성할 프리펩이 없습니다.", "확인"); return; } if (!EditorUtility.DisplayDialog("확인", $"모든 프리펩 ({allItems.Count}개)의 썸네일을 개별 생성합니다.\n계속하시겠습니까?", "생성", "취소")) { return; } GeneratePrefabThumbnails(allItems, "모든 프리펩"); } /// /// 썸네일이 없는 프리펩만 개별 썸네일 생성 /// private void GenerateMissingPrefabThumbnails() { var itemsWithoutThumbnail = new List(); foreach (var prop in _database.props) { foreach (var prefabPath in prop.prefabPaths) { string prefabName = Path.GetFileNameWithoutExtension(prefabPath); string expectedThumbnailPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png"; string fullPath = expectedThumbnailPath.Replace("Assets/", Application.dataPath + "/"); if (!File.Exists(fullPath)) { itemsWithoutThumbnail.Add(new PrefabDisplayItem { prefabName = prefabName, prefabPath = prefabPath, folderName = prop.propName, parentProp = prop }); } } } if (itemsWithoutThumbnail.Count == 0) { EditorUtility.DisplayDialog("알림", "모든 프리펩에 썸네일이 있습니다.", "확인"); return; } GeneratePrefabThumbnails(itemsWithoutThumbnail, $"썸네일 없는 {itemsWithoutThumbnail.Count}개 프리펩"); } /// /// PrefabDisplayItem 리스트에 대해 썸네일 일괄 생성 /// private void GeneratePrefabThumbnails(List items, string description) { int successCount = 0; int failCount = 0; try { for (int i = 0; i < items.Count; i++) { var item = items[i]; EditorUtility.DisplayProgressBar("썸네일 생성 중", $"{item.prefabName} ({i + 1}/{items.Count})", (float)i / items.Count); if (GenerateThumbnailForItem(item)) { successCount++; } else { failCount++; } } } finally { EditorUtility.ClearProgressBar(); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); ClearThumbnailCache(); ApplyFilter(); // displayItems 다시 생성하여 썸네일 경로 반영 Repaint(); EditorUtility.DisplayDialog("완료", $"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", "확인"); } private void GenerateThumbnailsForSelected() { // 현재 선택된 프랍 (Selection에서) var selectedObjects = Selection.objects; var propsToGenerate = new List(); foreach (var obj in selectedObjects) { string path = AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(path)) continue; var prop = _database.props.Find(p => path.StartsWith(p.folderPath)); if (prop != null && !propsToGenerate.Contains(prop)) { propsToGenerate.Add(prop); } } if (propsToGenerate.Count == 0) { EditorUtility.DisplayDialog("알림", "선택된 프랍이 없습니다.\n프로젝트 창에서 프랍 폴더나 프리펩을 선택해주세요.", "확인"); return; } GenerateThumbnails(propsToGenerate, $"선택한 {propsToGenerate.Count}개 프랍"); } private void GenerateAllThumbnails() { if (!EditorUtility.DisplayDialog("확인", $"모든 프랍 ({_database.props.Count}개)의 썸네일을 생성합니다.\n계속하시겠습니까?", "생성", "취소")) { return; } GenerateThumbnails(_database.props, "모든 프랍"); } private void GenerateMissingThumbnails() { var propsWithoutThumbnail = _database.props .Where(p => string.IsNullOrEmpty(p.thumbnailPath) || !File.Exists(p.thumbnailPath.Replace("Assets/", Application.dataPath + "/"))) .ToList(); if (propsWithoutThumbnail.Count == 0) { EditorUtility.DisplayDialog("알림", "모든 프랍에 썸네일이 있습니다.", "확인"); return; } GenerateThumbnails(propsWithoutThumbnail, $"썸네일 없는 {propsWithoutThumbnail.Count}개 프랍"); } private void GenerateThumbnails(List props, string description) { int successCount = 0; int failCount = 0; try { for (int i = 0; i < props.Count; i++) { var prop = props[i]; EditorUtility.DisplayProgressBar("썸네일 생성 중", $"{prop.propName} ({i + 1}/{props.Count})", (float)i / props.Count); if (GenerateThumbnail(prop)) { successCount++; } else { failCount++; } } } finally { EditorUtility.ClearProgressBar(); } EditorUtility.SetDirty(_database); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); ClearThumbnailCache(); Repaint(); EditorUtility.DisplayDialog("완료", $"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", "확인"); } private bool GenerateThumbnail(PropInfo propInfo) { if (propInfo.prefabPaths.Count == 0) { UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩이 없어 썸네일을 생성할 수 없습니다."); return false; } // 프리펩 로드 var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); if (prefab == null) { UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩을 로드할 수 없습니다."); return false; } // 썸네일 폴더 경로 string thumbnailFolderPath = $"{propInfo.folderPath}/{THUMBNAIL_FOLDER}"; string thumbnailFileName = $"{propInfo.propName}_thumbnail.png"; string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}"; // 폴더 생성 if (!AssetDatabase.IsValidFolder(thumbnailFolderPath)) { AssetDatabase.CreateFolder(propInfo.folderPath, THUMBNAIL_FOLDER); } // 씬에서 직접 렌더링하여 썸네일 생성 (씬 조명 활용) var previewTexture = GeneratePreviewTexture(prefab); if (previewTexture == null) { UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 미리보기를 생성할 수 없습니다."); return false; } // 텍스처 저장 try { // PNG로 저장 byte[] pngData = previewTexture.EncodeToPNG(); string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/"); string directory = Path.GetDirectoryName(fullPath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } File.WriteAllBytes(fullPath, pngData); DestroyImmediate(previewTexture); // 에셋 임포트 AssetDatabase.ImportAsset(thumbnailPath); // PropInfo 업데이트 propInfo.thumbnailPath = thumbnailPath; UnityEngine.Debug.Log($"[PropBrowser] {propInfo.propName}: 썸네일 생성 완료"); return true; } catch (Exception ex) { UnityEngine.Debug.LogError($"[PropBrowser] {propInfo.propName}: 썸네일 저장 실패 - {ex.Message}"); return false; } } private Texture2D GeneratePreviewTexture(GameObject prefab) { // 씬에서 프리펩을 소환하여 씬 조명을 활용 var tempInstance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; if (tempInstance == null) { tempInstance = Instantiate(prefab); } // 썸네일 촬영용 임시 위치 설정 (씬 외곽) Vector3 capturePosition = new Vector3(10000f, 10000f, 10000f); tempInstance.transform.position = capturePosition; tempInstance.name = "[ThumbnailCapture_Temp]"; try { // 바운드 계산 - 모든 렌더러 포함 Bounds bounds = CalculateTotalBounds(tempInstance); if (bounds.size == Vector3.zero) { DestroyImmediate(tempInstance); return null; } // 카메라 설정 - 퍼스펙티브, 투명 배경 var cameraGo = new GameObject("[ThumbnailCamera_Temp]"); var camera = cameraGo.AddComponent(); camera.backgroundColor = new Color(0, 0, 0, 0); // 투명 배경 camera.clearFlags = CameraClearFlags.SolidColor; camera.orthographic = false; // 퍼스펙티브 카메라 camera.fieldOfView = 35f; // 적당한 FOV로 자연스러운 원근감 camera.nearClipPlane = 0.01f; camera.farClipPlane = 10000f; // 카메라 위치 계산 - 바운더리 기반으로 전체가 보이도록 PositionCameraToFitBounds(camera, bounds); // 렌더 텍스처 var rt = new RenderTexture(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, 24, RenderTextureFormat.ARGB32); rt.antiAliasing = 4; // 안티앨리어싱 추가 camera.targetTexture = rt; // 항상 전용 조명 사용 (씬 조명과 독립적으로 일관된 썸네일 생성) var lightContainer = new GameObject("[ThumbnailLights_Temp]"); // 메인 키 라이트 - 카메라 방향에서 약간 위에서 비춤 (밝게) var keyLightGo = new GameObject("[KeyLight]"); keyLightGo.transform.SetParent(lightContainer.transform); var keyLight = keyLightGo.AddComponent(); keyLight.type = LightType.Directional; keyLight.transform.rotation = Quaternion.Euler(35, -25, 0); // 앞쪽 위에서 keyLight.intensity = 2.0f; // 더 밝게 keyLight.color = new Color(1f, 0.98f, 0.96f); // 따뜻한 화이트 // 필 라이트 - 반대쪽에서 그림자 채우기 var fillLightGo = new GameObject("[FillLight]"); fillLightGo.transform.SetParent(lightContainer.transform); var fillLight = fillLightGo.AddComponent(); fillLight.type = LightType.Directional; fillLight.transform.rotation = Quaternion.Euler(20, 160, 0); // 뒤쪽에서 fillLight.intensity = 1.0f; fillLight.color = new Color(0.9f, 0.92f, 1f); // 약간 차가운 색 // 림 라이트 - 뒤쪽에서 윤곽 강조 var rimLightGo = new GameObject("[RimLight]"); rimLightGo.transform.SetParent(lightContainer.transform); var rimLight = rimLightGo.AddComponent(); rimLight.type = LightType.Directional; rimLight.transform.rotation = Quaternion.Euler(10, 180, 0); // 정면 뒤쪽 rimLight.intensity = 0.8f; rimLight.color = Color.white; // 렌더링 camera.Render(); // 텍스처 읽기 var texture = new Texture2D(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, TextureFormat.RGBA32, false); RenderTexture.active = rt; texture.ReadPixels(new Rect(0, 0, THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE), 0, 0); texture.Apply(); RenderTexture.active = null; // 정리 DestroyImmediate(lightContainer); DestroyImmediate(cameraGo); DestroyImmediate(rt); DestroyImmediate(tempInstance); return texture; } catch (Exception ex) { UnityEngine.Debug.LogError($"[PropBrowser] GeneratePreviewTexture 오류: {ex.Message}"); DestroyImmediate(tempInstance); return null; } } /// /// 오브젝트의 전체 바운드 계산 (모든 렌더러, 스킨드 메시, 파티클 포함) /// private Bounds CalculateTotalBounds(GameObject obj) { var renderers = obj.GetComponentsInChildren(true); var skinnedMeshRenderers = obj.GetComponentsInChildren(true); var meshFilters = obj.GetComponentsInChildren(true); Bounds bounds = new Bounds(obj.transform.position, Vector3.zero); bool hasBounds = false; // 렌더러 바운드 수집 foreach (var renderer in renderers) { // 파티클 시스템은 제외 (불필요하게 큰 바운드 생성) if (renderer is ParticleSystemRenderer) continue; if (!hasBounds) { bounds = renderer.bounds; hasBounds = true; } else { bounds.Encapsulate(renderer.bounds); } } // 바운드가 없으면 MeshFilter에서 직접 계산 if (!hasBounds) { foreach (var mf in meshFilters) { if (mf.sharedMesh == null) continue; var meshBounds = mf.sharedMesh.bounds; var worldBounds = new Bounds( mf.transform.TransformPoint(meshBounds.center), Vector3.Scale(meshBounds.size, mf.transform.lossyScale) ); if (!hasBounds) { bounds = worldBounds; hasBounds = true; } else { bounds.Encapsulate(worldBounds); } } } return bounds; } /// /// 카메라를 바운더리에 맞게 위치시켜 전체가 보이도록 함 /// private void PositionCameraToFitBounds(Camera camera, Bounds bounds) { // 앞쪽에서 약간 위, 약간 오른쪽에서 촬영 (3/4 뷰) // Unity에서 일반적으로 Z-가 앞쪽 (캐릭터가 바라보는 방향) // 카메라는 Z+ 방향에서 Z- 방향을 바라봄 Vector3 viewDirection = new Vector3(0.3f, 0.4f, 1f).normalized; // 바운드 크기 계산 float boundsSphereRadius = bounds.extents.magnitude; // FOV 기반 거리 계산 - 전체가 화면에 들어오도록 float fov = camera.fieldOfView * Mathf.Deg2Rad; float distance = boundsSphereRadius / Mathf.Sin(fov / 2f); // 약간의 여백 추가 (1.3배 - 좀 더 여유있게) distance *= 1.3f; // 카메라 위치 설정 - 오브젝트 앞에서 촬영 camera.transform.position = bounds.center + viewDirection * distance; camera.transform.LookAt(bounds.center); // 클리핑 평면 조정 camera.nearClipPlane = Mathf.Max(0.01f, distance - boundsSphereRadius * 2f); camera.farClipPlane = distance + boundsSphereRadius * 2f; } #endregion #region Web Upload private void ShowWebUploadMenu() { var menu = new GenericMenu(); menu.AddItem(new GUIContent("웹사이트에 업로드"), false, () => UploadToWebsite()); menu.AddItem(new GUIContent("업로드 설정 열기"), false, () => WebsitePropExporter.ShowWindow()); menu.ShowAsContext(); } private void UploadToWebsite() { if (_database == null || _database.props.Count == 0) { EditorUtility.DisplayDialog("오류", "업로드할 프랍이 없습니다.", "확인"); return; } // WebsitePropExporter 윈도우 열고 바로 업로드 var exporter = EditorWindow.GetWindow("프랍 업로드"); exporter.UploadToWebsite(); } #endregion } }