Streamingle_URP/Assets/Scripts/Editor/Utilities/NormalizedFbxExporter.cs
user 4a49ecd772 Refactor: 배경/프랍 브라우저 IMGUI→UI Toolkit 전환 + USS 리디자인
- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바)
- excludeFromWeb 상태 새로고침 시 보존 수정
- 삭제된 배경 리소스 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:55:48 +09:00

2365 lines
97 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 호환)
// ════════════════════════════════════════════════════════════════
/// <summary>FBX 출력 포맷. 값은 Unity FBX Exporter의 ExportFormat과 동일.</summary>
public enum FbxExportFormat { ASCII = 0, Binary = 1 }
/// <summary>FBX 포함 내용. 값은 Unity FBX Exporter의 Include와 동일.</summary>
public enum FbxInclude { Model = 0, Anim = 1, ModelAndAnim = 2 }
/// <summary>FBX 오브젝트 위치. 값은 Unity FBX Exporter의 ObjectPosition과 동일.</summary>
public enum FbxObjectPosition { LocalCentered = 0, WorldAbsolute = 1, Reset = 2 }
/// <summary>FBX LOD 내보내기. 값은 Unity FBX Exporter의 LODExportType과 동일.</summary>
public enum FbxLODExportType { All = 0, Highest = 1, Lowest = 2 }
// ════════════════════════════════════════════════════════════════
// FBX Export Compat (v4/v5 reflection bridge)
// ════════════════════════════════════════════════════════════════
/// <summary>
/// FBX Exporter v4(internal API)와 v5(public ExportModelOptions)를
/// 모두 지원하기 위한 reflection 기반 호환 레이어.
/// </summary>
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);
}
/// <summary>v5+ ExportModelOptions를 지원하는지 여부.</summary>
public static bool SupportsOptions => _supportsOptions;
/// <summary>초기화 로그 (에디터 UI 표시용).</summary>
public static string InitLog => _initLog;
/// <summary>
/// ExportModelOptions 인스턴스를 reflection으로 생성하고 프로퍼티를 설정합니다.
/// v5+에서만 동작하며, v4에서는 null을 반환합니다.
/// </summary>
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;
}
/// <summary>
/// v5+: ExportObject(path, obj, options)
/// v4: ExportObject(path, obj)
/// </summary>
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
// ════════════════════════════════════════════════════════════════
/// <summary>
/// Export 결과를 담는 클래스. 처리 내역과 통계를 포함합니다.
/// </summary>
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
// ════════════════════════════════════════════════════════════════
/// <summary>
/// FBX Export 래퍼: Bone Scale → 1, SMR Transform → Identity 정규화 후 Export.
/// 클론을 생성하여 원본을 수정하지 않으며, Bindpose와 Vertex를 자동 보상 재계산합니다.
/// </summary>
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<string, float[]> 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<Mesh>();
var createdMaterials = new List<Material>();
var savedBindposes = new Dictionary<Mesh, Matrix4x4[]>();
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<Renderer>(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<Mesh> clonedMeshes,
Dictionary<Mesh, Matrix4x4[]> 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<SkinnedMeshRenderer>(true);
if (smrs.Length == 0) return;
// ── Phase 1: 본 스케일 정규화 ──
Dictionary<Transform, Matrix4x4> originalMatrices = null;
if (normalizeBoneScales)
{
EditorUtility.DisplayProgressBar("Normalized FBX Export",
"Phase 1: 본 스케일 정규화...", 0.3f);
var skeleton = CollectSkeletonTransforms(smrs, root.transform);
originalMatrices = new Dictionary<Transform, Matrix4x4>();
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
/// <summary>
/// 본 하위에 있는 MeshRenderer를 찾아 SkinnedMeshRenderer로 변환하고 루트로 이동합니다.
/// 부모 본에 weight 1.0을 할당하여 본을 따라 움직이도록 합니다.
/// </summary>
/// <returns>변환된 MeshRenderer 개수</returns>
static int ConvertChildMeshRenderers(GameObject root, List<Mesh> clonedMeshes, bool verbose)
{
// 스켈레톤 Transform 수집
var smrs = root.GetComponentsInChildren<SkinnedMeshRenderer>(true);
var boneSet = new HashSet<Transform>();
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<MeshRenderer>(true))
{
if (mr.GetComponent<MeshFilter>() != 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<MeshRenderer>(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<MeshFilter>();
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<SkinnedMeshRenderer>();
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<Transform> 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<Transform> CollectSkeletonTransforms(SkinnedMeshRenderer[] smrs, Transform cloneRoot)
{
var result = new HashSet<Transform>();
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<Transform> result)
{
result.Add(root);
foreach (Transform child in root)
CollectDescendants(child, result);
}
static void NormalizeScales(HashSet<Transform> 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<Transform, Matrix4x4> 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
/// <summary>
/// SMR의 로컬 Transform을 메시에 베이크하고 Transform을 Identity로 리셋합니다.
/// Unity 2021 호환: ClearBlendShapes를 사용하지 않고 새 Mesh를 생성하여
/// AddBlendShapeFrame의 내부 버퍼 동기화 문제를 회피합니다.
/// </summary>
/// <returns>베이크된 새 Mesh (호출자가 clonedMeshes 교체 필요)</returns>
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" };
/// <summary>
/// 클론의 모든 Renderer 머티리얼을 Standard 셰이더로 변환합니다.
/// 원본 머티리얼의 베이스맵(텍스처), 컬러, 노멀맵을 복사합니다.
/// 원본 머티리얼은 변경하지 않습니다.
/// </summary>
static int ConvertMaterialsToStandard(GameObject root, List<Material> 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<Renderer>(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;
}
/// <summary>
/// 소스 GameObject의 모든 SMR에서 블렌드쉐입 가중치를 수집합니다.
/// key: SMR의 GameObject 이름, value: 각 블렌드쉐입의 가중치 배열
/// </summary>
static Dictionary<string, float[]> CollectBlendShapeWeights(GameObject source)
{
var result = new Dictionary<string, float[]>();
foreach (var smr in source.GetComponentsInChildren<SkinnedMeshRenderer>(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;
}
/// <summary>
/// 내보낸 FBX 파일을 열어 각 BlendShapeChannel의 DeformPercent에
/// 수집된 가중치 값을 기록합니다.
/// 메시 버텍스는 변경하지 않으므로 2x 문제가 발생하지 않습니다.
/// </summary>
/// <returns>기록된 블렌드쉐입 채널 수</returns>
static int SetBlendShapeWeightsInFbx(string fbxPath, Dictionary<string, float[]> 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<string, float[]> 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<SkinnedMeshRenderer>(true);
var boneSet = new HashSet<Transform>();
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<Transform>();
foreach (var smr in smrs)
meshSet.Add(smr.transform);
foreach (var mr in root.GetComponentsInChildren<MeshRenderer>(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<Transform> targets, string category, bool verbose)
{
var nameGroups = new Dictionary<string, List<Transform>>();
foreach (var t in targets)
{
if (t == root) continue;
if (!nameGroups.TryGetValue(t.name, out var list))
{
list = new List<Transform>();
nameGroups[t.name] = list;
}
list.Add(t);
}
var usedNames = new HashSet<string>();
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
/// <summary>
/// Mesh를 수동 딥카피합니다. Object.Instantiate(Mesh)는 Unity 2021에서
/// 블렌드쉐이프 데이터를 누락하거나, 내부 포맷 상태가 꼬여
/// ClearBlendShapes/AddBlendShapeFrame이 실패할 수 있습니다.
/// new Mesh()로 생성하면 깨끗한 상태에서 모든 데이터가 정확히 복사됩니다.
/// </summary>
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;
}
/// <summary>스케일에 음수 성분이 있는지 확인합니다.</summary>
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<string>();
while (t != null && t != root)
{
parts.Add(t.name);
t = t.parent;
}
parts.Reverse();
return string.Join("/", parts);
}
/// <summary>
/// 처리 후 클론 상태를 검증하고 이상이 있으면 경고 로그를 출력합니다.
/// </summary>
static void ValidateCloneState(GameObject clone)
{
var smrs = clone.GetComponentsInChildren<SkinnedMeshRenderer>(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<ExportPreset> presets = new List<ExportPreset>();
}
// ════════════════════════════════════════════════════════════════
// 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<NormalizedFbxExporterWindow>("Normalized FBX Export");
window.minSize = new Vector2(400, 500);
}
// ── EditorPrefs Keys ──
const string P = "NormFbxExp_";
// ── Settings ──
bool _batchMode;
GameObject _target;
List<GameObject> _batchTargets = new List<GameObject>();
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<ExportResult> _history = new List<ExportResult>();
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<ExportPresetList>(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<SkinnedMeshRenderer>(true);
_preview.smrCount = smrs.Length;
var boneSet = new HashSet<Transform>();
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<MeshRenderer>(true))
{
if (boneSet.Contains(mr.transform)) continue;
var p = mr.transform.parent;
while (p != null)
{
if (boneSet.Contains(p))
{
if (mr.GetComponent<MeshFilter>()?.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<Transform>();
foreach (var smr in smrs) meshSet.Add(smr.transform);
foreach (var mr in target.GetComponentsInChildren<MeshRenderer>(true)) meshSet.Add(mr.transform);
CountDupNames(meshSet, target.transform, out _preview.dupMeshNameCount, _preview.dupMeshNameGroups);
}
static void CountDupNames(HashSet<Transform> set, Transform root,
out int totalDups, List<(string name, int count)> groups)
{
var nameCount = new Dictionary<string, int>();
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<string>();
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<ExportResult>();
var usedNames = new HashSet<string>();
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<ExportResult> 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<string>();
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<Transform> 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<string>();
while (t != null && t != root)
{
parts.Add(t.name);
t = t.parent;
}
parts.Reverse();
return string.Join("/", parts);
}
#endregion
}
}