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
}
}