- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바) - excludeFromWeb 상태 새로고침 시 보존 수정 - 삭제된 배경 리소스 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2365 lines
97 KiB
C#
2365 lines
97 KiB
C#
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
|
||
}
|
||
}
|