Files
XRLib/Assets/Scripts/UVC/GLTF/GLTFExporter.cs
2025-11-24 20:24:04 +09:00

831 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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);
}
}
}