// 휘영(Hwiyeong) — Unity VFX 인덱서 + 매처 // 설치: 이 파일을 Assets/Editor/ 에 넣으면 끝. 메뉴: Window > Hwiyeong > VFX Matcher // // 인덱싱: 프로젝트의 파티클 프리팹을 스캔 → 에디트모드에서 생애주기 렌더(Play 불필요, // URP/HDRP/Built-in 지원) → 가장 밝은 '피크 프레임'을 256px 썸네일로 → 서버 업로드. // 프리팹/머티리얼/텍스처는 업로드되지 않는다 — 썸네일과 경로 문자열만. // 검색: 문장(아무 언어) → 렌더 썸네일 후보 → 클릭으로 프리팹 핑 / 씬 배치. // // 실측 각인 (2026-07-17~18 Phase 0): // · URP에서 camera.Render()는 무시됨 → RenderPipeline.StandardRequest 필수 // · 프레이밍은 렌더러 바운즈(과대평가)가 아니라 실제 파티클 위치로 // · 피크 프레임이 2x2 시트보다 검색 임베딩 품질 우수 (42%→58%) // · Simulate(restart=true) = 결정적 → 같은 프리팹은 항상 같은 썸네일 #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; using UnityEngine.Networking; using UnityEngine.Rendering; namespace Hwiyeong { public class HwiyeongIndexer : EditorWindow { private const string PREF_KEY = "HW_apiKey"; private const string PREF_URL = "HW_serverUrl"; private string serverUrl = "https://hwiyeong.14dimension.com"; private string apiKey = ""; private string pathFilter = ""; // 쉼표 구분 경로 포함 필터 (비우면 전체) private string query = ""; private string status = ""; private Vector2 scroll; private MatchResponse result; private readonly Dictionary thumbs = new Dictionary(); [MenuItem("Window/Hwiyeong/VFX Matcher")] public static void Open() { var w = GetWindow("휘영 VFX Matcher"); w.minSize = new Vector2(460, 460); } private void OnEnable() { apiKey = EditorPrefs.GetString(PREF_KEY, apiKey); serverUrl = EditorPrefs.GetString(PREF_URL, serverUrl); } private void OnGUI() { EditorGUI.BeginChangeCheck(); serverUrl = EditorGUILayout.TextField("서버 URL", serverUrl); apiKey = EditorGUILayout.PasswordField("API 키", apiKey); if (EditorGUI.EndChangeCheck()) { EditorPrefs.SetString(PREF_KEY, apiKey); EditorPrefs.SetString(PREF_URL, serverUrl); } pathFilter = EditorGUILayout.TextField(new GUIContent("경로 필터", "쉼표 구분, 비우면 프로젝트 전체 (예: Hovl,MyVFX)"), pathFilter); GUILayout.BeginHorizontal(); if (GUILayout.Button("이펙트 스캔 · 인덱싱", GUILayout.Height(26))) { status = ScanAndIndex(serverUrl, apiKey, pathFilter, 100000, (msg, t) => EditorUtility.DisplayProgressBar("휘영 인덱싱", msg, t)); EditorUtility.ClearProgressBar(); } if (GUILayout.Button("상태", GUILayout.Width(60))) status = CheckStatus(serverUrl, apiKey); GUILayout.EndHorizontal(); EditorGUILayout.Space(6); EditorGUILayout.LabelField("어떤 이펙트가 필요한가요? (아무 언어나)", EditorStyles.miniLabel); GUILayout.BeginHorizontal(); query = EditorGUILayout.TextField(query); if (GUILayout.Button("검색", GUILayout.Width(64)) && !string.IsNullOrWhiteSpace(query)) Search(); GUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(status)) EditorGUILayout.HelpBox(status, MessageType.Info); scroll = EditorGUILayout.BeginScrollView(scroll); if (result != null && result.candidates != null) { for (int i = 0; i < result.candidates.Length; i++) { var c = result.candidates[i]; EditorGUILayout.BeginVertical("box"); GUILayout.BeginHorizontal(); if (thumbs.TryGetValue(i, out var tex) && tex != null) GUILayout.Label(tex, GUILayout.Width(96), GUILayout.Height(96)); GUILayout.BeginVertical(); var marker = (result.winner_index == i) ? "★ AI " : ""; EditorGUILayout.LabelField(marker + Path.GetFileName(c.file_path.Replace('\\', '/')), EditorStyles.boldLabel); EditorGUILayout.LabelField("score " + c.score.ToString("0.000"), EditorStyles.miniLabel); GUILayout.BeginHorizontal(); if (GUILayout.Button("프리팹 핑", GUILayout.Width(90))) PingPrefab(c.file_path); if (GUILayout.Button("씬에 배치", GUILayout.Width(90))) PlaceInScene(c.file_path); GUILayout.EndHorizontal(); EditorGUILayout.LabelField(c.file_path, EditorStyles.miniLabel); GUILayout.EndVertical(); GUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } } EditorGUILayout.EndScrollView(); } // ---- 인덱싱 코어 (UI 없이도 호출 가능 — 자동화/테스트용) -------------- public static string ScanAndIndex(string serverUrl, string apiKey, string pathFilter, int maxPrefabs, Action progress = null) { var filters = (pathFilter ?? "").Split(',').Select(s => s.Trim()) .Where(s => s.Length > 0).ToArray(); var all = AssetDatabase.FindAssets("t:Prefab") .Select(AssetDatabase.GUIDToAssetPath) .Where(p => filters.Length == 0 || filters.Any(f => p.IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0)) .ToList(); // 파티클 포함 프리팹만 (로드는 여기서 한 번) var candidates = new List(); for (int i = 0; i < all.Count && candidates.Count < maxPrefabs; i++) { if (progress != null && (i % 50 == 0)) progress($"스캔 {i}/{all.Count}", 0.1f * i / Mathf.Max(1, all.Count)); var go = AssetDatabase.LoadAssetAtPath(all[i]); if (go != null && go.GetComponentInChildren(true) != null) candidates.Add(all[i]); } if (candidates.Count == 0) return "파티클 프리팹을 찾지 못했습니다."; // 증분: 서버와 diff var diffBody = JsonUtility.ToJson(new SyncPaths { paths = candidates.ToArray() }); var diffJson = Post(serverUrl, apiKey, "/index/diff", diffBody, out var derr); if (diffJson == null) return "서버 연결 실패: " + derr; var diff = JsonUtility.FromJson(diffJson); var todo = (diff.@new ?? new string[0]).ToList(); if (todo.Count == 0) return $"이미 최신입니다 — 전체 {candidates.Count}개, 새 이펙트 없음" + (diff.removed != null && diff.removed.Length > 0 ? $" (서버에만 있는 {diff.removed.Length}개는 콘솔에서 정리 가능)" : ""); int done = 0, failed = 0; var batch = new List(); foreach (var path in todo) { progress?.Invoke($"렌더링 {done + failed + 1}/{todo.Count} {Path.GetFileName(path)}", 0.1f + 0.9f * (done + failed) / Mathf.Max(1, todo.Count)); var jpg = CapturePeakThumbnail(path, 256); if (jpg == null) { failed++; continue; } batch.Add(new IndexItem { path = path, thumb_b64 = Convert.ToBase64String(jpg) }); if (batch.Count >= 12) { if (!Upload(serverUrl, apiKey, batch, out var uerr)) return $"업로드 실패({done}개 완료 후): {uerr}"; done += batch.Count; batch.Clear(); } } if (batch.Count > 0) { if (!Upload(serverUrl, apiKey, batch, out var uerr2)) return $"업로드 실패({done}개 완료 후): {uerr2}"; done += batch.Count; } return $"인덱싱 완료 — 신규 {done}개 업로드, 실패 {failed}개 (전체 {candidates.Count}개)"; } private static bool Upload(string serverUrl, string apiKey, List items, out string error) { var body = JsonUtility.ToJson(new IndexAddRequest { items = items.ToArray() }); return Post(serverUrl, apiKey, "/index/add", body, out error) != null; } /// 파티클 프리팹 → 피크(최대 밝기) 프레임 256px JPG. 실패 시 null. public static byte[] CapturePeakThumbnail(string prefabPath, int size) { GameObject inst = null, camGo = null; RenderTexture rt = null; Texture2D frame = null, best = null; try { var prefab = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefab == null) return null; var far = new Vector3(5000f, -5000f, 0f); inst = UnityEngine.Object.Instantiate(prefab, far, Quaternion.identity); inst.hideFlags = HideFlags.HideAndDontSave; inst.SetActive(true); var systems = inst.GetComponentsInChildren(true); if (systems.Length == 0) return null; var root = systems[0]; float life = 0f; foreach (var ps in systems) { var m = ps.main; life = Mathf.Max(life, m.duration + m.startLifetime.constantMax); } float T = Mathf.Clamp(life, 0.6f, 5f); // 실파티클 위치 바운즈 (렌더러 바운즈는 과대평가 — 실측) var buf = new ParticleSystem.Particle[2048]; var b = new Bounds(far, Vector3.one * 1.5f); bool has = false; foreach (var t in new[] { 0.2f, 0.45f, 0.7f }) { root.Simulate(T * t, true, true); foreach (var ps in systems) { int n = ps.GetParticles(buf); var tr = ps.transform; bool local = ps.main.simulationSpace == ParticleSystemSimulationSpace.Local; for (int k = 0; k < n; k++) { var pos = local ? tr.TransformPoint(buf[k].position) : buf[k].position; var pb = new Bounds(pos, Vector3.one * buf[k].GetCurrentSize(ps) * 0.5f); if (!has) { b = pb; has = true; } else b.Encapsulate(pb); } } } float radius = Mathf.Clamp(b.extents.magnitude, 1.2f, 14f); camGo = new GameObject("HwiyeongCam") { hideFlags = HideFlags.HideAndDontSave }; var cam = camGo.AddComponent(); cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0.04f, 0.06f, 0.10f, 1f); cam.fieldOfView = 50f; float dist = radius / Mathf.Tan(25f * Mathf.Deg2Rad) * 1.15f; cam.transform.position = b.center + new Vector3(0.15f, 0.4f, -1f).normalized * dist; cam.transform.LookAt(b.center); cam.nearClipPlane = 0.05f; cam.farClipPlane = dist + radius * 4f + 50f; int cap = Mathf.Max(size, 256); rt = new RenderTexture(cap, cap, 24); frame = new Texture2D(cap, cap, TextureFormat.RGB24, false); float bestBright = -1f; foreach (var t in new[] { 0.12f, 0.35f, 0.6f, 0.9f }) { root.Simulate(T * t, true, true); var req = new RenderPipeline.StandardRequest(); // URP/HDRP 공식 경로 if (RenderPipeline.SupportsRenderRequest(cam, req)) { req.destination = rt; RenderPipeline.SubmitRenderRequest(cam, req); } else { cam.targetTexture = rt; // Built-in 폴백 cam.Render(); cam.targetTexture = null; } var prev = RenderTexture.active; RenderTexture.active = rt; frame.ReadPixels(new Rect(0, 0, cap, cap), 0, 0); frame.Apply(); RenderTexture.active = prev; // 밝기 샘플링으로 피크 프레임 선택 (실측: 피크가 시트보다 임베딩 품질↑) var px = frame.GetPixels32(); float bright = 0f; for (int k = 0; k < px.Length; k += 101) bright += px[k].r + px[k].g + px[k].b; if (bright > bestBright) { bestBright = bright; if (best != null) UnityEngine.Object.DestroyImmediate(best); best = new Texture2D(cap, cap, TextureFormat.RGB24, false); best.SetPixels32(px); best.Apply(); } } if (best == null) return null; if (cap != size) { // 다운스케일 (RT 블릿) var small = RenderTexture.GetTemporary(size, size); Graphics.Blit(best, small); var prev2 = RenderTexture.active; RenderTexture.active = small; var outTex = new Texture2D(size, size, TextureFormat.RGB24, false); outTex.ReadPixels(new Rect(0, 0, size, size), 0, 0); outTex.Apply(); RenderTexture.active = prev2; RenderTexture.ReleaseTemporary(small); var jpgS = outTex.EncodeToJPG(88); UnityEngine.Object.DestroyImmediate(outTex); return jpgS; } return best.EncodeToJPG(88); } catch (Exception e) { Debug.LogWarning("[휘영] 캡처 실패 " + prefabPath + ": " + e.Message); return null; } finally { if (inst != null) UnityEngine.Object.DestroyImmediate(inst); if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo); if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } if (frame != null) UnityEngine.Object.DestroyImmediate(frame); if (best != null) UnityEngine.Object.DestroyImmediate(best); } } // ---- 검색 ----------------------------------------------------------- private void Search() { status = "검색 중… (AI 판정 포함 — 오랜만이면 GPU 웨이크업으로 오래 걸릴 수 있음)"; Repaint(); var body = JsonUtility.ToJson(new MatchRequest { query = query, top_k = 5 }); var text = Post(serverUrl, apiKey, "/match", body, out var err); if (text == null) { status = "실패: " + err; return; } result = JsonUtility.FromJson(text); foreach (var t in thumbs.Values) if (t != null) UnityEngine.Object.DestroyImmediate(t); thumbs.Clear(); for (int i = 0; i < result.candidates.Length; i++) { if (string.IsNullOrEmpty(result.candidates[i].preview_b64)) continue; var tex = new Texture2D(2, 2); tex.LoadImage(Convert.FromBase64String(result.candidates[i].preview_b64)); thumbs[i] = tex; } status = result.candidates.Length > 0 ? "" : "후보 없음 — 먼저 인덱싱하세요."; } private static void PingPrefab(string path) { var asset = AssetDatabase.LoadAssetAtPath(path); if (asset != null) { EditorGUIUtility.PingObject(asset); Selection.activeObject = asset; } } private static void PlaceInScene(string path) { var asset = AssetDatabase.LoadAssetAtPath(path); if (asset == null) return; var go = (GameObject)PrefabUtility.InstantiatePrefab(asset); go.transform.position = Vector3.zero; Undo.RegisterCreatedObjectUndo(go, "Place Hwiyeong VFX"); Selection.activeGameObject = go; } private static string CheckStatus(string serverUrl, string apiKey) { var req = UnityWebRequest.Get(serverUrl.TrimEnd('/') + "/index/status"); req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 30; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(30); return req.result == UnityWebRequest.Result.Success ? "서버 응답: " + req.downloadHandler.text : "실패: " + req.error; } private static string Post(string serverUrl, string apiKey, string route, string json, out string error) { var req = new UnityWebRequest(serverUrl.TrimEnd('/') + route, "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 180; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(30); if (req.result != UnityWebRequest.Result.Success) { error = req.error + " " + req.downloadHandler.text; return null; } error = null; return req.downloadHandler.text; } // ---- DTO ------------------------------------------------------------ [Serializable] private class IndexItem { public string path; public string thumb_b64; } [Serializable] private class IndexAddRequest { public IndexItem[] items; } [Serializable] private class SyncPaths { public string[] paths; } [Serializable] private class DiffResponse { public string[] @new; public string[] removed; public int unchanged; } [Serializable] private class MatchRequest { public string query; public int top_k; } [Serializable] private class Candidate { public string file_path; public string name_ko; public float score; public string preview_b64; } [Serializable] private class MatchResponse { public int winner_index; public string match_quality; public string reason; public Candidate[] candidates; } } } #endif