user 25111e0dc5 Improve: 프랍 썸네일 생성 개선 (카메라 각도, 조명)
- 카메라 각도 변경: 앞쪽에서 3/4 뷰로 촬영
- 3점 조명 시스템 적용 (키/필/림 라이트)
- 조명 강도 증가로 더 밝은 썸네일 생성
- 모든 프랍 썸네일 재생성

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:39:27 +09:00

1401 lines
51 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Prop.Editor
{
/// <summary>
/// 개별 프리펩 표시용 데이터
/// </summary>
public class PrefabDisplayItem
{
public string prefabName; // 프리펩 이름
public string prefabPath; // 프리펩 경로
public string folderName; // 폴더 이름
public PropInfo parentProp; // 부모 PropInfo 참조
public string thumbnailPath; // 썸네일 경로
public string DisplayName => prefabName;
}
/// <summary>
/// 프랍 브라우저 에디터 윈도우
/// </summary>
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<PropInfo> _filteredProps;
private List<PrefabDisplayItem> _displayItems; // 개별 프리펩 표시용
private Dictionary<string, Texture2D> _thumbnailCache = new Dictionary<string, Texture2D>();
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<PropBrowserWindow>("프랍 브라우저");
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<GameObject>(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<GameObject>(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<GameObject>(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<GameObject>(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<GameObject>(item.prefabPath);
if (prefab != null)
{
Selection.activeObject = prefab;
EditorGUIUtility.PingObject(prefab);
return;
}
}
// 프리펩이 없으면 폴더 선택
if (item.parentProp != null)
{
var folder = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(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<GameObject>(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<Texture2D>(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<GameObject>(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<UnityEngine.Object>(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<GameObject>(propInfo.MainPrefabPath);
if (prefab != null)
{
Selection.activeObject = prefab;
EditorGUIUtility.PingObject(prefab);
return;
}
}
// 프리펩이 없으면 폴더 선택
var folder = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(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<GameObject>(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<Texture2D>(propInfo.thumbnailPath);
if (texture != null)
{
_thumbnailCache[propInfo.thumbnailPath] = texture;
}
return texture;
}
private void ClearThumbnailCache()
{
_thumbnailCache.Clear();
}
private void LoadOrCreateDatabase()
{
_database = AssetDatabase.LoadAssetAtPath<PropDatabase>(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<PropDatabase>();
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")
{
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<PropInfo>();
_displayItems = new List<PrefabDisplayItem>();
return;
}
// 기존 필터링
if (string.IsNullOrEmpty(_searchFilter))
{
_filteredProps = new List<PropInfo>(_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<PrefabDisplayItem>();
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
/// <summary>
/// 개별 프리펩에 대한 썸네일 생성
/// </summary>
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<GameObject>(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;
}
}
/// <summary>
/// 모든 프리펩에 대해 개별 썸네일 생성
/// </summary>
private void GenerateAllPrefabThumbnails()
{
// 모든 프리펩 아이템 수집 (필터 없이)
var allItems = new List<PrefabDisplayItem>();
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, "모든 프리펩");
}
/// <summary>
/// 썸네일이 없는 프리펩만 개별 썸네일 생성
/// </summary>
private void GenerateMissingPrefabThumbnails()
{
var itemsWithoutThumbnail = new List<PrefabDisplayItem>();
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}개 프리펩");
}
/// <summary>
/// PrefabDisplayItem 리스트에 대해 썸네일 일괄 생성
/// </summary>
private void GeneratePrefabThumbnails(List<PrefabDisplayItem> 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<PropInfo>();
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<PropInfo> 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<GameObject>(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>();
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<Light>();
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<Light>();
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<Light>();
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;
}
}
/// <summary>
/// 오브젝트의 전체 바운드 계산 (모든 렌더러, 스킨드 메시, 파티클 포함)
/// </summary>
private Bounds CalculateTotalBounds(GameObject obj)
{
var renderers = obj.GetComponentsInChildren<Renderer>(true);
var skinnedMeshRenderers = obj.GetComponentsInChildren<SkinnedMeshRenderer>(true);
var meshFilters = obj.GetComponentsInChildren<MeshFilter>(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;
}
/// <summary>
/// 카메라를 바운더리에 맞게 위치시켜 전체가 보이도록 함
/// </summary>
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<WebsitePropExporter>("프랍 업로드");
exporter.UploadToWebsite();
}
#endregion
}
}