831 lines
37 KiB
C#
831 lines
37 KiB
C#
#nullable enable
|
||
using GLTFast;
|
||
using GLTFast.Export;
|
||
using GLTFast.Logging;
|
||
using GLTFast.Schema;
|
||
using Newtonsoft.Json;
|
||
using SimpleFileBrowser;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
using UnityEngine;
|
||
using GltfAccessor = GLTFast.Schema.Accessor;
|
||
using GltfAttributes = GLTFast.Schema.Attributes;
|
||
using GltfBufferView = GLTFast.Schema.BufferView;
|
||
using GltfImage = GLTFast.Schema.Image;
|
||
using GltfMaterial = GLTFast.Schema.Material;
|
||
//
|
||
// Alias GLTFast.Schema types to avoid ambiguity with UnityEngine types
|
||
//
|
||
using GltfMesh = GLTFast.Schema.Mesh;
|
||
// removed GltfGltf alias because GLTFast v6.6.0 doesn't provide a root 'Gltf' POCO type
|
||
using GltfPbr = GLTFast.Schema.PbrMetallicRoughness;
|
||
using GltfPrimitive = GLTFast.Schema.MeshPrimitive;
|
||
using GltfTexture = GLTFast.Schema.Texture;
|
||
using GltfTextureInfo = GLTFast.Schema.TextureInfo;
|
||
|
||
namespace UVC.GLTF
|
||
{
|
||
|
||
/// <summary>
|
||
/// Unity GameObject를 GLB(Binary glTF) 형식으로 내보내는 기능을 제공하는 클래스입니다.
|
||
/// GLTFast의 스키마를 사용하여 구조를 생성하고 Newtonsoft.Json으로 직렬화합니다.
|
||
/// </summary>
|
||
public static class GLTFExporter
|
||
{
|
||
|
||
|
||
public static async Task ExportAsync(GameObject node, string filePath)
|
||
{
|
||
var logger = new CollectingLogger();
|
||
|
||
// ExportSettings는 일반적인 내보내기 설정을 제공합니다.
|
||
var exportSettings = new ExportSettings
|
||
{
|
||
Format = GltfFormat.Binary,
|
||
FileConflictResolution = FileConflictResolution.Overwrite,
|
||
|
||
// 카메라 또는 애니메이션을 제외한 모든 것을 내보냅니다.
|
||
ComponentMask = ~(ComponentType.Camera | ComponentType.Animation),
|
||
|
||
// 조명 강도를 높입니다.
|
||
//LightIntensityFactor = 100f,
|
||
|
||
// 메시 버텍스 속성 색상과 텍스처 좌표(채널 1~8)가 사용되거나 참조되지 않더라도 항상
|
||
// 내보내지도록 합니다.
|
||
PreservedVertexAttributes = VertexAttributeUsage.AllTexCoords | VertexAttributeUsage.Color,
|
||
};
|
||
|
||
// GameObjectExportSettings는 게임 오브젝트/컴포넌트 기반 계층 구조에 대한 설정을 제공합니다.
|
||
var gameObjectExportSettings = new GameObjectExportSettings
|
||
{
|
||
// 내보내기에 비활성 게임 오브젝트 포함
|
||
OnlyActiveInHierarchy = false,
|
||
|
||
// 비활성화된 컴포넌트도 내보냅니다.
|
||
DisabledComponents = true,
|
||
|
||
// 특정 레이어의 게임 오브젝트만 내보냅니다.
|
||
//LayerMask = LayerMask.GetMask("Default", "MyCustomLayer"),
|
||
};
|
||
|
||
// GameObjectExport를 사용하면 게임 오브젝트 계층 구조에서 glTF를 생성할 수 있습니다.
|
||
var export = new GameObjectExport(exportSettings, gameObjectExportSettings, logger: logger);
|
||
|
||
// 내보낼 게임 오브젝트를 수집하는 예(재귀적으로)
|
||
var rootLevelNodes = new GameObject[]
|
||
{
|
||
node
|
||
};// GameObject.FindGameObjectsWithTag("ExportMe");
|
||
|
||
// 장면 추가
|
||
export.AddScene(rootLevelNodes, "glTF scene");
|
||
|
||
// 비동기 glTF 내보내기
|
||
var success = await export.SaveToFileAndDispose(filePath);
|
||
|
||
if (!success)
|
||
{
|
||
Debug.LogError("glTF를 내보내는 중 문제가 발생했습니다.");
|
||
|
||
// 모든 내보내기 메시지를 로깅합니다.
|
||
logger.LogAll();
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 지정된 GameObject 노드를 GLB 포맷의 바이트 배열로 내보냅니다.
|
||
/// 자식 객체들의 MeshRenderer와 SkinnedMeshRenderer를 포함하여 단일 노드로 병합 export를 시도합니다.
|
||
/// </summary>
|
||
/// <param name="node">내보낼 대상 GameObject</param>
|
||
/// <returns>GLB 파일 포맷의 바이트 배열. 실패하거나 내보낼 메쉬가 없으면 null을 반환합니다.</returns>
|
||
/// <summary>
|
||
/// 지정된 GameObject 노드를 GLB 포맷의 바이트 배열로 내보냅니다.
|
||
/// 자식 객체들의 MeshRenderer와 SkinnedMeshRenderer를 포함하여 단일 노드로 병합 export를 시도합니다.
|
||
/// </summary>
|
||
/// <param name="node">내보낼 대상 GameObject</param>
|
||
/// <returns>GLB 파일 포맷의 바이트 배열. 실패하거나 내보낼 메쉬가 없으면 null을 반환합니다.</returns>
|
||
public static byte[]? ExportNodeToGlbBytes(GameObject node)
|
||
{
|
||
if (node == null) return null;
|
||
|
||
// 1. 렌더러 수집 (MeshRenderer, SkinnedMeshRenderer)
|
||
var renderers = node.GetComponentsInChildren<Renderer>(true);
|
||
var entries = new List<MeshEntry>();
|
||
foreach (var r in renderers)
|
||
{
|
||
if (r == null || !r.enabled) continue;
|
||
UnityEngine.Mesh? mesh = null;
|
||
if (r is MeshRenderer)
|
||
{
|
||
var mf = r.GetComponent<MeshFilter>();
|
||
if (mf != null && mf.sharedMesh != null) mesh = mf.sharedMesh;
|
||
}
|
||
else if (r is SkinnedMeshRenderer smr)
|
||
{
|
||
var baked = new UnityEngine.Mesh();
|
||
try
|
||
{
|
||
smr.BakeMesh(baked);
|
||
mesh = baked;
|
||
}
|
||
catch
|
||
{
|
||
UnityEngine.Object.Destroy(baked);
|
||
mesh = null;
|
||
}
|
||
}
|
||
if (mesh == null) continue;
|
||
entries.Add(new MeshEntry { mesh = mesh, transform = r.transform, renderer = r });
|
||
}
|
||
if (entries.Count == 0)
|
||
{
|
||
Debug.LogWarning("GLTFExporter: no meshes to export.");
|
||
return null;
|
||
}
|
||
|
||
// GLTF 구조체 준비 (binary buffer + 메타데이터 컬렉션)
|
||
var bin = new List<byte>();
|
||
var bufferViews = new List<GltfBufferView>();
|
||
var accessors = new List<GltfAccessor>();
|
||
var gltfMeshes = new List<GltfMesh>();
|
||
var materials = new List<GltfMaterial>();
|
||
var images = new List<GltfImage>();
|
||
var textures = new List<GltfTexture>();
|
||
|
||
// 병렬 메타: 각 bufferView의 target, 각 accessor의 type (JSON 생성 시 사용)
|
||
var bufferViewTargets = new List<int>();
|
||
var accessorTypes = new List<string>();
|
||
|
||
var matToIndex = new Dictionary<UnityEngine.Material, int>();
|
||
var texToImageIndex = new Dictionary<Texture2D, int>();
|
||
|
||
// 루트 노드 기준으로 로컬 좌표계 변환 매트릭스
|
||
var worldToLocal = node.transform.worldToLocalMatrix;
|
||
|
||
// map transform -> mesh index (entries order -> gltfMeshes order)
|
||
var transformToMeshIndex = new Dictionary<Transform, int>();
|
||
|
||
foreach (var e in entries)
|
||
{
|
||
var mesh = e.mesh;
|
||
var t = e.transform;
|
||
int vcount = mesh.vertexCount;
|
||
var positions = mesh.vertices;
|
||
var normals = mesh.normals;
|
||
var uvs = mesh.uv;
|
||
|
||
// 2. POSITION 데이터 처리
|
||
int posBufferViewIndex;
|
||
int posAccessorIndex;
|
||
{
|
||
AlignTo4AppendPadding(bin);
|
||
int start = bin.Count;
|
||
var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
|
||
var max = new Vector3(float.MinValue, float.MinValue, float.MinValue);
|
||
for (int i = 0; i < vcount; i++)
|
||
{
|
||
var wp = t.TransformPoint(positions[i]);
|
||
var lp = worldToLocal.MultiplyPoint3x4(wp);
|
||
AppendFloat(bin, lp.x); AppendFloat(bin, lp.y); AppendFloat(bin, lp.z);
|
||
min = Vector3.Min(min, lp); max = Vector3.Max(max, lp);
|
||
}
|
||
int length = bin.Count - start;
|
||
posBufferViewIndex = bufferViews.Count;
|
||
bufferViews.Add(new GltfBufferView { buffer = 0, byteOffset = start, byteLength = length });
|
||
// positions are vertex data -> ARRAY_BUFFER
|
||
bufferViewTargets.Add(34962);
|
||
posAccessorIndex = accessors.Count;
|
||
var accessor = new GltfAccessor
|
||
{
|
||
bufferView = posBufferViewIndex,
|
||
byteOffset = 0,
|
||
componentType = GltfComponentType.Float,
|
||
count = vcount,
|
||
min = new[] { min.x, min.y, min.z },
|
||
max = new[] { max.x, max.y, max.z }
|
||
};
|
||
accessor.SetAttributeType(GltfAccessorAttributeType.VEC3);
|
||
accessors.Add(accessor);
|
||
accessorTypes.Add("VEC3");
|
||
}
|
||
|
||
// 3. NORMAL 데이터 처리
|
||
int normAccessorIndex = -1;
|
||
if (normals != null && normals.Length == vcount)
|
||
{
|
||
AlignTo4AppendPadding(bin);
|
||
int start = bin.Count;
|
||
for (int i = 0; i < vcount; i++)
|
||
{
|
||
var nw = t.TransformDirection(normals[i]).normalized;
|
||
var nl = worldToLocal.MultiplyVector(nw).normalized;
|
||
AppendFloat(bin, nl.x); AppendFloat(bin, nl.y); AppendFloat(bin, nl.z);
|
||
}
|
||
int length = bin.Count - start;
|
||
int bv = bufferViews.Count;
|
||
bufferViews.Add(new GltfBufferView { buffer = 0, byteOffset = start, byteLength = length });
|
||
// normals are vertex data -> ARRAY_BUFFER
|
||
bufferViewTargets.Add(34962);
|
||
normAccessorIndex = accessors.Count;
|
||
var accessor = new GltfAccessor
|
||
{
|
||
bufferView = bv,
|
||
byteOffset = 0,
|
||
componentType = GltfComponentType.Float,
|
||
count = vcount,
|
||
};
|
||
accessor.SetAttributeType(GltfAccessorAttributeType.VEC3);
|
||
accessors.Add(accessor);
|
||
accessorTypes.Add("VEC3");
|
||
}
|
||
|
||
// 4. TEXCOORD_0 (UV) 데이터 처리
|
||
int uvAccessorIndex = -1;
|
||
if (uvs != null && uvs.Length == vcount)
|
||
{
|
||
AlignTo4AppendPadding(bin);
|
||
int start = bin.Count;
|
||
for (int i = 0; i < vcount; i++)
|
||
{
|
||
AppendFloat(bin, uvs[i].x); AppendFloat(bin, uvs[i].y);
|
||
}
|
||
int length = bin.Count - start;
|
||
int bv = bufferViews.Count;
|
||
bufferViews.Add(new GltfBufferView { buffer = 0, byteOffset = start, byteLength = length });
|
||
// uvs are vertex data -> ARRAY_BUFFER
|
||
bufferViewTargets.Add(34962);
|
||
uvAccessorIndex = accessors.Count;
|
||
var accessor = new GltfAccessor
|
||
{
|
||
bufferView = bv,
|
||
byteOffset = 0,
|
||
componentType = GltfComponentType.Float,
|
||
count = vcount,
|
||
};
|
||
accessor.SetAttributeType(GltfAccessorAttributeType.VEC2);
|
||
accessors.Add(accessor);
|
||
accessorTypes.Add("VEC2");
|
||
}
|
||
|
||
// 5. Submesh(Primitives) 처리
|
||
var primitives = new List<GltfPrimitive>();
|
||
for (int s = 0; s < mesh.subMeshCount; s++)
|
||
{
|
||
var indices = mesh.GetTriangles(s);
|
||
if (indices == null || indices.Length == 0) continue;
|
||
|
||
bool useUInt = indices.Length > 65535;
|
||
AlignTo4AppendPadding(bin);
|
||
int start = bin.Count;
|
||
if (useUInt)
|
||
{
|
||
for (int i = 0; i < indices.Length; i++) AppendUInt(bin, (uint)indices[i]);
|
||
}
|
||
else
|
||
{
|
||
for (int i = 0; i < indices.Length; i++) AppendUShort(bin, (ushort)indices[i]);
|
||
}
|
||
int length = bin.Count - start;
|
||
int idxBV = bufferViews.Count;
|
||
bufferViews.Add(new GltfBufferView { buffer = 0, byteOffset = start, byteLength = length });
|
||
// indices are index buffer -> ELEMENT_ARRAY_BUFFER
|
||
bufferViewTargets.Add(34963);
|
||
int idxAccessor = accessors.Count;
|
||
var accessor = new GltfAccessor
|
||
{
|
||
bufferView = idxBV,
|
||
byteOffset = 0,
|
||
componentType = useUInt ? GltfComponentType.UnsignedInt : GltfComponentType.UnsignedShort,
|
||
count = indices.Length,
|
||
};
|
||
accessor.SetAttributeType(GltfAccessorAttributeType.SCALAR);
|
||
accessors.Add(accessor);
|
||
accessorTypes.Add("SCALAR");
|
||
|
||
// 6. Material 및 Texture 처리
|
||
int matIndex = -1;
|
||
UnityEngine.Material unityMat = null;
|
||
if (e.renderer != null)
|
||
{
|
||
var shared = e.renderer.sharedMaterials;
|
||
if (shared != null && s < shared.Length) unityMat = shared[s];
|
||
}
|
||
if (unityMat != null)
|
||
{
|
||
if (!matToIndex.TryGetValue(unityMat, out matIndex))
|
||
{
|
||
matIndex = materials.Count;
|
||
matToIndex[unityMat] = matIndex;
|
||
|
||
var pbr = new GltfPbr();
|
||
Color bc;
|
||
if (TryGetColor(unityMat, out bc))
|
||
{
|
||
pbr.baseColorFactor = new[] { bc.r, bc.g, bc.b, bc.a };
|
||
}
|
||
|
||
if (unityMat.mainTexture != null && unityMat.mainTexture is Texture2D t2d)
|
||
{
|
||
if (!texToImageIndex.TryGetValue(t2d, out var imgIdx))
|
||
{
|
||
try
|
||
{
|
||
var png = t2d.EncodeToPNG();
|
||
if (png != null && png.Length > 0)
|
||
{
|
||
AlignTo4AppendPadding(bin);
|
||
int imgStart = bin.Count;
|
||
bin.AddRange(png);
|
||
int imgLen = bin.Count - imgStart;
|
||
int imgBV = bufferViews.Count;
|
||
bufferViews.Add(new GltfBufferView { buffer = 0, byteOffset = imgStart, byteLength = imgLen });
|
||
// image bufferView – no target
|
||
bufferViewTargets.Add(0);
|
||
var img = new GltfImage { mimeType = "image/png", bufferView = imgBV };
|
||
imgIdx = images.Count;
|
||
images.Add(img);
|
||
texToImageIndex[t2d] = imgIdx;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
Debug.LogWarning($"GLTFExporter: failed to encode texture '{t2d.name}' to PNG.");
|
||
}
|
||
}
|
||
if (texToImageIndex.TryGetValue(t2d, out var imageIndex))
|
||
{
|
||
int texIdx = textures.Count;
|
||
textures.Add(new GltfTexture { source = imageIndex });
|
||
pbr.baseColorTexture = new GltfTextureInfo { index = texIdx };
|
||
}
|
||
}
|
||
|
||
materials.Add(new GltfMaterial { pbrMetallicRoughness = pbr });
|
||
}
|
||
}
|
||
|
||
var prim = new GltfPrimitive
|
||
{
|
||
attributes = new GltfAttributes { POSITION = posAccessorIndex },
|
||
indices = idxAccessor,
|
||
mode = DrawMode.Triangles
|
||
};
|
||
if (normAccessorIndex >= 0) prim.attributes.NORMAL = normAccessorIndex;
|
||
if (uvAccessorIndex >= 0) prim.attributes.TEXCOORD_0 = uvAccessorIndex;
|
||
if (matIndex >= 0) prim.material = matIndex;
|
||
primitives.Add(prim);
|
||
}
|
||
|
||
// add mesh and map transform -> mesh index
|
||
int meshIndex = gltfMeshes.Count;
|
||
gltfMeshes.Add(new GltfMesh { primitives = primitives.ToArray(), name = mesh.name ?? "mesh" });
|
||
transformToMeshIndex[e.transform] = meshIndex;
|
||
}
|
||
|
||
// Ensure accessorTypes fallback (safety)
|
||
for (int i = 0; i < accessors.Count; i++)
|
||
{
|
||
if (i >= accessorTypes.Count || string.IsNullOrEmpty(accessorTypes[i]))
|
||
{
|
||
accessorTypes.Add(InferAccessorType(accessors[i]));
|
||
}
|
||
}
|
||
|
||
// Build plain glTF root using camelCase keys and only required fields
|
||
// Create one node per exported mesh (preserves multiple child meshes)
|
||
var nodesList = new List<Dictionary<string, object>>();
|
||
for (int i = 0; i < gltfMeshes.Count; i++)
|
||
{
|
||
var entryTransform = entries.Count > i ? entries[i].transform : null;
|
||
var name = entryTransform != null ? entryTransform.name : $"mesh_{i}";
|
||
nodesList.Add(new Dictionary<string, object> { ["mesh"] = i, ["name"] = name });
|
||
}
|
||
|
||
// scene references all nodes
|
||
var sceneNodes = new int[nodesList.Count];
|
||
for (int i = 0; i < nodesList.Count; i++) sceneNodes[i] = i;
|
||
var scenes = new List<Dictionary<string, object>> { new Dictionary<string, object> { ["nodes"] = sceneNodes } };
|
||
var buffersList = new List<Dictionary<string, object>> { new Dictionary<string, object> { ["byteLength"] = (uint)bin.Count } };
|
||
|
||
var root = new Dictionary<string, object>
|
||
{
|
||
["asset"] = new Dictionary<string, object> { ["generator"] = "GLTFExporter", ["version"] = "2.0" },
|
||
["scenes"] = scenes.ToArray(),
|
||
["scene"] = 0,
|
||
["nodes"] = nodesList.ToArray(),
|
||
// meshes will be filled below as plain objects
|
||
["buffers"] = buffersList.ToArray()
|
||
};
|
||
|
||
// bufferViews -> plain objects (use bufferViewTargets)
|
||
if (bufferViews.Count > 0)
|
||
{
|
||
var bvObjs = new List<Dictionary<string, object>>();
|
||
for (int i = 0; i < bufferViews.Count; i++)
|
||
{
|
||
var bv = bufferViews[i];
|
||
var d = new Dictionary<string, object>
|
||
{
|
||
["buffer"] = bv.buffer,
|
||
["byteOffset"] = bv.byteOffset,
|
||
["byteLength"] = bv.byteLength
|
||
};
|
||
if (i < bufferViewTargets.Count && bufferViewTargets[i] != 0)
|
||
{
|
||
d["target"] = bufferViewTargets[i];
|
||
}
|
||
bvObjs.Add(d);
|
||
}
|
||
root["bufferViews"] = bvObjs.ToArray();
|
||
}
|
||
|
||
// accessors -> plain objects (use accessorTypes)
|
||
if (accessors.Count > 0)
|
||
{
|
||
var aObjs = new List<Dictionary<string, object>>();
|
||
for (int i = 0; i < accessors.Count; i++)
|
||
{
|
||
var a = accessors[i];
|
||
var d = new Dictionary<string, object>
|
||
{
|
||
["bufferView"] = a.bufferView,
|
||
["byteOffset"] = a.byteOffset,
|
||
["componentType"] = (int)a.componentType,
|
||
["count"] = a.count
|
||
};
|
||
if (a.min != null) d["min"] = a.min;
|
||
if (a.max != null) d["max"] = a.max;
|
||
if (i < accessorTypes.Count && !string.IsNullOrEmpty(accessorTypes[i]))
|
||
{
|
||
d["type"] = accessorTypes[i];
|
||
}
|
||
aObjs.Add(d);
|
||
}
|
||
root["accessors"] = aObjs.ToArray();
|
||
}
|
||
|
||
// images -> plain objects
|
||
if (images.Count > 0)
|
||
{
|
||
var imgs = new List<Dictionary<string, object>>();
|
||
foreach (var img in images)
|
||
{
|
||
var d = new Dictionary<string, object>();
|
||
if (!string.IsNullOrEmpty(img.mimeType)) d["mimeType"] = img.mimeType;
|
||
if (img.bufferView >= 0) d["bufferView"] = img.bufferView;
|
||
if (!string.IsNullOrEmpty(img.uri)) d["uri"] = img.uri;
|
||
imgs.Add(d);
|
||
}
|
||
root["images"] = imgs.ToArray();
|
||
}
|
||
|
||
// textures -> plain objects
|
||
if (textures.Count > 0)
|
||
{
|
||
var texs = new List<Dictionary<string, object>>();
|
||
foreach (var tex in textures)
|
||
{
|
||
var d = new Dictionary<string, object>();
|
||
if (tex.source >= 0) d["source"] = tex.source;
|
||
texs.Add(d);
|
||
}
|
||
root["textures"] = texs.ToArray();
|
||
}
|
||
|
||
// materials -> plain objects (only include glTF fields)
|
||
if (materials.Count > 0)
|
||
{
|
||
var mats = new List<Dictionary<string, object>>();
|
||
foreach (var m in materials)
|
||
{
|
||
var md = new Dictionary<string, object>();
|
||
if (m.pbrMetallicRoughness != null)
|
||
{
|
||
var p = m.pbrMetallicRoughness;
|
||
var pd = new Dictionary<string, object>();
|
||
if (p.baseColorFactor != null) pd["baseColorFactor"] = p.baseColorFactor;
|
||
if (p.baseColorTexture != null && p.baseColorTexture.index >= 0)
|
||
{
|
||
pd["baseColorTexture"] = new Dictionary<string, object> { ["index"] = p.baseColorTexture.index };
|
||
}
|
||
if (pd.Count > 0) md["pbrMetallicRoughness"] = pd;
|
||
}
|
||
if (!string.IsNullOrEmpty(m.name)) md["name"] = m.name;
|
||
// alphaCutoff is only valid when alphaMode == "MASK"
|
||
if (!string.IsNullOrEmpty(m.alphaMode)) md["alphaMode"] = m.alphaMode;
|
||
if (m.alphaMode == "MASK" && m.alphaCutoff > 0f) md["alphaCutoff"] = m.alphaCutoff;
|
||
mats.Add(md);
|
||
}
|
||
root["materials"] = mats.ToArray();
|
||
}
|
||
|
||
// meshes -> plain objects with primitives (only include actual attribute indices)
|
||
if (gltfMeshes.Count > 0)
|
||
{
|
||
var meshObjs = new List<Dictionary<string, object>>();
|
||
foreach (var gm in gltfMeshes)
|
||
{
|
||
var mObj = new Dictionary<string, object>();
|
||
if (!string.IsNullOrEmpty(gm.name)) mObj["name"] = gm.name;
|
||
var primObjs = new List<Dictionary<string, object>>();
|
||
if (gm.primitives != null)
|
||
{
|
||
foreach (var gp in gm.primitives)
|
||
{
|
||
var pObj = new Dictionary<string, object>();
|
||
// attributes
|
||
var attrs = new Dictionary<string, object>();
|
||
if (gp.attributes != null)
|
||
{
|
||
if (gp.attributes.POSITION >= 0) attrs["POSITION"] = gp.attributes.POSITION;
|
||
if (gp.attributes.NORMAL >= 0) attrs["NORMAL"] = gp.attributes.NORMAL;
|
||
if (gp.attributes.TEXCOORD_0 >= 0) attrs["TEXCOORD_0"] = gp.attributes.TEXCOORD_0;
|
||
if (gp.attributes.TANGENT >= 0) attrs["TANGENT"] = gp.attributes.TANGENT;
|
||
// add other TEXCOORD_n / COLOR_0 / JOINTS_0 / WEIGHTS_0 only if >=0
|
||
// check up to 8 texcoords
|
||
if (gp.attributes.TEXCOORD_1 >= 0) attrs["TEXCOORD_1"] = gp.attributes.TEXCOORD_1;
|
||
if (gp.attributes.TEXCOORD_2 >= 0) attrs["TEXCOORD_2"] = gp.attributes.TEXCOORD_2;
|
||
if (gp.attributes.TEXCOORD_3 >= 0) attrs["TEXCOORD_3"] = gp.attributes.TEXCOORD_3;
|
||
if (gp.attributes.TEXCOORD_4 >= 0) attrs["TEXCOORD_4"] = gp.attributes.TEXCOORD_4;
|
||
if (gp.attributes.TEXCOORD_5 >= 0) attrs["TEXCOORD_5"] = gp.attributes.TEXCOORD_5;
|
||
if (gp.attributes.TEXCOORD_6 >= 0) attrs["TEXCOORD_6"] = gp.attributes.TEXCOORD_6;
|
||
if (gp.attributes.TEXCOORD_7 >= 0) attrs["TEXCOORD_7"] = gp.attributes.TEXCOORD_7;
|
||
if (gp.attributes.COLOR_0 >= 0) attrs["COLOR_0"] = gp.attributes.COLOR_0;
|
||
if (gp.attributes.JOINTS_0 >= 0) attrs["JOINTS_0"] = gp.attributes.JOINTS_0;
|
||
if (gp.attributes.WEIGHTS_0 >= 0) attrs["WEIGHTS_0"] = gp.attributes.WEIGHTS_0;
|
||
}
|
||
pObj["attributes"] = attrs;
|
||
if (gp.indices >= 0) pObj["indices"] = gp.indices;
|
||
if (gp.material >= 0) pObj["material"] = gp.material;
|
||
// include mode only if not triangles (4) -- optionally include always
|
||
pObj["mode"] = (int)gp.mode;
|
||
primObjs.Add(pObj);
|
||
}
|
||
}
|
||
mObj["primitives"] = primObjs.ToArray();
|
||
meshObjs.Add(mObj);
|
||
}
|
||
root["meshes"] = meshObjs.ToArray();
|
||
}
|
||
|
||
var jsonSettings = new JsonSerializerSettings
|
||
{
|
||
NullValueHandling = NullValueHandling.Ignore,
|
||
Formatting = Formatting.None,
|
||
Culture = CultureInfo.InvariantCulture,
|
||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||
};
|
||
jsonSettings.Converters.Add(new ColorConverter());
|
||
string json = JsonConvert.SerializeObject(root, jsonSettings);
|
||
|
||
// 9. GLB 헤더 및 청크 작성
|
||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||
int jsonPad = PadTo4(jsonBytes.Length);
|
||
var jsonPadded = new byte[jsonBytes.Length + jsonPad];
|
||
Array.Copy(jsonBytes, jsonPadded, jsonBytes.Length);
|
||
for (int i = jsonBytes.Length; i < jsonPadded.Length; i++) jsonPadded[i] = 0x20;
|
||
|
||
int binPad = PadTo4(bin.Count);
|
||
var binPadded = new byte[bin.Count + binPad];
|
||
Array.Copy(bin.ToArray(), binPadded, bin.Count);
|
||
|
||
using (var ms = new MemoryStream())
|
||
using (var bw = new BinaryWriter(ms))
|
||
{
|
||
bw.Write(0x46546C67);
|
||
bw.Write(2);
|
||
int length = 12 + 8 + jsonPadded.Length + 8 + binPadded.Length;
|
||
bw.Write(length);
|
||
|
||
bw.Write(jsonPadded.Length);
|
||
bw.Write(0x4E4F534A);
|
||
bw.Write(jsonPadded);
|
||
|
||
bw.Write(binPadded.Length);
|
||
bw.Write(0x004E4942);
|
||
bw.Write(binPadded);
|
||
|
||
return ms.ToArray();
|
||
}
|
||
}
|
||
|
||
private static string InferAccessorType(GltfAccessor a)
|
||
{
|
||
// 가능하면 min/max 길이로 판단, 아니면 componentType 기준 간단 추정
|
||
try
|
||
{
|
||
if (a.min != null && a.min.Length == 3) return "VEC3";
|
||
if (a.min != null && a.min.Length == 2) return "VEC2";
|
||
if (a.min != null && a.min.Length == 4) return "VEC4";
|
||
}
|
||
catch { /* ignore */ }
|
||
|
||
// componentType 기반 추정(인덱스 등)
|
||
switch (a.componentType)
|
||
{
|
||
case GltfComponentType.UnsignedByte:
|
||
case GltfComponentType.UnsignedShort:
|
||
case GltfComponentType.UnsignedInt:
|
||
return "SCALAR";
|
||
case GltfComponentType.Float:
|
||
// 기본적으로 float로 만들어졌으면 벡터인지 모름 — 안전하게 SCALAR로 두지 말고 VEC3 우선시
|
||
return "VEC3";
|
||
default:
|
||
return "SCALAR";
|
||
}
|
||
}
|
||
|
||
|
||
public static void ExportNodeByExplorer(GameObject node, bool useLib = true)
|
||
{
|
||
if (node == null)
|
||
{
|
||
Debug.LogWarning("GLTFExporter: node is null, cannot export.");
|
||
return;
|
||
}
|
||
|
||
FileBrowser.ShowSaveDialog(
|
||
(string[] paths) =>
|
||
{
|
||
if (paths.Length == 1)
|
||
{
|
||
if (useLib)
|
||
{
|
||
ExportAsync(node, paths[0]).ContinueWith(t =>
|
||
{
|
||
if (t.IsFaulted)
|
||
{
|
||
Debug.LogError($"GLTFExporter: export failed with exception: {t.Exception}");
|
||
}
|
||
});
|
||
}
|
||
else
|
||
{
|
||
ExportNodeToGlbFile(node, paths[0]);
|
||
}
|
||
}
|
||
}, () =>
|
||
{
|
||
Debug.Log("User canceled save file dialog.");
|
||
}, FileBrowser.PickMode.Files,
|
||
false,
|
||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||
"model.glb",
|
||
"Save",
|
||
"Save");
|
||
}
|
||
|
||
|
||
public static void ExportNodeToGlbFile(GameObject node, string filePath)
|
||
{
|
||
|
||
if (node == null)
|
||
{
|
||
Debug.LogWarning("GLTFExporter: node is null, cannot export.");
|
||
return;
|
||
}
|
||
|
||
|
||
var glbBytes = ExportNodeToGlbBytes(node);
|
||
if (glbBytes != null)
|
||
{
|
||
try
|
||
{
|
||
File.WriteAllBytes(filePath, glbBytes);
|
||
Debug.Log($"GLTFExporter: exported GLB to '{filePath}'.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"GLTFExporter: failed to write GLB file '{filePath}': {ex.Message}");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("GLTFExporter: export returned no data.");
|
||
}
|
||
}
|
||
|
||
public static string? ExtractJsonChunkFromGlbBytes(byte[] glbBytes)
|
||
{
|
||
if (glbBytes == null || glbBytes.Length < 20) return null;
|
||
using (var ms = new MemoryStream(glbBytes))
|
||
using (var br = new BinaryReader(ms))
|
||
{
|
||
uint magic = br.ReadUInt32(); // should be 0x46546C67
|
||
uint version = br.ReadUInt32();
|
||
uint length = br.ReadUInt32();
|
||
if (magic != 0x46546C67) return null;
|
||
// JSON chunk
|
||
uint jsonChunkLen = br.ReadUInt32();
|
||
uint jsonChunkType = br.ReadUInt32();
|
||
if (jsonChunkType != 0x4E4F534A) return null; // 'JSON'
|
||
var jsonBytes = br.ReadBytes((int)jsonChunkLen);
|
||
// Trim trailing spaces (0x20) that were used for padding when creating GLB
|
||
int actualLen = jsonBytes.Length;
|
||
while (actualLen > 0 && jsonBytes[actualLen - 1] == 0x20) actualLen--;
|
||
return Encoding.UTF8.GetString(jsonBytes, 0, actualLen);
|
||
}
|
||
}
|
||
|
||
public static void DumpJsonFromGlbFile(string glbPath, string outJsonPath = null)
|
||
{
|
||
try
|
||
{
|
||
var bytes = File.ReadAllBytes(glbPath);
|
||
var json = ExtractJsonChunkFromGlbBytes(bytes);
|
||
if (json == null)
|
||
{
|
||
Debug.LogWarning($"GLTFExporter: failed to extract JSON chunk from '{glbPath}'.");
|
||
return;
|
||
}
|
||
if (string.IsNullOrEmpty(outJsonPath))
|
||
{
|
||
outJsonPath = Path.ChangeExtension(glbPath, ".gltf.json");
|
||
}
|
||
File.WriteAllText(outJsonPath, json, Encoding.UTF8);
|
||
Debug.Log($"GLTFExporter: extracted JSON chunk to '{outJsonPath}'.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"GLTFExporter: DumpJsonFromGlbFile failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
#region helpers
|
||
|
||
private class MeshEntry { public UnityEngine.Mesh mesh; public Transform transform; public Renderer renderer; }
|
||
|
||
private static void AppendFloat(List<byte> bin, float v) => bin.AddRange(BitConverter.GetBytes(v));
|
||
private static void AppendUShort(List<byte> bin, ushort v) => bin.AddRange(BitConverter.GetBytes(v));
|
||
private static void AppendUInt(List<byte> bin, uint v) => bin.AddRange(BitConverter.GetBytes(v));
|
||
private static void AlignTo4AppendPadding(List<byte> bin)
|
||
{
|
||
int pad = (4 - (bin.Count % 4)) % 4;
|
||
for (int i = 0; i < pad; i++) bin.Add(0);
|
||
}
|
||
private static int PadTo4(int v) => (4 - (v % 4)) % 4;
|
||
|
||
private static bool TryGetColor(UnityEngine.Material mat, out Color c)
|
||
{
|
||
c = Color.white;
|
||
if (mat == null) return false;
|
||
string[] props = { "_BaseColor", "_Color", "_TintColor", "_MainColor", "_WireframeColor" };
|
||
foreach (var p in props)
|
||
{
|
||
if (mat.HasProperty(p))
|
||
{
|
||
try { c = mat.GetColor(p); return true; } catch { }
|
||
}
|
||
}
|
||
try { c = mat.color; return true; } catch { return false; }
|
||
}
|
||
|
||
private static int PadTo4(long v) => (int)((4 - (v % 4)) % 4);
|
||
|
||
#endregion
|
||
}
|
||
|
||
class ColorConverter : JsonConverter
|
||
{
|
||
public override bool CanConvert(Type objectType)
|
||
{
|
||
return objectType == typeof(Color) || objectType == typeof(Color?);
|
||
}
|
||
|
||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||
{
|
||
if (value == null)
|
||
{
|
||
writer.WriteNull();
|
||
return;
|
||
}
|
||
|
||
var c = (Color)value;
|
||
// glTF는 보통 선형 색상 값을 기대하므로 linear 사용
|
||
var lin = c.linear;
|
||
writer.WriteStartArray();
|
||
writer.WriteValue((double)lin.r);
|
||
writer.WriteValue((double)lin.g);
|
||
writer.WriteValue((double)lin.b);
|
||
writer.WriteValue((double)lin.a);
|
||
writer.WriteEndArray();
|
||
}
|
||
|
||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||
{
|
||
if (reader.TokenType == JsonToken.Null) return null;
|
||
var arr = serializer.Deserialize<double[]>(reader);
|
||
if (arr == null) return Color.white;
|
||
float r = (float)(arr.Length > 0 ? arr[0] : 0.0);
|
||
float g = (float)(arr.Length > 1 ? arr[1] : 0.0);
|
||
float b = (float)(arr.Length > 2 ? arr[2] : 0.0);
|
||
float a = (float)(arr.Length > 3 ? arr[3] : 1.0);
|
||
return new Color(r, g, b, a);
|
||
}
|
||
}
|
||
} |