using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using UnityEngine; using UnityEditor; using UnityEditor.Formats.Fbx.Exporter; using Autodesk.Fbx; namespace Streamingle.Editor.Utilities { // ════════════════════════════════════════════════════════════════ // FBX Export Enums (자체 정의 — v4/v5 호환) // ════════════════════════════════════════════════════════════════ /// FBX 출력 포맷. 값은 Unity FBX Exporter의 ExportFormat과 동일. public enum FbxExportFormat { ASCII = 0, Binary = 1 } /// FBX 포함 내용. 값은 Unity FBX Exporter의 Include와 동일. public enum FbxInclude { Model = 0, Anim = 1, ModelAndAnim = 2 } /// FBX 오브젝트 위치. 값은 Unity FBX Exporter의 ObjectPosition과 동일. public enum FbxObjectPosition { LocalCentered = 0, WorldAbsolute = 1, Reset = 2 } /// FBX LOD 내보내기. 값은 Unity FBX Exporter의 LODExportType과 동일. public enum FbxLODExportType { All = 0, Highest = 1, Lowest = 2 } // ════════════════════════════════════════════════════════════════ // FBX Export Compat (v4/v5 reflection bridge) // ════════════════════════════════════════════════════════════════ /// /// FBX Exporter v4(internal API)와 v5(public ExportModelOptions)를 /// 모두 지원하기 위한 reflection 기반 호환 레이어. /// internal static class FbxExportCompat { static readonly Type _optionsType; static readonly MethodInfo _exportWithOptions; static readonly bool _supportsOptions; static readonly string _initLog; static FbxExportCompat() { var sb = new System.Text.StringBuilder(); sb.Append("[FbxExportCompat] Init: "); // v5+: ExportModelOptions 클래스가 public으로 존재 _optionsType = typeof(ModelExporter).Assembly.GetType( "UnityEditor.Formats.Fbx.Exporter.ExportModelOptions"); sb.Append($"OptionsType={(_optionsType != null ? "found" : "NOT FOUND")}"); if (_optionsType != null) { _exportWithOptions = typeof(ModelExporter).GetMethod("ExportObject", new[] { typeof(string), typeof(UnityEngine.Object), _optionsType }); sb.Append($", ExportMethod={(_exportWithOptions != null ? "found" : "NOT FOUND")}"); } _supportsOptions = _optionsType != null && _exportWithOptions != null; sb.Append($", SupportsOptions={_supportsOptions}"); _initLog = sb.ToString(); UnityEngine.Debug.Log(_initLog); } /// v5+ ExportModelOptions를 지원하는지 여부. public static bool SupportsOptions => _supportsOptions; /// 초기화 로그 (에디터 UI 표시용). public static string InitLog => _initLog; /// /// ExportModelOptions 인스턴스를 reflection으로 생성하고 프로퍼티를 설정합니다. /// v5+에서만 동작하며, v4에서는 null을 반환합니다. /// public static object BuildOptions( FbxExportFormat format, FbxInclude include, FbxObjectPosition position, FbxLODExportType lod, bool embedTextures, bool mayaCompatibleNames = false) { if (!_supportsOptions) { UnityEngine.Debug.LogWarning("[FbxExportCompat] BuildOptions: v4 감지 — 옵션 미지원, null 반환"); return null; } var opts = Activator.CreateInstance(_optionsType); int setCount = 0; setCount += SetEnum(opts, "ExportFormat", (int)format) ? 1 : 0; setCount += SetEnum(opts, "ModelAnimIncludeOption", (int)include) ? 1 : 0; setCount += SetEnum(opts, "ObjectPosition", (int)position) ? 1 : 0; setCount += SetEnum(opts, "LODExportType", (int)lod) ? 1 : 0; setCount += SetBool(opts, "EmbedTextures", embedTextures) ? 1 : 0; setCount += SetBool(opts, "UseMayaCompatibleNames", mayaCompatibleNames) ? 1 : 0; UnityEngine.Debug.Log($"[FbxExportCompat] BuildOptions: {setCount}/6 프로퍼티 설정 완료 " + $"(Format={format}, Include={include}, Position={position}, LOD={lod}, " + $"Embed={embedTextures}, Maya={mayaCompatibleNames})"); return opts; } /// /// v5+: ExportObject(path, obj, options) /// v4: ExportObject(path, obj) /// public static string ExportObject(string filePath, UnityEngine.Object obj, object options = null) { if (_supportsOptions && options != null) { UnityEngine.Debug.Log($"[FbxExportCompat] ExportObject: v5 reflection 경로 (options={options.GetType().Name})"); try { return _exportWithOptions.Invoke(null, new object[] { filePath, obj, options }) as string; } catch (Exception ex) { UnityEngine.Debug.LogError($"[FbxExportCompat] v5 reflection 호출 실패: {ex.InnerException?.Message ?? ex.Message}\n" + "→ v4 fallback (옵션 없이 내보내기)"); return ModelExporter.ExportObject(filePath, obj); } } UnityEngine.Debug.Log($"[FbxExportCompat] ExportObject: v4 fallback (supportsOptions={_supportsOptions}, options={(options != null ? "non-null" : "null")})"); return ModelExporter.ExportObject(filePath, obj); } static bool SetEnum(object target, string propName, int value) { var prop = _optionsType.GetProperty(propName); if (prop == null) { UnityEngine.Debug.LogWarning($"[FbxExportCompat] Property '{propName}' not found!"); return false; } prop.SetValue(target, Enum.ToObject(prop.PropertyType, value)); return true; } static bool SetBool(object target, string propName, bool value) { var prop = _optionsType.GetProperty(propName); if (prop == null) { UnityEngine.Debug.LogWarning($"[FbxExportCompat] Property '{propName}' not found!"); return false; } prop.SetValue(target, value); return true; } } // ════════════════════════════════════════════════════════════════ // Export Result // ════════════════════════════════════════════════════════════════ /// /// Export 결과를 담는 클래스. 처리 내역과 통계를 포함합니다. /// public class ExportResult { public string SourceName; public string FilePath; public bool Success; public int NormalizedBoneCount; public int ResetSMRCount; public int ConvertedMRCount; public int RenamedBoneCount; public int RenamedMeshCount; public int BakedBlendShapeCount; public int ConvertedMaterialCount; public long FileSizeBytes; public float ElapsedSeconds; public DateTime Timestamp = DateTime.Now; public string GetSummary() { var sb = new System.Text.StringBuilder(); sb.AppendLine($"대상: {SourceName}"); sb.AppendLine($"경로: {FilePath}"); if (FileSizeBytes > 0) sb.AppendLine($"파일 크기: {FileSizeBytes / 1024f:F1} KB"); sb.AppendLine($"소요 시간: {ElapsedSeconds:F2}초"); if (NormalizedBoneCount > 0) sb.AppendLine($"정규화된 본: {NormalizedBoneCount}개"); if (ResetSMRCount > 0) sb.AppendLine($"초기화된 SMR Transform: {ResetSMRCount}개"); if (ConvertedMRCount > 0) sb.AppendLine($"변환된 MeshRenderer: {ConvertedMRCount}개"); if (RenamedBoneCount > 0) sb.AppendLine($"이름 변경된 본: {RenamedBoneCount}개"); if (RenamedMeshCount > 0) sb.AppendLine($"이름 변경된 메쉬: {RenamedMeshCount}개"); if (BakedBlendShapeCount > 0) sb.AppendLine($"블렌드 쉐입 가중치 기록: {BakedBlendShapeCount}개"); if (ConvertedMaterialCount > 0) sb.AppendLine($"Standard 머티리얼 변환: {ConvertedMaterialCount}개"); return sb.ToString(); } } // ════════════════════════════════════════════════════════════════ // Core Exporter // ════════════════════════════════════════════════════════════════ /// /// FBX Export 래퍼: Bone Scale → 1, SMR Transform → Identity 정규화 후 Export. /// 클론을 생성하여 원본을 수정하지 않으며, Bindpose와 Vertex를 자동 보상 재계산합니다. /// public static class NormalizedFbxExporter { public static ExportResult Export( GameObject source, string filePath, bool normalizeBoneScales = true, bool resetSMRTransforms = true, bool convertChildMeshRenderers = false, bool deduplicateBoneNames = false, bool deduplicateMeshNames = false, bool preserveBlendShapeWeights = false, bool convertToStandardMaterial = false, bool stripTextures = false, bool verbose = false, object fbxOptions = null) { var result = new ExportResult { SourceName = source != null ? source.name : "(null)" }; var sw = System.Diagnostics.Stopwatch.StartNew(); if (source == null) { UnityEngine.Debug.LogError("[NormalizedFbxExporter] Source GameObject is null."); return result; } if (string.IsNullOrEmpty(filePath)) { UnityEngine.Debug.LogError("[NormalizedFbxExporter] File path is empty."); return result; } // 블렌드쉐입 가중치 수집 (FBX 후처리용) Dictionary blendShapeWeights = null; if (preserveBlendShapeWeights) blendShapeWeights = CollectBlendShapeWeights(source); // 처리할 게 없으면 바로 Export (preserveBlendShapeWeights는 후처리) if (!normalizeBoneScales && !resetSMRTransforms && !convertChildMeshRenderers && !deduplicateBoneNames && !deduplicateMeshNames && !convertToStandardMaterial) { var directPath = FbxExportCompat.ExportObject(filePath, source, fbxOptions); result.FilePath = directPath; result.Success = directPath != null; if (result.Success && blendShapeWeights != null && blendShapeWeights.Count > 0) result.BakedBlendShapeCount = SetBlendShapeWeightsInFbx(directPath, blendShapeWeights); sw.Stop(); result.ElapsedSeconds = (float)sw.Elapsed.TotalSeconds; TryGetFileSize(result); return result; } var clone = UnityEngine.Object.Instantiate(source); clone.name = source.name; clone.hideFlags = HideFlags.HideInHierarchy; var clonedMeshes = new List(); var createdMaterials = new List(); var savedBindposes = new Dictionary(); try { EditorUtility.DisplayProgressBar("Normalized FBX Export", "처리 중...", 0f); ProcessHierarchy(clone, normalizeBoneScales, resetSMRTransforms, convertChildMeshRenderers, deduplicateBoneNames, deduplicateMeshNames, clonedMeshes, savedBindposes, result, verbose); // 처리 후 클론 상태 검증 if (verbose) ValidateCloneState(clone); // 머티리얼을 Standard로 변환 (클론에서만 — 원본 무변경) if (convertToStandardMaterial) { EditorUtility.DisplayProgressBar("Normalized FBX Export", "머티리얼 변환...", 0.88f); result.ConvertedMaterialCount = ConvertMaterialsToStandard(clone, createdMaterials, verbose); } // 텍스처 제거 — 머티리얼 슬롯을 비워서 텍스처 참조 없이 내보냄 if (stripTextures) { foreach (var renderer in clone.GetComponentsInChildren(true)) renderer.sharedMaterials = new Material[renderer.sharedMaterials.Length]; } EditorUtility.DisplayProgressBar("Normalized FBX Export", "FBX 파일 쓰는 중...", 0.9f); var exportPath = FbxExportCompat.ExportObject(filePath, clone, fbxOptions); result.FilePath = exportPath; result.Success = exportPath != null; // FBX 후처리: 블렌드쉐입 가중치 기록 if (result.Success && blendShapeWeights != null && blendShapeWeights.Count > 0) { EditorUtility.DisplayProgressBar("Normalized FBX Export", "블렌드쉐입 가중치 기록...", 0.95f); result.BakedBlendShapeCount = SetBlendShapeWeightsInFbx(exportPath, blendShapeWeights); } TryGetFileSize(result); } finally { // 원본 메시의 바인드포즈 복원 (임시 수정한 경우) foreach (var kvp in savedBindposes) kvp.Key.bindposes = kvp.Value; foreach (var mat in createdMaterials) UnityEngine.Object.DestroyImmediate(mat); foreach (var mesh in clonedMeshes) UnityEngine.Object.DestroyImmediate(mesh); UnityEngine.Object.DestroyImmediate(clone); EditorUtility.ClearProgressBar(); } sw.Stop(); result.ElapsedSeconds = (float)sw.Elapsed.TotalSeconds; if (result.Success) UnityEngine.Debug.Log($"[NormalizedFbxExporter] 완료: {result.FilePath} ({result.ElapsedSeconds:F2}s)"); else UnityEngine.Debug.LogError("[NormalizedFbxExporter] 내보내기 실패."); return result; } static void TryGetFileSize(ExportResult result) { try { if (!string.IsNullOrEmpty(result.FilePath)) { var fi = new System.IO.FileInfo(result.FilePath); if (fi.Exists) result.FileSizeBytes = fi.Length; } } catch { } } static void ProcessHierarchy( GameObject root, bool normalizeBoneScales, bool resetSMRTransforms, bool convertChildMeshRenderers, bool deduplicateBoneNames, bool deduplicateMeshNames, List clonedMeshes, Dictionary savedBindposes, ExportResult result, bool verbose) { // ── Phase 0: 본 하위 MeshRenderer → SkinnedMeshRenderer 변환 ── if (convertChildMeshRenderers) { EditorUtility.DisplayProgressBar("Normalized FBX Export", "Phase 0: MeshRenderer 변환...", 0.1f); result.ConvertedMRCount = ConvertChildMeshRenderers(root, clonedMeshes, verbose); } // SMR 목록 (Phase 0에서 새로 생성된 SMR 포함) var smrs = root.GetComponentsInChildren(true); if (smrs.Length == 0) return; // ── Phase 1: 본 스케일 정규화 ── Dictionary originalMatrices = null; if (normalizeBoneScales) { EditorUtility.DisplayProgressBar("Normalized FBX Export", "Phase 1: 본 스케일 정규화...", 0.3f); var skeleton = CollectSkeletonTransforms(smrs, root.transform); originalMatrices = new Dictionary(); foreach (var t in skeleton) originalMatrices[t] = t.localToWorldMatrix; int negativeCount = 0; foreach (var t in skeleton) { var absS = new Vector3(Mathf.Abs(t.localScale.x), Mathf.Abs(t.localScale.y), Mathf.Abs(t.localScale.z)); if (!ApproximatelyOne(absS)) { result.NormalizedBoneCount++; if (HasNegativeComponent(t.localScale)) negativeCount++; if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] Scale ≠ 1: {GetPath(t, root.transform)} = {t.localScale}" + (HasNegativeComponent(t.localScale) ? " (부호 보존)" : "")); } } if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] 스켈레톤 {skeleton.Count}개, 정규화 대상: {result.NormalizedBoneCount}개 (음수 포함: {negativeCount}개)"); NormalizeScales(skeleton); if (verbose) { foreach (var t in skeleton) { var absPost = new Vector3(Mathf.Abs(t.localScale.x), Mathf.Abs(t.localScale.y), Mathf.Abs(t.localScale.z)); if (!ApproximatelyOne(absPost)) UnityEngine.Debug.LogWarning($"[NormalizedFbxExporter] 정규화 후 |Scale| ≠ 1: {GetPath(t, root.transform)} = {t.localScale}"); } } } // ── Phase 2: SMR별 메시 처리 ── for (int si = 0; si < smrs.Length; si++) { var smr = smrs[si]; EditorUtility.DisplayProgressBar("Normalized FBX Export", $"Phase 2: {smr.name} ({si + 1}/{smrs.Length})...", 0.5f + 0.3f * si / smrs.Length); bool needsBindposeRecalc = normalizeBoneScales && smr.bones != null && smr.bones.Length > 0 && smr.sharedMesh != null; bool needsTransformBake = resetSMRTransforms && !IsIdentity(smr.transform) && smr.sharedMesh != null; if (!needsBindposeRecalc && !needsTransformBake) continue; if (needsBindposeRecalc && !needsTransformBake) { // ── 바인드포즈만 수정 — 메시 복제 없이 원본 임시 수정 ── // Unity 2021 호환: AddBlendShapeFrame/ClearBlendShapes를 사용하지 않으므로 // 블렌드쉐입 데이터가 원본 그대로 보존됩니다. // Export 후 finally 블록에서 원래 바인드포즈로 복원합니다. var mesh = smr.sharedMesh; if (!savedBindposes.ContainsKey(mesh)) savedBindposes[mesh] = mesh.bindposes; RecalculateBindposes(smr, mesh, originalMatrices); } else { // ── Transform Bake 필요 — 새 Mesh 생성 (블렌드쉐입 포함) ── var mesh = smr.sharedMesh; if (!clonedMeshes.Contains(mesh)) { mesh = CloneMesh(smr.sharedMesh); smr.sharedMesh = mesh; clonedMeshes.Add(mesh); } if (needsBindposeRecalc) RecalculateBindposes(smr, mesh, originalMatrices); var bakedMesh = BakeAndResetTransform(smr, mesh, root.transform); clonedMeshes.Remove(mesh); UnityEngine.Object.DestroyImmediate(mesh); mesh = bakedMesh; smr.sharedMesh = mesh; clonedMeshes.Add(mesh); result.ResetSMRCount++; } } // ── Phase 3: 이름 중복 해결 ── if (deduplicateBoneNames || deduplicateMeshNames) { EditorUtility.DisplayProgressBar("Normalized FBX Export", "Phase 3: 이름 중복 해결...", 0.85f); DeduplicateNames(root, deduplicateBoneNames, deduplicateMeshNames, result, verbose); } } #region ── Phase 0: Child MeshRenderer → SkinnedMeshRenderer 변환 ── /// /// 본 하위에 있는 MeshRenderer를 찾아 SkinnedMeshRenderer로 변환하고 루트로 이동합니다. /// 부모 본에 weight 1.0을 할당하여 본을 따라 움직이도록 합니다. /// /// 변환된 MeshRenderer 개수 static int ConvertChildMeshRenderers(GameObject root, List clonedMeshes, bool verbose) { // 스켈레톤 Transform 수집 var smrs = root.GetComponentsInChildren(true); var boneSet = new HashSet(); foreach (var smr in smrs) { if (smr.rootBone != null) CollectDescendants(smr.rootBone, boneSet); if (smr.bones != null) { foreach (var b in smr.bones) { if (b == null) continue; boneSet.Add(b); var ancestor = b.parent; while (ancestor != null && ancestor != root.transform) { boneSet.Add(ancestor); ancestor = ancestor.parent; } } } } // MeshRenderer+MeshFilter를 가진 Transform은 본이 아닌 메시 오브젝트 → 제거 foreach (var mr in root.GetComponentsInChildren(true)) { if (mr.GetComponent() != null) boneSet.Remove(mr.transform); } if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] Phase 0: boneSet {boneSet.Count}개"); if (boneSet.Count == 0) return 0; // 본 하위의 MeshRenderer 수집 var meshRenderers = root.GetComponentsInChildren(true); var targets = new List<(MeshRenderer mr, MeshFilter mf, Transform parentBone, Matrix4x4 worldMatrix)>(); foreach (var mr in meshRenderers) { if (boneSet.Contains(mr.transform)) continue; var parentBone = FindNearestBoneAncestor(mr.transform, boneSet); if (parentBone == null) continue; var mf = mr.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] Phase 0 대상: {GetPath(mr.transform, root.transform)} → Bone: {parentBone.name}"); targets.Add((mr, mf, parentBone, mr.transform.localToWorldMatrix)); } if (targets.Count == 0) { if (verbose) UnityEngine.Debug.Log("[NormalizedFbxExporter] Phase 0: 변환 대상 없음"); return 0; } // 변환 실행 int converted = 0; foreach (var (mr, mf, parentBone, worldMatrix) in targets) { if (mr == null) continue; var originalMesh = mf.sharedMesh; var materials = mr.sharedMaterials; var go = mr.gameObject; var mesh = CloneMesh(originalMesh); clonedMeshes.Add(mesh); var bakeMatrix = root.transform.worldToLocalMatrix * worldMatrix; BakeMatrixIntoMesh(mesh, bakeMatrix); var weights = new BoneWeight[mesh.vertexCount]; for (int i = 0; i < weights.Length; i++) { weights[i].boneIndex0 = 0; weights[i].weight0 = 1f; } mesh.boneWeights = weights; mesh.bindposes = new Matrix4x4[] { parentBone.worldToLocalMatrix * root.transform.localToWorldMatrix }; UnityEngine.Object.DestroyImmediate(mf); UnityEngine.Object.DestroyImmediate(mr); var newSmr = go.AddComponent(); newSmr.sharedMesh = mesh; newSmr.sharedMaterials = materials; newSmr.bones = new Transform[] { parentBone }; newSmr.rootBone = parentBone; go.transform.SetParent(root.transform, false); go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one; converted++; } UnityEngine.Debug.Log($"[NormalizedFbxExporter] Phase 0: MeshRenderer → SMR 변환 {converted}개 완료"); return converted; } static Transform FindNearestBoneAncestor(Transform t, HashSet bones) { var current = t.parent; while (current != null) { if (bones.Contains(current)) return current; current = current.parent; } return null; } static void BakeMatrixIntoMesh(Mesh mesh, Matrix4x4 matrix) { var lin = matrix; lin.m03 = 0; lin.m13 = 0; lin.m23 = 0; var nrm = lin.inverse.transpose; var verts = mesh.vertices; for (int i = 0; i < verts.Length; i++) verts[i] = matrix.MultiplyPoint3x4(verts[i]); mesh.vertices = verts; var normals = mesh.normals; if (normals != null && normals.Length > 0) { for (int i = 0; i < normals.Length; i++) normals[i] = nrm.MultiplyVector(normals[i]).normalized; mesh.normals = normals; } var tangents = mesh.tangents; if (tangents != null && tangents.Length > 0) { for (int i = 0; i < tangents.Length; i++) { var v = lin.MultiplyVector( new Vector3(tangents[i].x, tangents[i].y, tangents[i].z)).normalized; tangents[i] = new Vector4(v.x, v.y, v.z, tangents[i].w); } mesh.tangents = tangents; } mesh.RecalculateBounds(); } #endregion #region ── Phase 1: Bone Scale Normalization ── static HashSet CollectSkeletonTransforms(SkinnedMeshRenderer[] smrs, Transform cloneRoot) { var result = new HashSet(); foreach (var smr in smrs) { if (smr.rootBone != null) { CollectDescendants(smr.rootBone, result); var ancestor = smr.rootBone.parent; while (ancestor != null && ancestor != cloneRoot) { result.Add(ancestor); ancestor = ancestor.parent; } } if (smr.bones == null) continue; foreach (var bone in smr.bones) if (bone != null) result.Add(bone); } return result; } static void CollectDescendants(Transform root, HashSet result) { result.Add(root); foreach (Transform child in root) CollectDescendants(child, result); } static void NormalizeScales(HashSet transforms) { foreach (var t in transforms.OrderBy(GetDepth)) { var s = t.localScale; // 절대값 기준으로 크기가 이미 ~1이면 스킵 // (부호만 다른 경우: (-1,-1,-1) 등은 그대로 유지) var absScale = new Vector3(Mathf.Abs(s.x), Mathf.Abs(s.y), Mathf.Abs(s.z)); if (ApproximatelyOne(absScale)) continue; // 부호 보존: 양수→1, 음수→-1 var targetScale = new Vector3( s.x < 0f ? -1f : 1f, s.y < 0f ? -1f : 1f, s.z < 0f ? -1f : 1f); // 자식 보상: 절대값(크기)만큼 보상 (부호는 자식 자체의 것 유지) for (int i = 0; i < t.childCount; i++) { var child = t.GetChild(i); child.localPosition = Vector3.Scale(absScale, child.localPosition); child.localScale = Vector3.Scale(absScale, child.localScale); } t.localScale = targetScale; } } static void RecalculateBindposes( SkinnedMeshRenderer smr, Mesh mesh, Dictionary originalMatrices) { var bones = smr.bones; var bp = mesh.bindposes; if (bp == null || bp.Length != bones.Length) return; var newBp = new Matrix4x4[bp.Length]; for (int i = 0; i < bones.Length; i++) { if (bones[i] != null && originalMatrices.TryGetValue(bones[i], out var oldL2W)) newBp[i] = bones[i].worldToLocalMatrix * oldL2W * bp[i]; else newBp[i] = bp[i]; } mesh.bindposes = newBp; } #endregion #region ── Phase 2: SMR Transform Bake ── /// /// SMR의 로컬 Transform을 메시에 베이크하고 Transform을 Identity로 리셋합니다. /// Unity 2021 호환: ClearBlendShapes를 사용하지 않고 새 Mesh를 생성하여 /// AddBlendShapeFrame의 내부 버퍼 동기화 문제를 회피합니다. /// /// 베이크된 새 Mesh (호출자가 clonedMeshes 교체 필요) static Mesh BakeAndResetTransform(SkinnedMeshRenderer smr, Mesh sourceMesh, Transform root) { var t = smr.transform; // SMR 메시 로컬 → 루트 로컬: 전체 Transform 체인 반영 var trs = root.worldToLocalMatrix * t.localToWorldMatrix; // 방향 벡터용 (평행이동 제거, 3×3 부분만) var lin = trs; lin.m03 = 0f; lin.m13 = 0f; lin.m23 = 0f; var nrm = lin.inverse.transpose; var mesh = new Mesh(); mesh.name = sourceMesh.name; mesh.indexFormat = sourceMesh.indexFormat; // ── Geometry (변환 적용) ── var verts = sourceMesh.vertices; for (int i = 0; i < verts.Length; i++) verts[i] = trs.MultiplyPoint3x4(verts[i]); mesh.vertices = verts; var normals = sourceMesh.normals; if (normals != null && normals.Length > 0) { for (int i = 0; i < normals.Length; i++) normals[i] = nrm.MultiplyVector(normals[i]).normalized; mesh.normals = normals; } var tangents = sourceMesh.tangents; if (tangents != null && tangents.Length > 0) { for (int i = 0; i < tangents.Length; i++) { var v = lin.MultiplyVector( new Vector3(tangents[i].x, tangents[i].y, tangents[i].z)).normalized; tangents[i] = new Vector4(v.x, v.y, v.z, tangents[i].w); } mesh.tangents = tangents; } // ── 변환 불필요 속성 그대로 복사 ── mesh.colors = sourceMesh.colors; mesh.uv = sourceMesh.uv; mesh.uv2 = sourceMesh.uv2; mesh.uv3 = sourceMesh.uv3; mesh.uv4 = sourceMesh.uv4; // ── Topology ── mesh.subMeshCount = sourceMesh.subMeshCount; for (int i = 0; i < sourceMesh.subMeshCount; i++) mesh.SetTriangles(sourceMesh.GetTriangles(i), i); // ── Skinning ── mesh.boneWeights = sourceMesh.boneWeights; // 바인드포즈 보상: newBp × newVert = (oldBp × TRS⁻¹) × (TRS × oldVert) = oldBp × oldVert var bindposes = sourceMesh.bindposes; if (bindposes != null && bindposes.Length > 0) { var trsInv = trs.inverse; for (int i = 0; i < bindposes.Length; i++) bindposes[i] = bindposes[i] * trsInv; } mesh.bindposes = bindposes; // ── Blend Shapes (변환 후 새 Mesh에 직접 추가 — ClearBlendShapes 회피) ── int shapeCount = sourceMesh.blendShapeCount; if (shapeCount > 0) { int vc = sourceMesh.vertexCount; var dv = new Vector3[vc]; var dn = new Vector3[vc]; var dt = new Vector3[vc]; for (int s = 0; s < shapeCount; s++) { string shapeName = sourceMesh.GetBlendShapeName(s); int fc = sourceMesh.GetBlendShapeFrameCount(s); for (int f = 0; f < fc; f++) { float w = sourceMesh.GetBlendShapeFrameWeight(s, f); sourceMesh.GetBlendShapeFrameVertices(s, f, dv, dn, dt); var newDv = new Vector3[vc]; var newDn = new Vector3[vc]; var newDt = new Vector3[vc]; for (int vi = 0; vi < vc; vi++) { newDv[vi] = lin.MultiplyVector(dv[vi]); newDn[vi] = nrm.MultiplyVector(dn[vi]); newDt[vi] = lin.MultiplyVector(dt[vi]); } mesh.AddBlendShapeFrame(shapeName, w, newDv, newDn, newDt); } } } mesh.RecalculateBounds(); // 루트 직하로 이동 후 Identity — 버텍스가 이미 루트 기준이므로 t.SetParent(root, false); t.localPosition = Vector3.zero; t.localRotation = Quaternion.identity; t.localScale = Vector3.one; return mesh; } #endregion #region ── Material → Standard 변환 ── static readonly string[] BaseTexProps = { "_MainTex", "_BaseMap", "_BaseColorMap" }; static readonly string[] BaseColorProps = { "_Color", "_BaseColor" }; /// /// 클론의 모든 Renderer 머티리얼을 Standard 셰이더로 변환합니다. /// 원본 머티리얼의 베이스맵(텍스처), 컬러, 노멀맵을 복사합니다. /// 원본 머티리얼은 변경하지 않습니다. /// static int ConvertMaterialsToStandard(GameObject root, List createdMaterials, bool verbose) { var standardShader = Shader.Find("Standard"); if (standardShader == null) { UnityEngine.Debug.LogWarning("[NormalizedFbxExporter] Standard 셰이더를 찾을 수 없습니다."); return 0; } int converted = 0; var renderers = root.GetComponentsInChildren(true); foreach (var renderer in renderers) { var srcMats = renderer.sharedMaterials; var newMats = new Material[srcMats.Length]; bool changed = false; for (int i = 0; i < srcMats.Length; i++) { var src = srcMats[i]; if (src == null || src.shader == standardShader) { newMats[i] = src; continue; } var mat = new Material(standardShader); mat.name = src.name; createdMaterials.Add(mat); // 베이스 텍스처 foreach (var prop in BaseTexProps) { if (!src.HasProperty(prop)) continue; var tex = src.GetTexture(prop); if (tex == null) continue; mat.SetTexture("_MainTex", tex); mat.SetTextureScale("_MainTex", src.GetTextureScale(prop)); mat.SetTextureOffset("_MainTex", src.GetTextureOffset(prop)); break; } // 베이스 컬러 foreach (var prop in BaseColorProps) { if (!src.HasProperty(prop)) continue; mat.SetColor("_Color", src.GetColor(prop)); break; } // 노멀맵 if (src.HasProperty("_BumpMap")) { var normalMap = src.GetTexture("_BumpMap"); if (normalMap != null) { mat.SetTexture("_BumpMap", normalMap); mat.EnableKeyword("_NORMALMAP"); if (src.HasProperty("_BumpScale")) mat.SetFloat("_BumpScale", src.GetFloat("_BumpScale")); } } if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] 머티리얼 변환: {src.name} ({src.shader.name} → Standard)"); newMats[i] = mat; changed = true; converted++; } if (changed) renderer.sharedMaterials = newMats; } return converted; } #endregion #region ── BlendShape Weight Preserve (FBX 후처리) ── static bool HasNonZeroBlendShapeWeight(SkinnedMeshRenderer smr) { for (int s = 0; s < smr.sharedMesh.blendShapeCount; s++) { if (!Mathf.Approximately(smr.GetBlendShapeWeight(s), 0f)) return true; } return false; } /// /// 소스 GameObject의 모든 SMR에서 블렌드쉐입 가중치를 수집합니다. /// key: SMR의 GameObject 이름, value: 각 블렌드쉐입의 가중치 배열 /// static Dictionary CollectBlendShapeWeights(GameObject source) { var result = new Dictionary(); foreach (var smr in source.GetComponentsInChildren(true)) { if (smr.sharedMesh == null || smr.sharedMesh.blendShapeCount == 0) continue; if (!HasNonZeroBlendShapeWeight(smr)) continue; var weights = new float[smr.sharedMesh.blendShapeCount]; for (int i = 0; i < weights.Length; i++) weights[i] = smr.GetBlendShapeWeight(i); result[smr.gameObject.name] = weights; } return result; } /// /// 내보낸 FBX 파일을 열어 각 BlendShapeChannel의 DeformPercent에 /// 수집된 가중치 값을 기록합니다. /// 메시 버텍스는 변경하지 않으므로 2x 문제가 발생하지 않습니다. /// /// 기록된 블렌드쉐입 채널 수 static int SetBlendShapeWeightsInFbx(string fbxPath, Dictionary weightsByNodeName) { int totalSet = 0; var manager = FbxManager.Create(); try { var ioSettings = FbxIOSettings.Create(manager, Autodesk.Fbx.Globals.IOSROOT); // EXP_FBX_EMBEDDED = false: 임베드 여부는 1차 Export에서 결정됨. // true로 하면 텍스처가 의도치 않게 임베드되어 용량이 폭증할 수 있음. ioSettings.SetBoolProp(Autodesk.Fbx.Globals.EXP_FBX_EMBEDDED, false); manager.SetIOSettings(ioSettings); var scene = FbxScene.Create(manager, ""); // FBX 파일 읽기 var importer = FbxImporter.Create(manager, ""); if (!importer.Initialize(fbxPath, -1, ioSettings)) { importer.Destroy(); return 0; } importer.Import(scene); importer.Destroy(); // 블렌드쉐입 채널에 DeformPercent 기록 totalSet = SetDeformPercentRecursive(scene.GetRootNode(), weightsByNodeName); if (totalSet > 0) { // 수정된 FBX 다시 쓰기 (임베드된 텍스처 보존) var exporter = FbxExporter.Create(manager, ""); int fileFormat = manager.GetIOPluginRegistry() .FindWriterIDByDescription("FBX binary (*.fbx)"); if (exporter.Initialize(fbxPath, fileFormat, ioSettings)) exporter.Export(scene); exporter.Destroy(); } scene.Destroy(); } finally { manager.Destroy(); } return totalSet; } static int SetDeformPercentRecursive(FbxNode node, Dictionary weightsByNodeName) { if (node == null) return 0; int count = 0; string nodeName = node.GetName(); var mesh = node.GetMesh(); if (mesh != null && weightsByNodeName.TryGetValue(nodeName, out float[] weights)) { int bsDeformerCount = mesh.GetDeformerCount(FbxDeformer.EDeformerType.eBlendShape); for (int d = 0; d < bsDeformerCount; d++) { var blendShape = mesh.GetBlendShapeDeformer(d); if (blendShape == null) continue; int channelCount = blendShape.GetBlendShapeChannelCount(); for (int c = 0; c < channelCount && c < weights.Length; c++) { if (Mathf.Approximately(weights[c], 0f)) continue; var channel = blendShape.GetBlendShapeChannel(c); if (channel == null) continue; channel.DeformPercent.Set((double)weights[c]); count++; } } } for (int i = 0; i < node.GetChildCount(); i++) count += SetDeformPercentRecursive(node.GetChild(i), weightsByNodeName); return count; } #endregion #region ── Phase 3: Name Deduplication ── static void DeduplicateNames(GameObject root, bool deduplicateBones, bool deduplicateMeshes, ExportResult result, bool verbose) { var smrs = root.GetComponentsInChildren(true); var boneSet = new HashSet(); foreach (var smr in smrs) { if (smr.rootBone != null) CollectDescendants(smr.rootBone, boneSet); if (smr.bones != null) foreach (var b in smr.bones) { if (b == null) continue; boneSet.Add(b); var ancestor = b.parent; while (ancestor != null && ancestor != root.transform) { boneSet.Add(ancestor); ancestor = ancestor.parent; } } } var meshSet = new HashSet(); foreach (var smr in smrs) meshSet.Add(smr.transform); foreach (var mr in root.GetComponentsInChildren(true)) meshSet.Add(mr.transform); if (deduplicateBones) result.RenamedBoneCount = DeduplicateCategory(root.transform, boneSet, "Bone", verbose); if (deduplicateMeshes) result.RenamedMeshCount = DeduplicateCategory(root.transform, meshSet, "Mesh", verbose); UnityEngine.Debug.Log($"[NormalizedFbxExporter] Phase 3: 본 이름 {result.RenamedBoneCount}개, 메쉬 이름 {result.RenamedMeshCount}개 변경"); } static int DeduplicateCategory(Transform root, HashSet targets, string category, bool verbose) { var nameGroups = new Dictionary>(); foreach (var t in targets) { if (t == root) continue; if (!nameGroups.TryGetValue(t.name, out var list)) { list = new List(); nameGroups[t.name] = list; } list.Add(t); } var usedNames = new HashSet(); foreach (var t in targets) if (t != root) usedNames.Add(t.name); int renamed = 0; foreach (var (name, group) in nameGroups) { if (group.Count <= 1) continue; int suffix = 1; for (int i = 1; i < group.Count; i++) { string newName; do { newName = $"{name}_{suffix++}"; } while (usedNames.Contains(newName)); if (verbose) UnityEngine.Debug.Log($"[NormalizedFbxExporter] {category} 이름 변경: {group[i].name} → {newName}"); group[i].name = newName; usedNames.Add(newName); renamed++; } } return renamed; } #endregion #region ── Utilities ── /// /// Mesh를 수동 딥카피합니다. Object.Instantiate(Mesh)는 Unity 2021에서 /// 블렌드쉐이프 데이터를 누락하거나, 내부 포맷 상태가 꼬여 /// ClearBlendShapes/AddBlendShapeFrame이 실패할 수 있습니다. /// new Mesh()로 생성하면 깨끗한 상태에서 모든 데이터가 정확히 복사됩니다. /// static Mesh CloneMesh(Mesh source) { var mesh = new Mesh(); mesh.name = source.name; mesh.indexFormat = source.indexFormat; // Geometry mesh.vertices = source.vertices; mesh.normals = source.normals; mesh.tangents = source.tangents; mesh.colors = source.colors; mesh.uv = source.uv; mesh.uv2 = source.uv2; mesh.uv3 = source.uv3; mesh.uv4 = source.uv4; // Topology (submesh별 삼각형) mesh.subMeshCount = source.subMeshCount; for (int i = 0; i < source.subMeshCount; i++) mesh.SetTriangles(source.GetTriangles(i), i); // Skinning mesh.boneWeights = source.boneWeights; mesh.bindposes = source.bindposes; // Blend Shapes (명시적 복사 — Unity 2021 호환) if (source.blendShapeCount > 0) { int vc = source.vertexCount; var dv = new Vector3[vc]; var dn = new Vector3[vc]; var dt = new Vector3[vc]; for (int s = 0; s < source.blendShapeCount; s++) { string name = source.GetBlendShapeName(s); int fc = source.GetBlendShapeFrameCount(s); for (int f = 0; f < fc; f++) { float w = source.GetBlendShapeFrameWeight(s, f); source.GetBlendShapeFrameVertices(s, f, dv, dn, dt); mesh.AddBlendShapeFrame(name, w, (Vector3[])dv.Clone(), (Vector3[])dn.Clone(), (Vector3[])dt.Clone()); } } } mesh.RecalculateBounds(); return mesh; } static bool IsIdentity(Transform t) { return t.localPosition == Vector3.zero && t.localRotation == Quaternion.identity && ApproximatelyOne(t.localScale); } static bool ApproximatelyOne(Vector3 v, float tolerance = 1e-4f) { return Mathf.Abs(v.x - 1f) < tolerance && Mathf.Abs(v.y - 1f) < tolerance && Mathf.Abs(v.z - 1f) < tolerance; } /// 스케일에 음수 성분이 있는지 확인합니다. static bool HasNegativeComponent(Vector3 v) { return v.x < 0f || v.y < 0f || v.z < 0f; } static int GetDepth(Transform t) { int d = 0; while (t.parent != null) { d++; t = t.parent; } return d; } static string GetPath(Transform t, Transform root) { var parts = new List(); while (t != null && t != root) { parts.Add(t.name); t = t.parent; } parts.Reverse(); return string.Join("/", parts); } /// /// 처리 후 클론 상태를 검증하고 이상이 있으면 경고 로그를 출력합니다. /// static void ValidateCloneState(GameObject clone) { var smrs = clone.GetComponentsInChildren(true); int issueCount = 0; foreach (var smr in smrs) { if (smr.sharedMesh == null) continue; // 본 스케일 검증 (절대값 기준, 부호는 무시) if (smr.bones != null) { foreach (var bone in smr.bones) { if (bone == null) continue; var ls = bone.lossyScale; var absLs = new Vector3(Mathf.Abs(ls.x), Mathf.Abs(ls.y), Mathf.Abs(ls.z)); if (!ApproximatelyOne(absLs, 1e-3f)) { UnityEngine.Debug.LogWarning( $"[Validate] 본 |lossyScale| ≠ 1: {bone.name} = ({ls.x:F6}, {ls.y:F6}, {ls.z:F6})"); issueCount++; } } } // 바인드포즈 개수 검증 var mesh = smr.sharedMesh; if (smr.bones != null && mesh.bindposes != null && mesh.bindposes.Length != smr.bones.Length) { UnityEngine.Debug.LogWarning( $"[Validate] 바인드포즈 수 불일치: {smr.name} — bones={smr.bones.Length}, bindposes={mesh.bindposes.Length}"); issueCount++; } // SMR 트랜스폼 검증 if (!IsIdentity(smr.transform)) { UnityEngine.Debug.LogWarning( $"[Validate] SMR 트랜스폼 ≠ identity: {smr.name} — pos={smr.transform.localPosition}, rot={smr.transform.localEulerAngles}, scale={smr.transform.localScale}"); issueCount++; } } if (issueCount == 0) UnityEngine.Debug.Log("[Validate] 클론 상태 검증 통과 — 이상 없음"); else UnityEngine.Debug.LogWarning($"[Validate] 클론 상태 검증 완료 — {issueCount}건 이상 발견"); } #endregion } // ════════════════════════════════════════════════════════════════ // Export Preset // ════════════════════════════════════════════════════════════════ [Serializable] internal class ExportPreset { public string name = "New Preset"; public bool normalizeBones = true; public bool resetSMR = true; public bool convertChildMR; public bool dedupBoneNames; public bool dedupMeshNames; public bool preserveBlendShapes; public bool convertToStandard; public bool stripTextures; public bool verbose; public int exportFormat; public int include; public int objectPosition; public int lodExportType; public bool embedTextures; } [Serializable] internal class ExportPresetList { public List presets = new List(); } // ════════════════════════════════════════════════════════════════ // Preview Data // ════════════════════════════════════════════════════════════════ internal class PreviewData { public int smrCount; public int boneCount; public int nonUnitBoneCount; public int nonIdentitySMRCount; public int childMRCount; public int dupBoneNameCount; public int dupMeshNameCount; public int nonZeroBlendShapeCount; public List<(string path, Vector3 scale)> nonUnitBoneList = new List<(string, Vector3)>(); public List<(string mrPath, string boneName)> childMRList = new List<(string, string)>(); public List<(string name, int count)> dupBoneNameGroups = new List<(string, int)>(); public List<(string name, int count)> dupMeshNameGroups = new List<(string, int)>(); } // ════════════════════════════════════════════════════════════════ // Editor Window // ════════════════════════════════════════════════════════════════ public class NormalizedFbxExporterWindow : EditorWindow { [MenuItem("Tools/Normalized FBX Export")] static void Open() { var window = GetWindow("Normalized FBX Export"); window.minSize = new Vector2(400, 500); } // ── EditorPrefs Keys ── const string P = "NormFbxExp_"; // ── Settings ── bool _batchMode; GameObject _target; List _batchTargets = new List(); bool _normalizeBones = true; bool _resetSMR = true; bool _convertChildMR; bool _dedupBoneNames; bool _dedupMeshNames; bool _preserveBlendShapes; bool _convertToStandard; bool _stripTextures; bool _verbose; FbxExportFormat _exportFormat = FbxExportFormat.Binary; FbxInclude _include = FbxInclude.ModelAndAnim; FbxObjectPosition _objectPosition = FbxObjectPosition.LocalCentered; FbxLODExportType _lodExportType = FbxLODExportType.All; bool _embedTextures; // ── UI State ── bool _foldPreview = true; bool _foldPreviewBones; bool _foldPreviewMR; bool _foldPreviewDupBones; bool _foldPreviewDupMeshes; bool _foldHistory; Vector2 _scrollPos; // ── Preset ── int _presetIdx = -1; bool _showPresetSave; string _newPresetName = ""; ExportPresetList _presetList = new ExportPresetList(); // ── Preview Cache ── int _cachedPreviewId; PreviewData _preview; // ── History ── List _history = new List(); Vector2 _historyScroll; // ── Styles (lazy init) ── static GUIStyle _subHeaderStyle; static GUIStyle SubHeaderStyle => _subHeaderStyle ??= new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.6f, 0.75f, 1f) }, padding = new RectOffset(4, 0, 6, 2) }; #region ── Lifecycle ── void OnEnable() { LoadPrefs(); LoadPresets(); if (Selection.activeGameObject != null && _target == null) _target = Selection.activeGameObject; } void OnDisable() => SavePrefs(); void OnSelectionChange() => Repaint(); #endregion #region ── EditorPrefs ── void LoadPrefs() { _normalizeBones = EditorPrefs.GetBool(P + "NormBones", true); _resetSMR = EditorPrefs.GetBool(P + "ResetSMR", true); _convertChildMR = EditorPrefs.GetBool(P + "ConvertMR", false); _dedupBoneNames = EditorPrefs.GetBool(P + "DedupBone", false); _dedupMeshNames = EditorPrefs.GetBool(P + "DedupMesh", false); _preserveBlendShapes = EditorPrefs.GetBool(P + "PreserveBS", false); _convertToStandard = EditorPrefs.GetBool(P + "ConvertStd", false); _stripTextures = EditorPrefs.GetBool(P + "StripTex", false); _verbose = EditorPrefs.GetBool(P + "Verbose", false); _exportFormat = (FbxExportFormat)EditorPrefs.GetInt(P + "Format", 0); _include = (FbxInclude)EditorPrefs.GetInt(P + "Include", 0); _objectPosition = (FbxObjectPosition)EditorPrefs.GetInt(P + "ObjPos", 0); _lodExportType = (FbxLODExportType)EditorPrefs.GetInt(P + "LOD", 0); _embedTextures = EditorPrefs.GetBool(P + "Embed", false); _batchMode = EditorPrefs.GetBool(P + "Batch", false); _foldPreview = EditorPrefs.GetBool(P + "FoldPrev", true); _foldHistory = EditorPrefs.GetBool(P + "FoldHist", false); } void SavePrefs() { EditorPrefs.SetBool(P + "NormBones", _normalizeBones); EditorPrefs.SetBool(P + "ResetSMR", _resetSMR); EditorPrefs.SetBool(P + "ConvertMR", _convertChildMR); EditorPrefs.SetBool(P + "DedupBone", _dedupBoneNames); EditorPrefs.SetBool(P + "DedupMesh", _dedupMeshNames); EditorPrefs.SetBool(P + "PreserveBS", _preserveBlendShapes); EditorPrefs.SetBool(P + "ConvertStd", _convertToStandard); EditorPrefs.SetBool(P + "StripTex", _stripTextures); EditorPrefs.SetBool(P + "Verbose", _verbose); EditorPrefs.SetInt(P + "Format", (int)_exportFormat); EditorPrefs.SetInt(P + "Include", (int)_include); EditorPrefs.SetInt(P + "ObjPos", (int)_objectPosition); EditorPrefs.SetInt(P + "LOD", (int)_lodExportType); EditorPrefs.SetBool(P + "Embed", _embedTextures); EditorPrefs.SetBool(P + "Batch", _batchMode); EditorPrefs.SetBool(P + "FoldPrev", _foldPreview); EditorPrefs.SetBool(P + "FoldHist", _foldHistory); } #endregion #region ── Presets ── void LoadPresets() { var json = EditorPrefs.GetString(P + "Presets", ""); if (!string.IsNullOrEmpty(json)) { try { _presetList = JsonUtility.FromJson(json); } catch { _presetList = new ExportPresetList(); } } if (_presetList == null) _presetList = new ExportPresetList(); } void SavePresets() { EditorPrefs.SetString(P + "Presets", JsonUtility.ToJson(_presetList)); } void ApplyPreset(ExportPreset p) { _normalizeBones = p.normalizeBones; _resetSMR = p.resetSMR; _convertChildMR = p.convertChildMR; _dedupBoneNames = p.dedupBoneNames; _dedupMeshNames = p.dedupMeshNames; _preserveBlendShapes = p.preserveBlendShapes; _convertToStandard = p.convertToStandard; _stripTextures = p.stripTextures; _verbose = p.verbose; _exportFormat = (FbxExportFormat)p.exportFormat; _include = (FbxInclude)p.include; _objectPosition = (FbxObjectPosition)p.objectPosition; _lodExportType = (FbxLODExportType)p.lodExportType; _embedTextures = p.embedTextures; } ExportPreset CreatePresetFromCurrent(string name) { return new ExportPreset { name = name, normalizeBones = _normalizeBones, resetSMR = _resetSMR, convertChildMR = _convertChildMR, dedupBoneNames = _dedupBoneNames, dedupMeshNames = _dedupMeshNames, preserveBlendShapes = _preserveBlendShapes, convertToStandard = _convertToStandard, stripTextures = _stripTextures, verbose = _verbose, exportFormat = (int)_exportFormat, include = (int)_include, objectPosition = (int)_objectPosition, lodExportType = (int)_lodExportType, embedTextures = _embedTextures }; } #endregion #region ── OnGUI ── void OnGUI() { _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos); // ════════════════════════════════════════ // Header // ════════════════════════════════════════ EditorGUILayout.Space(4); EditorGUILayout.LabelField("Normalized FBX Exporter", new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }); EditorGUILayout.Space(2); // ════════════════════════════════════════ // Preset // ════════════════════════════════════════ DrawPresetSection(); EditorGUILayout.Space(2); // ════════════════════════════════════════ // Target // ════════════════════════════════════════ DrawSectionHeader("Target"); _batchMode = EditorGUILayout.Toggle("Batch Mode", _batchMode); EditorGUILayout.Space(2); if (_batchMode) DrawBatchTargets(); else DrawSingleTarget(); // ════════════════════════════════════════ // Options (all visible) // ════════════════════════════════════════ EditorGUILayout.Space(4); DrawSectionHeader("Export Options"); // ── Mesh Processing ── DrawSubHeader("Mesh Processing"); _normalizeBones = DrawToggleRow("Bone Scale 정규화", "모든 본의 Scale을 1로 정규화합니다. Bindpose가 자동 보상됩니다.", _normalizeBones); _resetSMR = DrawToggleRow("SMR Transform 초기화", "SkinnedMeshRenderer의 Transform을 Identity로 초기화하고 버텍스를 Bake합니다.", _resetSMR); _convertChildMR = DrawToggleRow("본 하위 MeshRenderer 변환", "본 하위의 MeshRenderer를 SkinnedMeshRenderer로 변환하여 루트로 이동합니다.", _convertChildMR); // ── BlendShape ── DrawSubHeader("BlendShape"); _preserveBlendShapes = DrawToggleRow("블렌드 쉐입 값 유지", "현재 블렌드 쉐입 가중치를 FBX 파일에 기록합니다. 메시 데이터는 변경되지 않습니다.", _preserveBlendShapes); // ── Name ── DrawSubHeader("Name"); _dedupBoneNames = DrawToggleRow("본 이름 중복 해결", "중복된 본 이름에 _1, _2 접미사를 붙입니다.", _dedupBoneNames); _dedupMeshNames = DrawToggleRow("메쉬 이름 중복 해결", "중복된 메쉬 오브젝트 이름에 _1, _2 접미사를 붙입니다.", _dedupMeshNames); // ── Material ── DrawSubHeader("Material"); _convertToStandard = DrawToggleRow("머티리얼 Standard 변환", "커스텀 셰이더(lilToon, NiloToon 등)를 Standard로 변환합니다. 원본 머티리얼은 변경되지 않습니다.", _convertToStandard); _stripTextures = DrawToggleRow("텍스처 제외", "머티리얼의 텍스처 참조를 제거하여 메시 데이터만 내보냅니다.", _stripTextures); // ── FBX Format ── EditorGUILayout.Space(4); DrawSectionHeader("FBX Format"); _exportFormat = (FbxExportFormat)EditorGUILayout.EnumPopup("Format", _exportFormat); _include = (FbxInclude)EditorGUILayout.EnumPopup("Include", _include); _objectPosition = (FbxObjectPosition)EditorGUILayout.EnumPopup("Object Position", _objectPosition); _lodExportType = (FbxLODExportType)EditorGUILayout.EnumPopup("LOD", _lodExportType); _embedTextures = DrawToggleRow("텍스처 임베드", "텍스처를 FBX 파일에 포함합니다. Binary 포맷에서만 동작합니다.", _embedTextures); if (_embedTextures && _exportFormat != FbxExportFormat.Binary) { EditorGUILayout.HelpBox("텍스처 임베드는 Binary 포맷에서만 사용 가능합니다.", MessageType.Warning); } // 호환 레이어 상태 if (FbxExportCompat.SupportsOptions) { EditorGUILayout.HelpBox("FBX Exporter v5+ — 옵션이 적용됩니다.", MessageType.Info); } else { EditorGUILayout.HelpBox("FBX Exporter v4 감지 — Format 옵션이 적용되지 않습니다.\n" + "v5 이상으로 업그레이드를 권장합니다.", MessageType.Warning); } _verbose = DrawToggleRow("상세 로그", "콘솔에 개별 본/메시 처리 로그를 출력합니다.", _verbose); // ════════════════════════════════════════ // Export Button // ════════════════════════════════════════ EditorGUILayout.Space(10); DrawExportButton(); EditorGUILayout.Space(4); // ════════════════════════════════════════ // Preview // ════════════════════════════════════════ bool hasPreviewTarget = _batchMode ? _batchTargets.Any(t => t != null) : _target != null; if (hasPreviewTarget) { DrawSectionHeader("Preview"); _foldPreview = EditorGUILayout.Foldout(_foldPreview, _batchMode ? $"미리보기 (합계)" : "미리보기", true); if (_foldPreview) { if (_batchMode) DrawBatchPreview(); else DrawPreview(); } } // ════════════════════════════════════════ // History // ════════════════════════════════════════ if (_history.Count > 0) { EditorGUILayout.Space(2); DrawSectionHeader("History"); _foldHistory = EditorGUILayout.Foldout(_foldHistory, $"내보내기 이력 ({_history.Count})", true); if (_foldHistory) DrawHistory(); } EditorGUILayout.EndScrollView(); } #endregion #region ── UI Helpers ── static void DrawSectionHeader(string title) { var rect = GUILayoutUtility.GetRect(0, 20, GUILayout.ExpandWidth(true)); var lineColor = EditorGUIUtility.isProSkin ? new Color(0.35f, 0.35f, 0.35f) : new Color(0.7f, 0.7f, 0.7f); var lineY = rect.y + rect.height * 0.5f; EditorGUI.DrawRect(new Rect(rect.x, lineY, rect.width, 1), lineColor); var labelStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11, padding = new RectOffset(0, 0, 0, 0) }; var size = labelStyle.CalcSize(new GUIContent(title)); var bgColor = EditorGUIUtility.isProSkin ? new Color(0.22f, 0.22f, 0.22f) : new Color(0.76f, 0.76f, 0.76f); var labelRect = new Rect(rect.x + 8, rect.y + 1, size.x + 8, rect.height - 2); EditorGUI.DrawRect(labelRect, bgColor); GUI.Label(new Rect(labelRect.x + 4, labelRect.y, size.x, labelRect.height), title, labelStyle); } static void DrawSubHeader(string title) { EditorGUILayout.Space(3); EditorGUILayout.LabelField(title, SubHeaderStyle); } static bool DrawToggleRow(string label, string tooltip, bool value) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(new GUIContent(label, tooltip)); value = EditorGUILayout.Toggle(value, GUILayout.Width(16)); } return value; } #endregion #region ── Target Section ── void DrawSingleTarget() { _target = (GameObject)EditorGUILayout.ObjectField( "대상 오브젝트", _target, typeof(GameObject), true); // Drag & Drop 지원 (ObjectField이 기본 지원하지만 drop zone 추가) var dropRect = GUILayoutUtility.GetLastRect(); HandleDragAndDrop(dropRect, false); } void DrawBatchTargets() { EditorGUILayout.LabelField($"대상 오브젝트 ({_batchTargets.Count}개)"); int removeIdx = -1; for (int i = 0; i < _batchTargets.Count; i++) { using (new EditorGUILayout.HorizontalScope()) { _batchTargets[i] = (GameObject)EditorGUILayout.ObjectField( _batchTargets[i], typeof(GameObject), true); if (GUILayout.Button("\u00d7", GUILayout.Width(22))) removeIdx = i; } } if (removeIdx >= 0) _batchTargets.RemoveAt(removeIdx); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("현재 선택 추가")) { foreach (var go in Selection.gameObjects) { if (!_batchTargets.Contains(go)) _batchTargets.Add(go); } } if (_batchTargets.Count > 0 && GUILayout.Button("모두 제거", GUILayout.Width(70))) _batchTargets.Clear(); } // Drop Zone var style = new GUIStyle(EditorStyles.helpBox) { alignment = TextAnchor.MiddleCenter, fontSize = 11 }; var dropRect = GUILayoutUtility.GetRect(0, 28, GUILayout.ExpandWidth(true)); GUI.Box(dropRect, "여기에 오브젝트를 드래그하세요", style); HandleDragAndDrop(dropRect, true); } void HandleDragAndDrop(Rect dropArea, bool isBatch) { var evt = Event.current; if (!dropArea.Contains(evt.mousePosition)) return; if (evt.type == EventType.DragUpdated) { bool hasGameObject = DragAndDrop.objectReferences.Any(o => o is GameObject); DragAndDrop.visualMode = hasGameObject ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected; evt.Use(); } else if (evt.type == EventType.DragPerform) { DragAndDrop.AcceptDrag(); foreach (var obj in DragAndDrop.objectReferences) { if (obj is GameObject go) { if (isBatch) { if (!_batchTargets.Contains(go)) _batchTargets.Add(go); } else { _target = go; } } } evt.Use(); } } #endregion #region ── Preset Section ── void DrawPresetSection() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("Preset", EditorStyles.boldLabel, GUILayout.Width(50)); if (_presetList.presets.Count == 0) { EditorGUILayout.LabelField("(none)", EditorStyles.miniLabel); } else { var names = _presetList.presets.Select(p => p.name).ToArray(); int newIdx = EditorGUILayout.Popup(_presetIdx, names); if (newIdx != _presetIdx && newIdx >= 0 && newIdx < _presetList.presets.Count) { _presetIdx = newIdx; ApplyPreset(_presetList.presets[_presetIdx]); } } if (GUILayout.Button("+", EditorStyles.miniButtonLeft, GUILayout.Width(22))) _showPresetSave = !_showPresetSave; GUI.enabled = _presetIdx >= 0 && _presetIdx < _presetList.presets.Count; if (GUILayout.Button("-", EditorStyles.miniButtonRight, GUILayout.Width(22))) { _presetList.presets.RemoveAt(_presetIdx); _presetIdx = Mathf.Min(_presetIdx, _presetList.presets.Count - 1); SavePresets(); } GUI.enabled = true; } if (_showPresetSave) { EditorGUILayout.Space(2); using (new EditorGUILayout.HorizontalScope()) { _newPresetName = EditorGUILayout.TextField(_newPresetName); if (GUILayout.Button("Save", EditorStyles.miniButtonLeft, GUILayout.Width(40))) { if (!string.IsNullOrWhiteSpace(_newPresetName)) { _presetList.presets.Add(CreatePresetFromCurrent(_newPresetName.Trim())); _presetIdx = _presetList.presets.Count - 1; SavePresets(); } _showPresetSave = false; _newPresetName = ""; } if (GUILayout.Button("Cancel", EditorStyles.miniButtonRight, GUILayout.Width(50))) { _showPresetSave = false; _newPresetName = ""; } } } } } #endregion #region ── Preview ── void UpdatePreviewCache(GameObject target) { int id = target.GetInstanceID(); if (id == _cachedPreviewId && _preview != null) return; _cachedPreviewId = id; _preview = new PreviewData(); var smrs = target.GetComponentsInChildren(true); _preview.smrCount = smrs.Length; var boneSet = new HashSet(); foreach (var smr in smrs) { if (smr.rootBone != null) CollectDescendantsPreview(smr.rootBone, boneSet); if (smr.bones != null) foreach (var b in smr.bones) { if (b == null) continue; boneSet.Add(b); var ancestor = b.parent; while (ancestor != null && ancestor != target.transform) { boneSet.Add(ancestor); ancestor = ancestor.parent; } } if (!IsIdentityCheck(smr.transform)) _preview.nonIdentitySMRCount++; // 활성 블렌드 쉐입 카운트 if (smr.sharedMesh != null) { for (int s = 0; s < smr.sharedMesh.blendShapeCount; s++) { if (!Mathf.Approximately(smr.GetBlendShapeWeight(s), 0f)) _preview.nonZeroBlendShapeCount++; } } } _preview.boneCount = boneSet.Count; // Scale ≠ 1 bones foreach (var b in boneSet) { var s = b.localScale; if (!Mathf.Approximately(s.x, 1f) || !Mathf.Approximately(s.y, 1f) || !Mathf.Approximately(s.z, 1f)) { _preview.nonUnitBoneCount++; var path = GetPreviewPath(b, target.transform); _preview.nonUnitBoneList.Add((path, s)); } } // Child MeshRenderers foreach (var mr in target.GetComponentsInChildren(true)) { if (boneSet.Contains(mr.transform)) continue; var p = mr.transform.parent; while (p != null) { if (boneSet.Contains(p)) { if (mr.GetComponent()?.sharedMesh != null) { _preview.childMRCount++; _preview.childMRList.Add(( GetPreviewPath(mr.transform, target.transform), p.name)); } break; } p = p.parent; } } // Duplicate bone names CountDupNames(boneSet, target.transform, out _preview.dupBoneNameCount, _preview.dupBoneNameGroups); // Duplicate mesh names var meshSet = new HashSet(); foreach (var smr in smrs) meshSet.Add(smr.transform); foreach (var mr in target.GetComponentsInChildren(true)) meshSet.Add(mr.transform); CountDupNames(meshSet, target.transform, out _preview.dupMeshNameCount, _preview.dupMeshNameGroups); } static void CountDupNames(HashSet set, Transform root, out int totalDups, List<(string name, int count)> groups) { var nameCount = new Dictionary(); foreach (var t in set) { if (t == root) continue; if (!nameCount.ContainsKey(t.name)) nameCount[t.name] = 0; nameCount[t.name]++; } totalDups = 0; foreach (var (name, count) in nameCount) { if (count > 1) { totalDups += count - 1; groups.Add((name, count)); } } } void DrawPreview() { if (_target == null) return; UpdatePreviewCache(_target); DrawPreviewData(_preview, true); } void DrawBatchPreview() { // 배치 모드: 합산 미리보기 (상세 없음) var agg = new PreviewData(); foreach (var target in _batchTargets) { if (target == null) continue; UpdatePreviewCache(target); agg.smrCount += _preview.smrCount; agg.boneCount += _preview.boneCount; agg.nonUnitBoneCount += _preview.nonUnitBoneCount; agg.nonIdentitySMRCount += _preview.nonIdentitySMRCount; agg.childMRCount += _preview.childMRCount; agg.dupBoneNameCount += _preview.dupBoneNameCount; agg.dupMeshNameCount += _preview.dupMeshNameCount; agg.nonZeroBlendShapeCount += _preview.nonZeroBlendShapeCount; } // 배치에서는 캐시를 무효화 (여러 타겟 순회했으므로) _cachedPreviewId = 0; DrawPreviewData(agg, false); } void DrawPreviewData(PreviewData data, bool showDetail) { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField($"SkinnedMeshRenderer: {data.smrCount}개"); EditorGUILayout.LabelField($"Bone: {data.boneCount}개 (Scale \u2260 1: {data.nonUnitBoneCount}개)"); EditorGUILayout.LabelField($"SMR Transform \u2260 Identity: {data.nonIdentitySMRCount}개"); if (data.childMRCount > 0) EditorGUILayout.LabelField($"본 하위 MeshRenderer: {data.childMRCount}개"); if (data.dupBoneNameCount > 0) EditorGUILayout.LabelField($"본 이름 중복: {data.dupBoneNameCount}개"); if (data.dupMeshNameCount > 0) EditorGUILayout.LabelField($"메쉬 이름 중복: {data.dupMeshNameCount}개"); if (data.nonZeroBlendShapeCount > 0) EditorGUILayout.LabelField($"활성 블렌드 쉐입: {data.nonZeroBlendShapeCount}개"); } if (!showDetail) return; // ── 상세 목록 ── // Scale ≠ 1 본 목록 if (data.nonUnitBoneList.Count > 0) { _foldPreviewBones = EditorGUILayout.Foldout(_foldPreviewBones, $"Scale \u2260 1 본 ({data.nonUnitBoneList.Count})", true); if (_foldPreviewBones) { EditorGUI.indentLevel++; int limit = Mathf.Min(data.nonUnitBoneList.Count, 20); for (int i = 0; i < limit; i++) { var (path, scale) = data.nonUnitBoneList[i]; EditorGUILayout.LabelField(path, $"({scale.x:F3}, {scale.y:F3}, {scale.z:F3})", EditorStyles.miniLabel); } if (data.nonUnitBoneList.Count > limit) EditorGUILayout.LabelField($"... 외 {data.nonUnitBoneList.Count - limit}개", EditorStyles.miniLabel); EditorGUI.indentLevel--; } } // 변환 대상 MeshRenderer if (data.childMRList.Count > 0) { _foldPreviewMR = EditorGUILayout.Foldout(_foldPreviewMR, $"변환 대상 MeshRenderer ({data.childMRList.Count})", true); if (_foldPreviewMR) { EditorGUI.indentLevel++; foreach (var (mrPath, boneName) in data.childMRList) EditorGUILayout.LabelField(mrPath, $"\u2192 {boneName}", EditorStyles.miniLabel); EditorGUI.indentLevel--; } } // 중복 본 이름 if (data.dupBoneNameGroups.Count > 0) { _foldPreviewDupBones = EditorGUILayout.Foldout(_foldPreviewDupBones, $"중복 본 이름 ({data.dupBoneNameCount})", true); if (_foldPreviewDupBones) { EditorGUI.indentLevel++; foreach (var (name, count) in data.dupBoneNameGroups) EditorGUILayout.LabelField($"\"{name}\" \u00d7{count}", EditorStyles.miniLabel); EditorGUI.indentLevel--; } } // 중복 메쉬 이름 if (data.dupMeshNameGroups.Count > 0) { _foldPreviewDupMeshes = EditorGUILayout.Foldout(_foldPreviewDupMeshes, $"중복 메쉬 이름 ({data.dupMeshNameCount})", true); if (_foldPreviewDupMeshes) { EditorGUI.indentLevel++; foreach (var (name, count) in data.dupMeshNameGroups) EditorGUILayout.LabelField($"\"{name}\" \u00d7{count}", EditorStyles.miniLabel); EditorGUI.indentLevel--; } } } #endregion #region ── Export ── void DrawExportButton() { bool hasTarget = _batchMode ? _batchTargets.Any(t => t != null) : _target != null; bool hasOptions = _normalizeBones || _resetSMR || _convertChildMR || _dedupBoneNames || _dedupMeshNames || _preserveBlendShapes || _convertToStandard; GUI.enabled = hasTarget && hasOptions; string label = _batchMode ? $"Export ({_batchTargets.Count(t => t != null)})" : "Export FBX"; // 활성 옵션 요약 var activeOpts = new List(); if (_normalizeBones) activeOpts.Add("Scale"); if (_resetSMR) activeOpts.Add("SMR"); if (_convertChildMR) activeOpts.Add("MR"); if (_preserveBlendShapes) activeOpts.Add("BS"); if (_dedupBoneNames) activeOpts.Add("BName"); if (_dedupMeshNames) activeOpts.Add("MName"); if (_convertToStandard) activeOpts.Add("Mat"); if (_stripTextures) activeOpts.Add("NoTex"); if (activeOpts.Count > 0) label += $" [{string.Join("+", activeOpts)}]"; var btnStyle = new GUIStyle(GUI.skin.button) { fontSize = 13, fontStyle = FontStyle.Bold, fixedHeight = 34 }; if (GUILayout.Button(label, btnStyle)) { if (_batchMode) DoBatchExport(); else DoExport(); } if (!hasTarget && !hasOptions) EditorGUILayout.HelpBox("대상 오브젝트와 하나 이상의 옵션을 선택해주세요.", MessageType.Info); else if (!hasTarget) EditorGUILayout.HelpBox("대상 오브젝트를 선택해주세요.", MessageType.Info); else if (!hasOptions) EditorGUILayout.HelpBox("하나 이상의 옵션을 활성화해주세요.", MessageType.Info); GUI.enabled = true; } object BuildFbxOptions() { return FbxExportCompat.BuildOptions( _exportFormat, _include, _objectPosition, _lodExportType, _embedTextures && _exportFormat == FbxExportFormat.Binary); } void DoExport() { string path = EditorUtility.SaveFilePanel( "Normalized FBX 내보내기", Application.dataPath, _target.name, "fbx"); if (string.IsNullOrEmpty(path)) return; var result = NormalizedFbxExporter.Export( _target, path, _normalizeBones, _resetSMR, _convertChildMR, _dedupBoneNames, _dedupMeshNames, _preserveBlendShapes, _convertToStandard, _stripTextures, _verbose, BuildFbxOptions()); _history.Insert(0, result); SavePrefs(); ShowResultDialog(result); // 프리뷰 캐시 무효화 _cachedPreviewId = 0; } void DoBatchExport() { var targets = _batchTargets.Where(t => t != null).ToList(); if (targets.Count == 0) return; string folder = EditorUtility.SaveFolderPanel("내보내기 폴더 선택", Application.dataPath, ""); if (string.IsNullOrEmpty(folder)) return; var results = new List(); var usedNames = new HashSet(); var fbxOptions = BuildFbxOptions(); for (int i = 0; i < targets.Count; i++) { // 파일명 중복 방지 string baseName = targets[i].name; string fileName = baseName; int suffix = 1; while (usedNames.Contains(fileName)) fileName = $"{baseName}_{suffix++}"; usedNames.Add(fileName); string filePath = System.IO.Path.Combine(folder, fileName + ".fbx"); var result = NormalizedFbxExporter.Export( targets[i], filePath, _normalizeBones, _resetSMR, _convertChildMR, _dedupBoneNames, _dedupMeshNames, _preserveBlendShapes, _convertToStandard, _stripTextures, _verbose, fbxOptions); results.Add(result); _history.Insert(0, result); } SavePrefs(); _cachedPreviewId = 0; ShowBatchResultDialog(results); } void ShowResultDialog(ExportResult result) { if (result.Success) { EditorUtility.DisplayDialog("내보내기 완료", result.GetSummary(), "확인"); } else { EditorUtility.DisplayDialog("내보내기 실패", "내보내기에 실패했습니다. 콘솔을 확인해주세요.", "확인"); } } void ShowBatchResultDialog(List results) { int success = results.Count(r => r.Success); int fail = results.Count - success; var sb = new System.Text.StringBuilder(); sb.AppendLine($"성공: {success}개, 실패: {fail}개"); sb.AppendLine(); foreach (var r in results) { string status = r.Success ? "\u2713" : "\u2717"; sb.AppendLine($"{status} {r.SourceName} ({r.ElapsedSeconds:F2}s)"); } EditorUtility.DisplayDialog("일괄 내보내기 완료", sb.ToString(), "확인"); } #endregion #region ── History ── void DrawHistory() { using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); if (GUILayout.Button("이력 지우기", EditorStyles.miniButton, GUILayout.Width(80))) { _history.Clear(); return; } } _historyScroll = EditorGUILayout.BeginScrollView(_historyScroll, GUILayout.MaxHeight(150)); foreach (var r in _history) { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { string status = r.Success ? "\u2713" : "\u2717"; string time = r.Timestamp.ToString("HH:mm:ss"); EditorGUILayout.LabelField( $"{status} [{time}] {r.SourceName}", EditorStyles.boldLabel); if (r.Success) { var details = new List(); if (r.NormalizedBoneCount > 0) details.Add($"본 정규화 {r.NormalizedBoneCount}"); if (r.ResetSMRCount > 0) details.Add($"SMR 초기화 {r.ResetSMRCount}"); if (r.ConvertedMRCount > 0) details.Add($"MR 변환 {r.ConvertedMRCount}"); if (r.RenamedBoneCount > 0) details.Add($"본 이름 {r.RenamedBoneCount}"); if (r.RenamedMeshCount > 0) details.Add($"메쉬 이름 {r.RenamedMeshCount}"); if (r.BakedBlendShapeCount > 0) details.Add($"블렌드쉐입 {r.BakedBlendShapeCount}"); if (r.ConvertedMaterialCount > 0) details.Add($"머티리얼 변환 {r.ConvertedMaterialCount}"); if (details.Count > 0) EditorGUILayout.LabelField(string.Join(" | ", details), EditorStyles.miniLabel); string sizeStr = r.FileSizeBytes > 0 ? $"{r.FileSizeBytes / 1024f:F1}KB" : ""; EditorGUILayout.LabelField( $"{r.ElapsedSeconds:F2}s {sizeStr}", EditorStyles.miniLabel); } else { EditorGUILayout.LabelField("실패 — 콘솔 확인", EditorStyles.miniLabel); } } } EditorGUILayout.EndScrollView(); } #endregion #region ── Preview Utilities ── static void CollectDescendantsPreview(Transform root, HashSet result) { result.Add(root); foreach (Transform child in root) CollectDescendantsPreview(child, result); } static bool IsIdentityCheck(Transform t) { return t.localPosition == Vector3.zero && t.localRotation == Quaternion.identity && Mathf.Approximately(t.localScale.x, 1f) && Mathf.Approximately(t.localScale.y, 1f) && Mathf.Approximately(t.localScale.z, 1f); } static string GetPreviewPath(Transform t, Transform root) { var parts = new List(); while (t != null && t != root) { parts.Add(t.name); t = t.parent; } parts.Reverse(); return string.Join("/", parts); } #endregion } }