From a40dad081c0edeef9d30c82c3ea84e56ae5905a1 Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Wed, 26 Nov 2025 21:51:36 -0700 Subject: [PATCH 01/34] implemented exporting all LODs for skeletal meshes on the main export, bioMorphFace export, and MorphTargetSet export. Also added default filename suggestions based on the export name. --- .../PackageEditorExperimentsSquid.cs | 106 ++++++++++++------ 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 1364eabd7..16ff14348 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -13,7 +13,6 @@ using LegendaryExplorerCore.UnrealScript; using LegendaryExplorerCore.UnrealScript.Compiling.Errors; using Microsoft.Win32; -using Microsoft.WindowsAPICodePack.Dialogs; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Tga; using SixLabors.ImageSharp.PixelFormats; @@ -704,10 +703,15 @@ public static void ExportSelectedToPsx(PackageEditorWindow pew) { case "SkeletalMesh": // export the skeletal mesh as a psk - var d = new SaveFileDialog { Filter = "PSKX|*.pskx" }; + var d = new SaveFileDialog { Filter = "PSKX|*.pskx", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; if (d.ShowDialog() == true) { - PSK.CreateFromSkeletalMesh(((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(), 0, true).ToFile(d.FileName); + var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); + PSK.CreateFromSkeletalMesh(meshBin, 0, true).ToFile(d.FileName); + for (int i = 1; i < meshBin.LODModels.Length; i++) + { + PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^4]}_LOD{i}.pskx"); + } } return; case "AnimSet": @@ -778,27 +782,40 @@ private static void BioMorphFaceToPskxAndPsa(PackageEditorWindow pew) return; } - var d = new SaveFileDialog { Filter = "PSKX|*.pskx" }; + var d = new SaveFileDialog { Filter = "PSKX|*.pskx" , FileName = bmf.ObjectNameString}; if (d.ShowDialog() == true) { var baseHeadMesh = pew.Pcc.GetEntry(bmf.GetProperty("m_oBaseHead").Value) as ExportEntry; var baseMeshBin = baseHeadMesh.GetBinaryData(); - - // make most of the psk from the base head mesh - var psk = PSK.CreateFromSkeletalMesh(baseHeadMesh.GetBinaryData(), 0, true); - var bmfBin = bmf.GetBinaryData(); - for (var i = 0; i < psk.Points.Count && i < bmfBin.LODs[0].Length; i++) + void ExportLOD(int lod) { - // modify each point in the psk with the points from the bmf - var bmfPoint = bmfBin.LODs[0][i]; - psk.Points[i] = bmfPoint with { Y = -bmfPoint.Y }; - } + var psk = PSK.CreateFromSkeletalMesh(baseMeshBin, lod, true); - psk.ToFile(d.FileName); + for (var i = 0; i < psk.Points.Count && i < bmfBin.LODs[lod].Length; i++) + { + // modify each point in the psk with the points from the bmf + var bmfPoint = bmfBin.LODs[lod][i]; + psk.Points[i] = bmfPoint with { Y = -bmfPoint.Y }; + } + + if (lod == 0) + { + psk.ToFile(d.FileName); + } + else + { + psk.ToFile($"{d.FileName[..^4]}_LOD{lod}.pskx"); + } + } + // make most of the psk from the base head mesh + for (int i = 0; i < baseMeshBin.LODModels.Length && i < bmfBin.LODs.Length; i++) + { + ExportLOD(i); + } // now, output the psa file and config file var config = new StringBuilder(); @@ -2662,36 +2679,55 @@ private static void ExportMorphTargetSet(PackageEditorWindow pew) var baseMeshBin = baseMesh.GetBinaryData(); var targets = morphTargetSet.GetProperty>("Targets"); - var d = new SaveFileDialog { Filter = "PSKX|*.pskx" }; + var d = new SaveFileDialog { Filter = "PSKX|*.pskx", FileName = morphTargetSet.ObjectNameString }; if (d.ShowDialog() == true) { - // output the special psk into a file with the name of the base head - // make most of the psk from the base skeletal mesh - var psk = PSK.CreateFromSkeletalMesh(baseMeshBin, 0, true); - - foreach (var target in targets) + void OutputLOD(int lod) { - var targetExport = SharedMethods.ResolveEntryToExport(pew.Pcc.GetEntry(target.Value), new PackageCache()); - var targetBin = targetExport.GetBinaryData(); - psk.Morphs.Add(new PSK.MorphInfo - { - Name = targetExport.ObjectNameString, - VertexCount = targetBin.MorphLODModels[0].Vertices.Length - }); + // output the special psk into a file with the name of the base head + // make most of the psk from the base skeletal mesh + var psk = PSK.CreateFromSkeletalMesh(baseMeshBin, lod, true); - foreach (var vertex in targetBin.MorphLODModels[0].Vertices) + foreach (var target in targets) { - psk.MorphData.Add(new PSK.MorphDelta + var targetExport = SharedMethods.ResolveEntryToExport(pew.Pcc.GetEntry(target.Value), new PackageCache()); + var targetBin = targetExport.GetBinaryData(); + if (targetBin.MorphLODModels.Length > lod) { - PointIndex = vertex.SourceIdx, - PositionDelta = vertex.PositionDelta, - // this gets ignored on import to Blender anyway - //TangentZDelta = vertex.TangentZDelta - }); + psk.Morphs.Add(new PSK.MorphInfo + { + Name = targetExport.ObjectNameString, + VertexCount = targetBin.MorphLODModels[lod].Vertices.Length + }); + + foreach (var vertex in targetBin.MorphLODModels[lod].Vertices) + { + psk.MorphData.Add(new PSK.MorphDelta + { + PointIndex = vertex.SourceIdx, + PositionDelta = vertex.PositionDelta, + // this gets ignored on import to Blender anyway + //TangentZDelta = vertex.TangentZDelta + }); + } + } + } + + if (lod == 0) + { + psk.ToFile(d.FileName); + } + else + { + psk.ToFile($"{d.FileName[..^4]}_LOD{lod}.pskx"); } } - psk.ToFile(d.FileName); + // make most of the psk from the base head mesh + for (int i = 0; i < baseMeshBin.LODModels.Length; i++) + { + OutputLOD(i); + } // now, output the psa file and config file var config = new StringBuilder(); From 0580e483c39279fbbe4bb71f393f46cf63c09fb2 Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Wed, 3 Dec 2025 08:33:58 -0700 Subject: [PATCH 02/34] implemented material mapping for psk export. --- .../LegendaryExplorerCore/Unreal/PSK.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs index 51c15664e..edeca661a 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Numerics; +using LegendaryExplorerCore.Packages; using LegendaryExplorerCore.Unreal.BinaryConverters; namespace LegendaryExplorerCore.Unreal @@ -176,6 +177,19 @@ public static PSK CreateFromSkeletalMesh(SkeletalMesh skelMesh, int lodIdx = 0, }; int numTriangles = 0; var matIndices = new byte[numVertices]; + int[] materialMapping = Enumerable.Range(0, skelMesh.Materials.Length).ToArray(); + if (skelMesh.Export != null) + { + var LODInfo = skelMesh.Export.GetProperty>("LODInfo"); + if (LODInfo != null && LODInfo.Count > lodIdx) + { + var matMap = LODInfo[lodIdx].GetProp>("LODMaterialMap"); + if (matMap != null && matMap.Count > 0) + { + materialMapping = matMap.Select(x => x.Value).ToArray(); + } + } + } foreach (SkelMeshSection section in lod.Sections) { numTriangles += section.NumTriangles; @@ -185,7 +199,7 @@ public static PSK CreateFromSkeletalMesh(SkeletalMesh skelMesh, int lodIdx = 0, int i1 = lod.IndexBuffer[baseIndex + t * 3]; int i2 = lod.IndexBuffer[baseIndex + t * 3 + 1]; int i3 = lod.IndexBuffer[baseIndex + t * 3 + 2]; - byte materialIndex = (byte)section.MaterialIndex; + byte materialIndex = (byte)materialMapping[section.MaterialIndex]; matIndices[i1] = materialIndex; matIndices[i2] = materialIndex; matIndices[i3] = materialIndex; From 82f159660b4f3e8fc3ec8bc7745224742e155fcb Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Tue, 9 Dec 2025 17:27:17 -0500 Subject: [PATCH 03/34] Fixed some issues with PSK LOD export, implemented partial support for LOD import. It works correctly for import as new mesh. --- .../PackageEditorExperimentsSquid.cs | 244 +++++++++++------- 1 file changed, 152 insertions(+), 92 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 16ff14348..b4a6b4212 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -33,7 +33,6 @@ static internal class PackageEditorExperimentsSquid { // the Mass Effect binary mesh format enforces there be a maximum of 4 bone influences per vertex const int MaxBoneInfluences = 4; - public static void ImportAnimSet(PackageEditorWindow pew) { if (GetPsaFromFile(pew, out var psa, out var filePath)) @@ -119,9 +118,9 @@ public static void ImportPskAsNewMesh(PackageEditorWindow pew) { ShowError("This experiment does not support UDK files;"); } - if (GetPskFromFile(pew, out var psk, out var path)) + if (GetPskFromFile(out var psks, out var path)) { - if (!psk.Bones.Any()) + if (!psks[0].Bones.Any()) { throw new NotImplementedException("You can't make a static mesh yet"); } @@ -129,67 +128,27 @@ public static void ImportPskAsNewMesh(PackageEditorWindow pew) var meshExport = ExportCreator.CreateExport(pew.Pcc, Path.GetFileNameWithoutExtension(path), "SkeletalMesh"); var meshBin = SkeletalMesh.Create(); - SetupSkeleton(psk, meshBin); - SetupBounds(psk, meshBin); - SetupMaterials(pew, psk, meshBin); - CalculateNormalsIfNeeded(psk); - - GetAllVertices(psk, out List vertsInWedgeOrder, out TempVertex[] finalVerts); - CalcualteTangents(psk, vertsInWedgeOrder); - - StaticLODModel LOD; - List chunks; - SetupSectionsAndChunks(psk, meshBin, vertsInWedgeOrder, finalVerts, out LOD, out chunks); - - #region the rest of the LOD data - LOD.ActiveBoneIndices = [.. Enumerable.Range(0, psk.Bones.Count).Select(x => (ushort)x)]; - - // finally, write out the vertex data! - LOD.NumVertices = (uint)finalVerts.Length; - - LOD.VertexBufferGPUSkin = new SkeletalMeshVertexBuffer - { - VertexData = new GPUSkinVertex[finalVerts.Length], - MeshExtension = new Vector3(1, 1, 1) - }; + // TODO make sure the skeleton matches between all LODs + SetupSkeleton(psks[0], meshBin); + SetupBounds(psks[0], meshBin); - for (int chunkIndex = 0; chunkIndex < LOD.Chunks.Length; chunkIndex++) + // so, I need to make a slot for all materials, deduplicated from across the LODs + List materials = []; + foreach (var psk in psks) { - var LODChunk = LOD.Chunks[chunkIndex]; - var chunk = chunks[chunkIndex]; - for (var i = chunk.VertIndexStart; i <= chunk.VertIndexEnd; i++) + foreach (var mat in psk.Materials) { - var tempVert = finalVerts[i]; - var newVert = new GPUSkinVertex - { - UV = new Vector2DHalf(tempVert.U, tempVert.V), - Position = tempVert.Position with { Y = tempVert.Position.Y * -1 } - }; - - var vertNorm = tempVert.Normal with { Y = -tempVert.Normal.Y }; - var packedNorm = (PackedNormal)Vector3.Normalize(vertNorm); - // the w component of the normal is stores the bitangent sign, indicating whether the UV mapping is mirorred here - var normalW = tempVert.BiTangentSign > 0 ? (byte)255 : (byte)0; - newVert.TangentZ = new PackedNormal(packedNorm.X, packedNorm.Y, packedNorm.Z, normalW); - - var vertTangent = tempVert.Tangent with { Y = -tempVert.Tangent.Y }; - var packedTangent = (PackedNormal)Vector3.Normalize(vertTangent); - newVert.TangentX = packedTangent; - - // add in the bone influences - byte GetMappedBoneIndex(PSK.PSKWeight influence) + if (!materials.Contains(mat.Name)) { - var boneName = psk.Bones[influence.Bone].Name; - var meshBoneIndex = meshBin.RefSkeleton.FindIndex(x => x.Name == boneName); - return (byte)LODChunk.BoneMap.IndexOf((ushort)meshBoneIndex); + materials.Add(mat.Name); } - - (newVert.InfluenceBones, newVert.InfluenceWeights) = DistributeWeights(tempVert.Weights.Select(x => (GetMappedBoneIndex(x), x.Weight))); - - LOD.VertexBufferGPUSkin.VertexData[i] = newVert; } } - #endregion + SetupMaterials(pew, materials, meshBin); + + meshBin.LODModels = [.. psks.Select(x => SetupLOD(x, meshBin))]; + + meshExport.WriteBinary(meshBin); /* things I have not implemented: * net Index (probably not important unless you are doing ME3MP modding, and you can set it manually easily enough) @@ -199,11 +158,6 @@ byte GetMappedBoneIndex(PSK.PSKWeight influence) * importing to OT1 (the format is slightly different in ways I don't care to implement), you can probably use debug build to port into OT1 if you must * */ - // just write one LOD. we could extend this to multiple in the future if needed, but no one I know of is actually generating multiple LODs - meshBin.LODModels = [LOD]; - - meshExport.WriteBinary(meshBin); - // copy the sockets from the selected mesh onto the new one if (GetSelectedItem(pew, "SkeletalMesh", out var selectedMesh)) { @@ -220,6 +174,37 @@ byte GetMappedBoneIndex(PSK.PSKWeight influence) meshExport.WriteProperty(newSocketsProp); } } + + var lodInfoarray = new ArrayProperty("LODInfo"); + float[] displayFactors = [1.0f, 0.25f, 0.1f]; + for (int i = 0; i < psks.Length; i++) + { + var currentLod = psks[i]; + var displayFactorProp = new FloatProperty(displayFactors[Math.Min(i, displayFactors.Length - 1)], "DisplayFactor"); + var bEnableShadowCastingProp = new ArrayProperty(Enumerable.Repeat(new BoolProperty(true), currentLod.Materials.Count), "bEnableShadowCasting"); + var TriangleSortingProp = new ArrayProperty(Enumerable.Repeat(new EnumProperty("TRISORT_None", "TriangleSortOption", pew.Pcc.Game), currentLod.Materials.Count), "TriangleSorting"); + + var matMap = new List(currentLod.Materials.Count); + // to match vanilla, LOD0 has an empty array + if (i != 0) + { + foreach (var mat in currentLod.Materials) + { + matMap.Add(materials.IndexOf(mat.Name)); + } + } + + var LODMaterialMapProp = new ArrayProperty(matMap.Select(x => new IntProperty(x)), "LODMaterialMap"); + var lodInfo = new StructProperty("SkeletalMeshLODInfo", false, + displayFactorProp, + new FloatProperty(0.2f, "LODHysteresis"), + LODMaterialMapProp, + bEnableShadowCastingProp, + TriangleSortingProp); + lodInfoarray.Add(lodInfo); + } + + meshExport.WriteProperty(lodInfoarray); } static (Influences bones, Influences influences) DistributeWeights(IEnumerable<(byte bone, float weight)> weights) @@ -355,16 +340,16 @@ static void SetupBounds(PSK psk, SkeletalMesh meshBin) }; } - static void SetupMaterials(PackageEditorWindow pew, PSK psk, SkeletalMesh meshBin) + static void SetupMaterials(PackageEditorWindow pew, IList materials, SkeletalMesh meshBin) { - SetNumMaterialSlots(meshBin, psk.Materials.Count); - for (int i = 0; i < psk.Materials.Count; i++) + SetNumMaterialSlots(meshBin, materials.Count); + for (int i = 0; i < materials.Count; i++) { // Does not work because it is looking for the full instanced path; can I export using that? - var entry = pew.Pcc.FindEntry(psk.Materials[i].Name); + var entry = pew.Pcc.FindEntry(materials[i]); // a good enough heuristic for now - entry ??= pew.Pcc.Exports.FirstOrDefault(x => x.ObjectName == psk.Materials[i].Name && x.ClassName.Contains("Material")); - entry ??= pew.Pcc.Imports.FirstOrDefault(x => x.ObjectName == psk.Materials[i].Name && x.ClassName.Contains("Material")); + entry ??= pew.Pcc.Exports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); + entry ??= pew.Pcc.Imports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); if (entry != null) { meshBin.Materials[i] = entry.UIndex; @@ -678,6 +663,65 @@ ushort GetMeshBoneIndex(ushort pskIndex) BoneMap = [.. x.InfluenceBones.Select(GetMeshBoneIndex).Order()] })]; } + + static StaticLODModel SetupLOD(PSK psk, SkeletalMesh meshBin) + { + CalculateNormalsIfNeeded(psk); + GetAllVertices(psk, out List vertsInWedgeOrder, out TempVertex[] finalVerts); + CalcualteTangents(psk, vertsInWedgeOrder); + + SetupSectionsAndChunks(psk, meshBin, vertsInWedgeOrder, finalVerts, out StaticLODModel LOD, out List chunks); + + LOD.ActiveBoneIndices = [.. Enumerable.Range(0, psk.Bones.Count).Select(x => (ushort)x)]; + + // finally, write out the vertex data! + LOD.NumVertices = (uint)finalVerts.Length; + + LOD.VertexBufferGPUSkin = new SkeletalMeshVertexBuffer + { + VertexData = new GPUSkinVertex[finalVerts.Length], + MeshExtension = new Vector3(1, 1, 1) + }; + + for (int chunkIndex = 0; chunkIndex < LOD.Chunks.Length; chunkIndex++) + { + var LODChunk = LOD.Chunks[chunkIndex]; + var chunk = chunks[chunkIndex]; + for (var i = chunk.VertIndexStart; i <= chunk.VertIndexEnd; i++) + { + var tempVert = finalVerts[i]; + var newVert = new GPUSkinVertex + { + UV = new Vector2DHalf(tempVert.U, tempVert.V), + Position = tempVert.Position with { Y = tempVert.Position.Y * -1 } + }; + + var vertNorm = tempVert.Normal with { Y = -tempVert.Normal.Y }; + var packedNorm = (PackedNormal)Vector3.Normalize(vertNorm); + // the w component of the normal is stores the bitangent sign, indicating whether the UV mapping is mirorred here + var normalW = tempVert.BiTangentSign > 0 ? (byte)255 : (byte)0; + newVert.TangentZ = new PackedNormal(packedNorm.X, packedNorm.Y, packedNorm.Z, normalW); + + var vertTangent = tempVert.Tangent with { Y = -tempVert.Tangent.Y }; + var packedTangent = (PackedNormal)Vector3.Normalize(vertTangent); + newVert.TangentX = packedTangent; + + // add in the bone influences + byte GetMappedBoneIndex(PSK.PSKWeight influence) + { + var boneName = psk.Bones[influence.Bone].Name; + var meshBoneIndex = meshBin.RefSkeleton.FindIndex(x => x.Name == boneName); + return (byte)LODChunk.BoneMap.IndexOf((ushort)meshBoneIndex); + } + + (newVert.InfluenceBones, newVert.InfluenceWeights) = DistributeWeights(tempVert.Weights.Select(x => (GetMappedBoneIndex(x), x.Weight))); + + LOD.VertexBufferGPUSkin.VertexData[i] = newVert; + } + } + + return LOD; + } } private class TempVertex @@ -710,7 +754,7 @@ public static void ExportSelectedToPsx(PackageEditorWindow pew) PSK.CreateFromSkeletalMesh(meshBin, 0, true).ToFile(d.FileName); for (int i = 1; i < meshBin.LODModels.Length; i++) { - PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^4]}_LOD{i}.pskx"); + PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^5]}_LOD{i}.pskx"); } } return; @@ -807,7 +851,7 @@ void ExportLOD(int lod) } else { - psk.ToFile($"{d.FileName[..^4]}_LOD{lod}.pskx"); + psk.ToFile($"{d.FileName[..^5]}_LOD{lod}.pskx"); } } @@ -2119,7 +2163,7 @@ private static bool GetPsaFromFile(PackageEditorWindow pew, out PSA psa, out str return false; } - private static bool GetPskFromFile(PackageEditorWindow pew, out PSK psk, out string filePath) + private static bool GetPskFromFile(out PSK[] psks, out string filePath) { var d = new OpenFileDialog { @@ -2128,12 +2172,28 @@ private static bool GetPskFromFile(PackageEditorWindow pew, out PSK psk, out str }; if (d.ShowDialog() == true) { - psk = PSK.FromFile(d.FileName); filePath = d.FileName; - return psk != null; + var folder = Path.GetDirectoryName(filePath); + var extension = Path.GetExtension(filePath); + var baseName = Path.GetFileNameWithoutExtension(filePath); + var LOD0 = PSK.FromFile(filePath); + List lods = [LOD0]; + var lod = 1; + do + { + var path = Path.Combine(folder, $"{baseName}_LOD{lod++}{extension}"); + if (!File.Exists(path)) + { + break; + } + var lodPsk = PSK.FromFile(path); + lods.Add(lodPsk); + } while (true); + psks = [.. lods]; + return LOD0 != null; } - psk = null; + psks = []; filePath = null; return false; } @@ -2191,15 +2251,15 @@ public static void ReplaceBMFDataFromPskAndPsa(PackageEditorWindow pew) { if (GetSelectedItem(pew, "BioMorphFace", out var bmfExport)) { - if (GetPskFromFile(pew, out var psk, out _)) + if (GetPskFromFile(out var psks, out _)) { var bmfBin = bmfExport.GetBinaryData(); - Vector3[] vertexPos = new Vector3[psk.Points.Count]; + Vector3[] vertexPos = new Vector3[psks[0].Points.Count]; - for (int i = 0; i < psk.Points.Count; i++) + for (int i = 0; i < psks[0].Points.Count; i++) { - vertexPos[i] = psk.Points[i] with { Y = -psk.Points[i].Y }; + vertexPos[i] = psks[0].Points[i] with { Y = -psks[0].Points[i].Y }; } bmfBin.LODs = [[.. vertexPos]]; @@ -2399,12 +2459,12 @@ public static void UpdateRonFromPskAndPsa(PackageEditorWindow pew) { if (GetHeadmorphFromFile(out var headMorph, out var ronFilePath)) { - if (GetPskFromFile(pew, out var psk, out _)) + if (GetPskFromFile(out var psks, out _)) { - headMorph.Lod0Vertices = new List(psk.Points.Count); - for (int i = 0; i < psk.Points.Count; i++) + headMorph.Lod0Vertices = new List(psks[0].Points.Count); + for (int i = 0; i < psks[0].Points.Count; i++) { - headMorph.Lod0Vertices.Add(psk.Points[i] with { Y = -psk.Points[i].Y }); + headMorph.Lod0Vertices.Add(psks[0].Points[i] with { Y = -psks[0].Points[i].Y }); } } if (GetPsaFromFile(pew, out var psa, out _)) @@ -2563,7 +2623,7 @@ public static void ImportPskAndPsaAsMorphTarget(PackageEditorWindow pew) var baseMeshBinary = baseMesh.GetBinaryData(); // using bitwise | so it evaluates the second even if the first evaluates to true - if (GetPskFromFile(pew, out var psk, out var pskName) | GetPsaFromFile(pew, out var psa, out var psaName)) + if (GetPskFromFile(out var psks, out var pskName) | GetPsaFromFile(pew, out var psa, out var psaName)) { var morphTargetName = Path.GetFileNameWithoutExtension(pskName ?? psaName); @@ -2580,22 +2640,22 @@ public static void ImportPskAndPsaAsMorphTarget(PackageEditorWindow pew) { MorphLODModels = [new MorphTarget.MorphLODModel()] }; - morphTargetBin.MorphLODModels[0].NumBaseMeshVerts = psk.Points.Count; + morphTargetBin.MorphLODModels[0].NumBaseMeshVerts = psks[0].Points.Count; // add it to the morph target set targets.Add(new ObjectProperty(morphTarget.UIndex)); morphTargetSet.WriteProperty(targets); } - if (psk != null) + if (psks != null) { - if (psk.Points.Count != baseMeshBinary.LODModels[0].NumVertices) + if (psks[0].Points.Count != baseMeshBinary.LODModels[0].NumVertices) { ShowError("the number of vertices in the base mesh (LOD 0) and the psk must match."); return; } - if (psk.Points.Count != psk.Wedges.Count) + if (psks[0].Points.Count != psks[0].Wedges.Count) { ShowError("Can't use this psk; number of points and wedges differ."); return; @@ -2603,18 +2663,18 @@ public static void ImportPskAndPsaAsMorphTarget(PackageEditorWindow pew) List vertDeltas = []; - for (int i = 0; i < psk.Points.Count; i++) + for (int i = 0; i < psks[0].Points.Count; i++) { // gotta flip the y part of the position - psk.Points[i] = new Vector3(psk.Points[i].X, psk.Points[i].Y * -1, psk.Points[i].Z); + psks[0].Points[i] = new Vector3(psks[0].Points[i].X, psks[0].Points[i].Y * -1, psks[0].Points[i].Z); // TODO I could more simply represent this with a distance call and comparison - if (!ApproximatelyEqual(baseMeshBinary.LODModels[0].VertexBufferGPUSkin.VertexData[i].Position, psk.Points[i])) + if (!ApproximatelyEqual(baseMeshBinary.LODModels[0].VertexBufferGPUSkin.VertexData[i].Position, psks[0].Points[i])) { vertDeltas.Add(new MorphTarget.MorphVertex() { SourceIdx = (ushort)i, - PositionDelta = psk.Points[i] - baseMeshBinary.LODModels[0].VertexBufferGPUSkin.VertexData[i].Position + PositionDelta = psks[0].Points[i] - baseMeshBinary.LODModels[0].VertexBufferGPUSkin.VertexData[i].Position }); } @@ -2719,7 +2779,7 @@ void OutputLOD(int lod) } else { - psk.ToFile($"{d.FileName[..^4]}_LOD{lod}.pskx"); + psk.ToFile($"{d.FileName[..^5]}_LOD{lod}.pskx"); } } From b8a9b7de54eb097598540e4aef58e28fadb4021a Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Tue, 9 Dec 2025 18:02:32 -0500 Subject: [PATCH 04/34] Added experiment for importing a psk over an existing skeletal mesh. --- .../PackageEditorExperimentsSquid.cs | 211 +++++++++++------- .../ExperimentsMenuControl.xaml | 1 + .../ExperimentsMenuControl.xaml.cs | 5 + 3 files changed, 137 insertions(+), 80 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index b4a6b4212..fb70d93c2 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -108,104 +108,70 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } - public static void ImportPskAsNewMesh(PackageEditorWindow pew) - { - if (pew.Pcc.Game == MEGame.ME1) - { - ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); - } - if (pew.Pcc.Game == MEGame.UDK) - { - ShowError("This experiment does not support UDK files;"); - } - if (GetPskFromFile(out var psks, out var path)) - { - if (!psks[0].Bones.Any()) - { - throw new NotImplementedException("You can't make a static mesh yet"); - } - var meshExport = ExportCreator.CreateExport(pew.Pcc, Path.GetFileNameWithoutExtension(path), "SkeletalMesh"); - var meshBin = SkeletalMesh.Create(); + private static SkeletalMesh CreateSkeletalMeshFromPsks(PackageEditorWindow pew, PSK[] psks, out ArrayProperty lodInfoProp) + { + var meshBin = SkeletalMesh.Create(); - // TODO make sure the skeleton matches between all LODs - SetupSkeleton(psks[0], meshBin); - SetupBounds(psks[0], meshBin); + // TODO make sure the skeleton matches between all LODs + SetupSkeleton(psks[0], meshBin); + SetupBounds(psks[0], meshBin); - // so, I need to make a slot for all materials, deduplicated from across the LODs - List materials = []; - foreach (var psk in psks) + // so, I need to make a slot for all materials, deduplicated from across the LODs + List materials = []; + foreach (var psk in psks) + { + foreach (var mat in psk.Materials) { - foreach (var mat in psk.Materials) + if (!materials.Contains(mat.Name)) { - if (!materials.Contains(mat.Name)) - { - materials.Add(mat.Name); - } + materials.Add(mat.Name); } } - SetupMaterials(pew, materials, meshBin); + } + SetupMaterials(pew, materials, meshBin); - meshBin.LODModels = [.. psks.Select(x => SetupLOD(x, meshBin))]; + meshBin.LODModels = [.. psks.Select(x => SetupLOD(x, meshBin))]; - meshExport.WriteBinary(meshBin); + /* things I have not implemented: + * net Index (probably not important unless you are doing ME3MP modding, and you can set it manually easily enough) + * Clothing Assets (all null anyway in vanilla) + * LOD size (doesn't seem to be important; UDK imports have it set to 0, and I don't know how it is calculated) + * PerPolyBoneKDOPS (no idea what this is, it's mostly empty in vanilla) + * importing to OT1 (the format is slightly different in ways I don't care to implement), you can probably use debug build to port into OT1 if you must + * */ - /* things I have not implemented: - * net Index (probably not important unless you are doing ME3MP modding, and you can set it manually easily enough) - * Clothing Assets (all null anyway in vanilla) - * LOD size (doesn't seem to be important; UDK imports have it set to 0, and I don't know how it is calculated) - * PerPolyBoneKDOPS (no idea what this is, it's mostly empty in vanilla) - * importing to OT1 (the format is slightly different in ways I don't care to implement), you can probably use debug build to port into OT1 if you must - * */ + lodInfoProp = new ArrayProperty("LODInfo"); + float[] displayFactors = [1.0f, 0.25f, 0.1f]; + for (int i = 0; i < psks.Length; i++) + { + var currentLod = psks[i]; + var displayFactorProp = new FloatProperty(displayFactors[Math.Min(i, displayFactors.Length - 1)], "DisplayFactor"); + var bEnableShadowCastingProp = new ArrayProperty(Enumerable.Repeat(new BoolProperty(true), currentLod.Materials.Count), "bEnableShadowCasting"); + var TriangleSortingProp = new ArrayProperty(Enumerable.Repeat(new EnumProperty("TRISORT_None", "TriangleSortOption", pew.Pcc.Game), currentLod.Materials.Count), "TriangleSorting"); - // copy the sockets from the selected mesh onto the new one - if (GetSelectedItem(pew, "SkeletalMesh", out var selectedMesh)) + var matMap = new List(currentLod.Materials.Count); + // to match vanilla, LOD0 has an empty array + if (i != 0) { - var oldSocketsProp = selectedMesh.GetProperty>("Sockets"); - if (oldSocketsProp != null) + foreach (var mat in currentLod.Materials) { - var newSocketsProp = new ArrayProperty("Sockets"); - foreach (var socket in oldSocketsProp) - { - var newEntry = EntryCloner.CloneEntry(socket.ResolveToEntry(pew.Pcc), incrementIndex: false); - newEntry.Parent = meshExport; - newSocketsProp.Add(new ObjectProperty(newEntry)); - } - meshExport.WriteProperty(newSocketsProp); + matMap.Add(materials.IndexOf(mat.Name)); } } - var lodInfoarray = new ArrayProperty("LODInfo"); - float[] displayFactors = [1.0f, 0.25f, 0.1f]; - for (int i = 0; i < psks.Length; i++) - { - var currentLod = psks[i]; - var displayFactorProp = new FloatProperty(displayFactors[Math.Min(i, displayFactors.Length - 1)], "DisplayFactor"); - var bEnableShadowCastingProp = new ArrayProperty(Enumerable.Repeat(new BoolProperty(true), currentLod.Materials.Count), "bEnableShadowCasting"); - var TriangleSortingProp = new ArrayProperty(Enumerable.Repeat(new EnumProperty("TRISORT_None", "TriangleSortOption", pew.Pcc.Game), currentLod.Materials.Count), "TriangleSorting"); + var LODMaterialMapProp = new ArrayProperty(matMap.Select(x => new IntProperty(x)), "LODMaterialMap"); + var lodInfo = new StructProperty("SkeletalMeshLODInfo", false, + displayFactorProp, + new FloatProperty(0.2f, "LODHysteresis"), + LODMaterialMapProp, + bEnableShadowCastingProp, + TriangleSortingProp); + lodInfoProp.Add(lodInfo); + } - var matMap = new List(currentLod.Materials.Count); - // to match vanilla, LOD0 has an empty array - if (i != 0) - { - foreach (var mat in currentLod.Materials) - { - matMap.Add(materials.IndexOf(mat.Name)); - } - } + return meshBin; - var LODMaterialMapProp = new ArrayProperty(matMap.Select(x => new IntProperty(x)), "LODMaterialMap"); - var lodInfo = new StructProperty("SkeletalMeshLODInfo", false, - displayFactorProp, - new FloatProperty(0.2f, "LODHysteresis"), - LODMaterialMapProp, - bEnableShadowCastingProp, - TriangleSortingProp); - lodInfoarray.Add(lodInfo); - } - - meshExport.WriteProperty(lodInfoarray); - } static (Influences bones, Influences influences) DistributeWeights(IEnumerable<(byte bone, float weight)> weights) { @@ -724,6 +690,91 @@ byte GetMappedBoneIndex(PSK.PSKWeight influence) } } + public static void ImportPskAsNewMesh(PackageEditorWindow pew) + { + if (pew.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (pew.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetPskFromFile(out var psks, out var path)) + { + if (!psks[0].Bones.Any()) + { + throw new NotImplementedException("You can't make a static mesh yet"); + } + + var meshBin = CreateSkeletalMeshFromPsks(pew, psks, out var lodInfoProp); + + var meshExport = ExportCreator.CreateExport(pew.Pcc, Path.GetFileNameWithoutExtension(path), "SkeletalMesh"); + + meshExport.WriteBinary(meshBin); + + // copy the sockets from the selected mesh onto the new one + if (GetSelectedItem(pew, "SkeletalMesh", out var selectedMesh)) + { + var oldSocketsProp = selectedMesh.GetProperty>("Sockets"); + if (oldSocketsProp != null) + { + var newSocketsProp = new ArrayProperty("Sockets"); + foreach (var socket in oldSocketsProp) + { + var newEntry = EntryCloner.CloneEntry(socket.ResolveToEntry(pew.Pcc), incrementIndex: false); + newEntry.Parent = meshExport; + newSocketsProp.Add(new ObjectProperty(newEntry)); + } + meshExport.WriteProperty(newSocketsProp); + } + } + + meshExport.WriteProperty(lodInfoProp); + } + + } + + public static void ImportPskOverMesh(PackageEditorWindow pew) + { + if (pew.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (pew.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetSelectedItem(pew, "SkeletalMesh", out var selectedMesh)) + { + if (GetPskFromFile(out var psks, out var path)) + { + if (!psks[0].Bones.Any()) + { + throw new NotImplementedException("You can't make a static mesh yet"); + } + + var meshBin = CreateSkeletalMeshFromPsks(pew, psks, out var lodInfoProp); + selectedMesh.WriteBinary(meshBin); + + var newProps = new PropertyCollection(); + var oldSocketsProp = selectedMesh.GetProperty>("Sockets"); + if (oldSocketsProp != null) + { + newProps.Add(oldSocketsProp); + } + + newProps.Add(lodInfoProp); + + selectedMesh.WriteProperties(newProps); + } + } + else + { + ShowError("You must select an existing SkelelalMesh to replace"); + } + } + private class TempVertex { public ushort Index { get; set; } diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml index 8292b6d43..7b8aa88a1 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml @@ -182,6 +182,7 @@ + diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 5be100b80..9c71baf73 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1355,6 +1355,11 @@ private void ExportRonToPsx_Click(object sender, RoutedEventArgs e) PackageEditorExperimentsSquid.RonFileToPskx(GetPEWindow()); } + private void ImportPskOverMesh_Click(object sender, RoutedEventArgs e) + { + PackageEditorExperimentsSquid.ImportPskOverMesh(GetPEWindow()); + } + // import a mesh like object private void ImportPskAsNewMesh_Click(object sender, RoutedEventArgs e) { From caccf518e94c82619f68d50391e293ae1ac500d0 Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Wed, 10 Dec 2025 15:51:22 -0500 Subject: [PATCH 05/34] added a new experiment to export textures from a MIC, effects mat, or base mat. Also added texture export to the Skeletal Mesh export. --- .../PackageEditorExperimentsSquid.cs | 162 +++++++++++++++++- .../ExperimentsMenuControl.xaml | 3 +- .../ExperimentsMenuControl.xaml.cs | 5 + 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index fb70d93c2..e8fddfc5c 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -10,6 +10,7 @@ using LegendaryExplorerCore.Save; using LegendaryExplorerCore.Unreal; using LegendaryExplorerCore.Unreal.BinaryConverters; +using LegendaryExplorerCore.Unreal.ObjectInfo; using LegendaryExplorerCore.UnrealScript; using LegendaryExplorerCore.UnrealScript.Compiling.Errors; using Microsoft.Win32; @@ -108,7 +109,6 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } - private static SkeletalMesh CreateSkeletalMeshFromPsks(PackageEditorWindow pew, PSK[] psks, out ArrayProperty lodInfoProp) { var meshBin = SkeletalMesh.Create(); @@ -775,6 +775,154 @@ public static void ImportPskOverMesh(PackageEditorWindow pew) } } + public static void ExportTexturesFromMaterial(PackageEditorWindow pew) + { + if (GetSelectedItem(pew, ["MaterialInstanceConstant", "BioMaterialInstanceConstant", "Material", "RvrEffectsMaterialUser"], out var materialExport)) + { + var saveFolderDialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "Select destination folder", + UseDescriptionForTitle = true + }; + if (saveFolderDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + { + var saveFolder = saveFolderDialog.SelectedPath; + ExportMaterialTextures(materialExport, saveFolder); + } + } + else + { + ShowError("You must select a MaterialInstanceConstant, BioMaterialInstanceConstant, Material, or RvrEffectsMaterialUser"); + } + } + + private static void ExportMaterialTextures(ExportEntry materialExport, string exportDirectory, Dictionary textureExports = null) + { + textureExports ??= []; + List baseTextures = []; + var cache = new PackageCache(); + + delegateByType(materialExport); + + foreach (var tex in baseTextures) + { + if (!tex.IsA("Texture2D")) + { + continue; + } + var texture = new Texture2D(tex); + var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); + if (!File.Exists(exportPath)) + { + texture.ExportToPNG(exportPath); + } + } + + foreach (var tex in textureExports.Values) + { + if (!tex.IsA("Texture2D")) + { + continue; + } + var texture = new Texture2D(tex); + var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); + if (!File.Exists(exportPath)) + { + texture.ExportToPNG(exportPath); + } + } + + void delegateByType(ExportEntry materialEntry) + { + var selectedEntryClass = materialEntry.ClassName; + if (materialEntry.ClassName == "Material") + { + ExportBaseMaterialTextures(materialEntry); + } + else if (materialEntry.IsA("MaterialInstanceConstant")) + { + ExportMICTextures(materialEntry); + } + else if (materialEntry.IsA("RvrEffectsMaterialUser")) + { + ExportEffectMatUserTextures(materialEntry); + + } + else + { + return; + } + } + + void ExportEffectMatUserTextures(ExportEntry effectsMatEntry) + { + // for this, just get the base material stuff + if (effectsMatEntry.GetProperty("m_pBaseMaterial", cache).TryResolveExport(effectsMatEntry.FileRef, cache, out var baseMat)) + { + delegateByType(baseMat); + } + } + + void ExportMICTextures(ExportEntry micExport) + { + // get anything from the texture Parameters + var texParamsProp = micExport.GetProperty>("TextureParameterValues", cache); + if (texParamsProp != null) + { + foreach (var texParam in texParamsProp) + { + var paramName = texParam.GetProp("ParameterName").Value.Instanced; + if (!textureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) + { + textureExports.Add(paramName, value); + } + } + } + // then go to the parent, if it exists + if (micExport.GetProperty("Parent", cache).TryResolveExport(micExport.FileRef, cache, out var parent)) + { + delegateByType(parent); + } + } + + void ExportBaseMaterialTextures(ExportEntry baseMatEntry) + { + var expressions = baseMatEntry.GetProperty>("Expressions"); + if (expressions == null) + { + return; + } + + var matBin = ObjectBinary.From(baseMatEntry); + if (matBin.SM3MaterialResource.UniformExpressionTextures != null) + { + foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) + { + if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + { + // skip the really dumb textures + if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + baseTextures.Add(tex); + } + } + } + + // Read default expressions + foreach (var expr in expressions.Select(x => x.ResolveToEntry(baseMatEntry.FileRef)).Where(x => x != null && x.IsA("MaterialExpressionTextureSampleParameter")).OfType()) + { + var paramName = expr.GetProperty("ParameterName")?.Value.Instanced ?? "None"; + + if (!textureExports.ContainsKey(paramName) && expr.GetProperty("Texture").TryResolveExport(baseMatEntry.FileRef, cache, out var value)) + { + textureExports.Add(paramName, value); + } + } + } + } + private class TempVertex { public ushort Index { get; set; } @@ -807,6 +955,18 @@ public static void ExportSelectedToPsx(PackageEditorWindow pew) { PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^5]}_LOD{i}.pskx"); } + // export the textures as well + var textureDirectory = $"{d.FileName[..^5]}_Textures"; + Directory.CreateDirectory(textureDirectory); + foreach (var matIdx in meshBin.Materials) + { + var entry = pew.Pcc.GetEntry(matIdx); + if (entry != null) + { + var matExport = SharedMethods.ResolveEntryToExport(entry, new PackageCache()); + ExportMaterialTextures(matExport, textureDirectory); + } + } } return; case "AnimSet": diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml index 7b8aa88a1..6330c655d 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml @@ -175,6 +175,7 @@ + @@ -182,7 +183,7 @@ - + diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 9c71baf73..1e1ede1a0 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1350,6 +1350,11 @@ private void ExportSelectedToPsx_Click(object sender, RoutedEventArgs e) PackageEditorExperimentsSquid.ExportSelectedToPsx(GetPEWindow()); } + private void ExportSelectedMaterial_Click(object sender, RoutedEventArgs e) + { + PackageEditorExperimentsSquid.ExportTexturesFromMaterial(GetPEWindow()); + } + private void ExportRonToPsx_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.RonFileToPskx(GetPEWindow()); From 8731a42d90f6406f201fb8c66e25ca985dbe66da Mon Sep 17 00:00:00 2001 From: "DropTheSquid17@gmail.com" Date: Wed, 31 Dec 2025 20:46:36 -0500 Subject: [PATCH 06/34] implemented static mesh export. It works well so far in my testing. I need to make sure I didn't break anything else (i probably did) and test more edge cases. --- .../PackageEditorExperimentsSquid.cs | 111 +++++++++----- .../LegendaryExplorerCore/Unreal/PSK.cs | 138 +++++++++++++++++- 2 files changed, 205 insertions(+), 44 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index e8fddfc5c..be68c6abe 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -945,54 +945,32 @@ public static void ExportSelectedToPsx(PackageEditorWindow pew) switch (selectedEntryClass) { case "SkeletalMesh": - // export the skeletal mesh as a psk - var d = new SaveFileDialog { Filter = "PSKX|*.pskx", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; - if (d.ShowDialog() == true) - { - var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); - PSK.CreateFromSkeletalMesh(meshBin, 0, true).ToFile(d.FileName); - for (int i = 1; i < meshBin.LODModels.Length; i++) - { - PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^5]}_LOD{i}.pskx"); - } - // export the textures as well - var textureDirectory = $"{d.FileName[..^5]}_Textures"; - Directory.CreateDirectory(textureDirectory); - foreach (var matIdx in meshBin.Materials) - { - var entry = pew.Pcc.GetEntry(matIdx); - if (entry != null) - { - var matExport = SharedMethods.ResolveEntryToExport(entry, new PackageCache()); - ExportMaterialTextures(matExport, textureDirectory); - } - } - } + ExportSkeletalMeshToPskx(pew); return; case "AnimSet": case "BioDynamicAnimSet": - ExportAnimSet(pew); + ExportAnimSetToPsa(pew); return; case "AnimSequence": - ExportAnimSequence(pew); + ExportAnimSequenceToPsa(pew); + return; + case "StaticMesh": + ExportStaticMeshToPskx(pew); return; - //case "StaticMesh": - // ExportStaticMeshToPSKX(pew); - // return; case "BioMorphFace": BioMorphFaceToPskxAndPsa(pew); return; case "MorphTargetSet": - ExportMorphTargetSet(pew); + ExportMorphTargetSetToPskxAndPsa(pew); return; - // TODO support StaticMesh, BrushComponent, FracturedStaticMesh, etc. There are a few other mesh like objects it might be nice to be able to edit, but very low priority? + // TODO support BrushComponent, FracturedStaticMesh, etc. There are a few other mesh like objects it might be nice to be able to edit, but very low priority? default: - ShowError("You must open a pcc file and select a SkeletalMesh, BioMorphFace, MorphTargetSet, AnimSet, or AnimSequence for this experiment"); + ShowError("You must open a pcc file and select a SkeletalMesh, StaticMesh, BioMorphFace, MorphTargetSet, AnimSet, or AnimSequence for this experiment"); return; } } - private static void ExportAnimSequence(PackageEditorWindow pew) + private static void ExportAnimSequenceToPsa(PackageEditorWindow pew) { if (GetSelectedItem(pew, "AnimSequence", out var animSeqExport)) { @@ -1006,7 +984,7 @@ private static void ExportAnimSequence(PackageEditorWindow pew) } } - private static void ExportAnimSet(PackageEditorWindow pew) + private static void ExportAnimSetToPsa(PackageEditorWindow pew) { if (GetSelectedItem(pew, ["AnimSet", "BioDynamicAnimSet"], out var animSetExport)) { @@ -1022,11 +1000,66 @@ private static void ExportAnimSet(PackageEditorWindow pew) } } - //private static void ExportStaticMeshToPSKX(PackageEditorWindow pew) - //{ - // // TODO implement this - // throw new NotImplementedException("I haven't implemented exporting static meshes yet."); - //} + private static void ExportSkeletalMeshToPskx(PackageEditorWindow pew) + { + var d = new SaveFileDialog { Filter = "PSKX|*.pskx", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; + if (d.ShowDialog() == true) + { + var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); + PSK.CreateFromSkeletalMesh(meshBin, 0, true).ToFile(d.FileName); + for (int i = 1; i < meshBin.LODModels.Length; i++) + { + PSK.CreateFromSkeletalMesh(meshBin, i, true).ToFile($"{d.FileName[..^5]}_LOD{i}.pskx"); + } + // export the textures as well + var textureDirectory = $"{d.FileName[..^5]}_Textures"; + Directory.CreateDirectory(textureDirectory); + foreach (var matIdx in meshBin.Materials) + { + var entry = pew.Pcc.GetEntry(matIdx); + if (entry != null) + { + var matExport = SharedMethods.ResolveEntryToExport(entry, new PackageCache()); + ExportMaterialTextures(matExport, textureDirectory); + } + } + } + } + + private static void ExportStaticMeshToPskx(PackageEditorWindow pew) + { + // for now, only support ME3 and LE. ME1 and ME2 have a different static mesh format. + if (!(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) + { + ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + } + + var d = new SaveFileDialog { Filter = "PSKX|*.pskx", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; + if (d.ShowDialog() == true) + { + var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); + PSK.CreateFromStaticMesh(meshBin, 0).ToFile(d.FileName); + for (int i = 1; i < meshBin.LODModels.Length; i++) + { + PSK.CreateFromStaticMesh(meshBin, i).ToFile($"{d.FileName[..^5]}_LOD{i}.pskx"); + } + // TODO export the collision mesh, if present + // export the textures as well + var textureDirectory = $"{d.FileName[..^5]}_Textures"; + Directory.CreateDirectory(textureDirectory); + // get all the material indices across all LODs + var materials = meshBin.LODModels.SelectMany(x => x.Elements.Select(y => y.Material)).Distinct(); + foreach (var matIdx in materials) + { + var entry = pew.Pcc.GetEntry(matIdx); + if (entry != null) + { + var matExport = SharedMethods.ResolveEntryToExport(entry, new PackageCache()); + ExportMaterialTextures(matExport, textureDirectory); + } + } + } + } private static void BioMorphFaceToPskxAndPsa(PackageEditorWindow pew) { @@ -2931,7 +2964,7 @@ private static bool ApproximatelyEqual(Vector3 first, Vector3 second) return false; } - private static void ExportMorphTargetSet(PackageEditorWindow pew) + private static void ExportMorphTargetSetToPskxAndPsa(PackageEditorWindow pew) { if (!GetSelectedItem(pew, "MorphTargetSet", out var morphTargetSet)) { diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs index edeca661a..648417ba2 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; +using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Unreal.BinaryConverters; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; -using LegendaryExplorerCore.Packages; -using LegendaryExplorerCore.Unreal.BinaryConverters; namespace LegendaryExplorerCore.Unreal { @@ -20,6 +21,7 @@ public class PSK public List VertexNormals; public List Morphs; public List MorphData; + public List ExtraUVs; private const int version = 1999801; @@ -128,6 +130,19 @@ protected void Serialize(SerializingContainer sc) sc.Serialize(ref morphDataHeader); sc.Serialize(ref MorphData, morphDataHeader.DataCount, sc.Serialize); } + + if (ExtraUVs != null && ExtraUVs.Count != 0) + { + var extraUVsHeader = new PSA.ChunkHeader + { + ChunkID = "EXTRAUVS", + Version = version, + DataSize = 0x8, + DataCount = ExtraUVs.Count + }; + sc.Serialize(ref extraUVsHeader); + sc.Serialize(ref ExtraUVs, extraUVsHeader.DataCount, sc.Serialize); + } } } @@ -158,6 +173,119 @@ public static PSK FromFile(string filePath) return psk; } + public static PSK CreateFromStaticMesh(StaticMesh staticMesh, int lodIdx = 0) + { + var lod = staticMesh.LODModels[lodIdx]; + + var numVertices = lod.NumVertices; + + var psk = new PSK + { + Points = [], + Wedges = [], + Faces = [], + Materials = [], + VertexNormals = [], + ExtraUVs = [] + }; + + int numTriangles = 0; + var matIndices = new byte[numVertices]; + var useFullPrecisionUVs = lod.VertexBuffer.bUseFullPrecisionUVs; + + // account for any weirdness in the materials and indices, or multiple elements with the same material + int[] mats = [.. lod.Elements.Select(x => x.Material).Distinct()]; + foreach (var element in lod.Elements) + { + element.MaterialIndex = mats.IndexOf(element.Material); + } + + foreach (var matUIndex in mats) + { + psk.Materials.Add(new PSKMaterial + { + Name = staticMesh.Export.FileRef.GetEntry(matUIndex)?.ObjectName.Instanced ?? "" + }); + } + + // allocate the space we need for all extra UVs + var numberExtraUvs = lod.VertexBuffer.NumTexCoords - 1; + Vector2[] tempExtraUVs = []; + if (numberExtraUvs > 0) + { + tempExtraUVs = new Vector2[(int)numVertices * (int)numberExtraUvs]; + } + + + foreach (var element in lod.Elements) + { + numTriangles += (int)element.NumTriangles; + for (uint t = 0; t < element.NumTriangles; t++) + { + // TODO first index vs minVertexIndex? seem to match in all cases I have seen + uint baseIndex = element.FirstIndex; + // TODO sometimes the index buffer might not be there (according to other comments in LEX) in which case we have to look at triangles in KDOPS + int i1 = lod.IndexBuffer[baseIndex + t * 3]; + int i2 = lod.IndexBuffer[baseIndex + t * 3 + 1]; + int i3 = lod.IndexBuffer[baseIndex + t * 3 + 2]; + byte materialIndex = (byte)element.MaterialIndex; + matIndices[i1] = materialIndex; + matIndices[i2] = materialIndex; + matIndices[i3] = materialIndex; + psk.Faces.Add(new PSKTriangle + { + // intentionally flipped; corner ordering determines normal direction, and flipped normals will mess everything up + WedgeIdx1 = (ushort)i1, + WedgeIdx0 = (ushort)i2, + WedgeIdx2 = (ushort)i3, + MatIndex = materialIndex + }); + } + } + + foreach (var pos in lod.PositionVertexBuffer.VertexData) + { + psk.Points.Add(new Vector3(pos.X, pos.Y * -1, pos.Z)); + } + + for (int i = 0; i < lod.VertexBuffer.VertexData.Length; i++) + { + var vert = lod.VertexBuffer.VertexData[i]; + + // vertex normal + var vertNorm = (Vector3)vert.TangentZ; + vertNorm = vertNorm with { Y = -vertNorm.Y }; + vertNorm = Vector3.Normalize(vertNorm); + psk.VertexNormals.Add(vertNorm); + + // wedges + psk.Wedges.Add(new PSKWedge + { + MatIndex = matIndices[i], + PointIndex = (ushort)i, + U = useFullPrecisionUVs ? vert.HalfPrecisionUVs[0].X : vert.HalfPrecisionUVs[0].X, + V = useFullPrecisionUVs ? vert.HalfPrecisionUVs[0].Y : vert.HalfPrecisionUVs[0].Y + }); + + // extra UVs in the pskx are laid out with all of UV1, then all of UV2, with the length matching the number of wedges, which in this case is the same as the number of points + for (int j = 1; j < lod.VertexBuffer.NumTexCoords; j++) + { + var extraUV = new Vector2(useFullPrecisionUVs ? vert.HalfPrecisionUVs[j].X : vert.HalfPrecisionUVs[j].X, useFullPrecisionUVs ? vert.HalfPrecisionUVs[j].Y : vert.HalfPrecisionUVs[j].Y); + tempExtraUVs[i + ((j - 1) * (int)numVertices)] = extraUV; + } + } + + psk.ExtraUVs = [.. tempExtraUVs]; + + return psk; + } + + // used for static mesh collision, among other things + public static PSK CreateFromAggGeom(StructProperty aggGeom) + { + throw new NotImplementedException(); + } + public static PSK CreateFromSkeletalMesh(SkeletalMesh skelMesh, int lodIdx = 0, bool includeVertexNormals = false) { var lod = skelMesh.LODModels[lodIdx]; @@ -177,7 +305,7 @@ public static PSK CreateFromSkeletalMesh(SkeletalMesh skelMesh, int lodIdx = 0, }; int numTriangles = 0; var matIndices = new byte[numVertices]; - int[] materialMapping = Enumerable.Range(0, skelMesh.Materials.Length).ToArray(); + int[] materialMapping = [.. Enumerable.Range(0, skelMesh.Materials.Length)]; if (skelMesh.Export != null) { var LODInfo = skelMesh.Export.GetProperty>("LODInfo"); @@ -186,7 +314,7 @@ public static PSK CreateFromSkeletalMesh(SkeletalMesh skelMesh, int lodIdx = 0, var matMap = LODInfo[lodIdx].GetProp>("LODMaterialMap"); if (matMap != null && matMap.Count > 0) { - materialMapping = matMap.Select(x => x.Value).ToArray(); + materialMapping = [.. matMap.Select(x => x.Value)]; } } } From 78e95191b2f074be3fdf6837a50a40df8d4b4536 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Sat, 17 Jan 2026 09:45:07 -0500 Subject: [PATCH 07/34] fixed a bug reported by Sidious that caused BioMorphFace export to fail if the base head was an import rather than an export. --- .../PackageEditor/Experiments/PackageEditorExperimentsSquid.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index be68c6abe..016cbda71 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -1073,8 +1073,7 @@ private static void BioMorphFaceToPskxAndPsa(PackageEditorWindow pew) var d = new SaveFileDialog { Filter = "PSKX|*.pskx" , FileName = bmf.ObjectNameString}; if (d.ShowDialog() == true) { - - var baseHeadMesh = pew.Pcc.GetEntry(bmf.GetProperty("m_oBaseHead").Value) as ExportEntry; + var baseHeadMesh = SharedMethods.ResolveEntryToExport(pew.Pcc.GetEntry(bmf.GetProperty("m_oBaseHead").Value), new PackageCache()); var baseMeshBin = baseHeadMesh.GetBinaryData(); var bmfBin = bmf.GetBinaryData(); From 28f7f94ad29028a733e934b2eb57ae733359905b Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 20 Jan 2026 16:08:32 -0500 Subject: [PATCH 08/34] fixed a bug in PSK texture export so it exports base material textures even if there are no expressions. --- .../PackageEditorExperimentsSquid.cs | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 016cbda71..91668edd7 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -732,7 +732,6 @@ public static void ImportPskAsNewMesh(PackageEditorWindow pew) meshExport.WriteProperty(lodInfoProp); } - } public static void ImportPskOverMesh(PackageEditorWindow pew) @@ -885,31 +884,50 @@ void ExportMICTextures(ExportEntry micExport) } } - void ExportBaseMaterialTextures(ExportEntry baseMatEntry) + void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExpressionTextures = true) { - var expressions = baseMatEntry.GetProperty>("Expressions"); - if (expressions == null) + if (includeUniformExpressionTextures) { - return; - } + var matBin = ObjectBinary.From(baseMatEntry); + if (matBin.SM2MaterialResource.UniformExpressionTextures != null) + { + foreach (var texIdx in matBin.SM2MaterialResource.UniformExpressionTextures) + { + if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + { + // skip the really dumb textures + if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + baseTextures.Add(tex); + } + } + } - var matBin = ObjectBinary.From(baseMatEntry); - if (matBin.SM3MaterialResource.UniformExpressionTextures != null) - { - foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) + if (matBin.SM3MaterialResource.UniformExpressionTextures != null) { - if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) { - // skip the really dumb textures - if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) { - continue; + // skip the really dumb textures + if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + baseTextures.Add(tex); } - baseTextures.Add(tex); } } } + var expressions = baseMatEntry.GetProperty>("Expressions"); + if (expressions == null) + { + return; + } + // Read default expressions foreach (var expr in expressions.Select(x => x.ResolveToEntry(baseMatEntry.FileRef)).Where(x => x != null && x.IsA("MaterialExpressionTextureSampleParameter")).OfType()) { @@ -922,7 +940,7 @@ void ExportBaseMaterialTextures(ExportEntry baseMatEntry) } } } - + private class TempVertex { public ushort Index { get; set; } From 1f23a68b05fd41024ec6ad2bcc9c17e6269ccec7 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 20 Jan 2026 16:10:23 -0500 Subject: [PATCH 09/34] Started building out glTF pipeline. I think basic skeletal mesh export is working correctly. Import is still only half implemented. --- .../PackageEditor/Experiments/SquidGltf.cs | 806 ++++++++++++++++++ .../ExperimentsMenuControl.xaml | 2 + .../ExperimentsMenuControl.xaml.cs | 10 + .../LegendaryExplorerCore.csproj | 1 + 4 files changed, 819 insertions(+) create mode 100644 LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs new file mode 100644 index 000000000..b8496cc63 --- /dev/null +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -0,0 +1,806 @@ +using LegendaryExplorerCore.Gammtek.Extensions; +using LegendaryExplorerCore.Helpers; +using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Unreal.BinaryConverters; +using Microsoft.Win32; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; +using SharpGLTF.Schema2; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace LegendaryExplorer.Tools.PackageEditor.Experiments +{ + public class SquidGltf + { + public static void ImportGltf(PackageEditorWindow pew) + { + if (GetGltfFromFile(out var gltf, out string _)) + { + foreach (var node in gltf.LogicalNodes) + { + // TODO sort meshes to group them into LODs + if (!node.Mesh.IsNull()) + { + var intermediateMesh = ToIntermediateMesh(node); + if (node.Skin.IsNull()) + { + //ImportStaticMesh(node); + } + else + { + ImportSkeletalMesh(intermediateMesh, pew.Pcc); + } + } + } + } + } + + public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) + { + var d = new SaveFileDialog { Filter = "glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; + if (d.ShowDialog() == true) + { + // TODO check that the selected item is a skeletal mesh + var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); + var intermediateMesh = ToIntermediateMesh(meshBin); + var gltf = ToGltf(intermediateMesh); + gltf.SaveGLTF(d.FileName); + } + } + + private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) + { + var intermediateMesh = new IntermediateMesh() + { + Name = mesh.Export.ObjectName.Instanced + }; + + // materials + foreach (var mat in mesh.Materials) + { + string name; + if (mat == 0) + { + name = "null"; + } + else + { + name = mesh.Export.FileRef.GetEntry(mat).MemoryFullPath; + } + intermediateMesh.Materials.Add(name); + } + + // skeleton + intermediateMesh.Skeleton = []; + for (int i = 0; i < mesh.RefSkeleton.Length; i++) + { + var bone = mesh.RefSkeleton[i]; + intermediateMesh.Skeleton.Add(new IntermediateBone() + { + Index = i, + Name = bone.Name.Instanced, + ParentIndex = bone.ParentIndex, + NumChildren = bone.NumChildren, + Position = bone.Position, + Rotation = bone.Orientation + }); + } + + // LODs + for (int i = 0; i < mesh.LODModels.Length; i++) + { + intermediateMesh.LODs.Add(ToIntermediateLod(mesh.LODModels[i], i)); + } + return intermediateMesh; + } + + const float weightUnpackScale = 1f / 255; + private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) + { + var intermediateLod = new IntermediateLOD() + { + Index = index + }; + + List vertices = []; + + for (int i = 0; i < lod.VertexBufferGPUSkin.VertexData.Length; i++) + { + var originalVertex = lod.VertexBufferGPUSkin.VertexData[i]; + // we need to find the chunk containing this vertex + var chunk = lod.Chunks.Last(x => x.BaseVertexIndex <= i); + List<(int influenceBone, float weight)> influences = []; + for (int j = 0; j < 4; j++) + { + var bone = chunk.BoneMap[originalVertex.InfluenceBones[j]]; + var weight = originalVertex.InfluenceWeights[j] * weightUnpackScale; + if (weight > 0) + { + influences.Add((bone, weight)); + } + } + vertices.Add(new IntermediateVertex() + { + Index = i, + Position = originalVertex.Position, + Normal = (Vector3)originalVertex.TangentZ, + Tangent = (Vector3)originalVertex.TangentX, + OriginalIndex = i, + UVs = [originalVertex.UV], + Influences = influences, + BiTangentDirection = originalVertex.TangentZ.W / 127.5f - 1 + }); + } + + foreach (var section in lod.Sections) + { + var intermediateSection = new IntermediateMeshSection + { + MaterialIndex = section.MaterialIndex, + // use the same vertices for all mesh sections so we don't need to reindex all the triangles + Vertices = vertices + }; + + for (int i = (int)section.BaseIndex; i < section.BaseIndex + section.NumTriangles * 3; i += 3) + { + intermediateSection.Triangles.Add(new IntermediateTriangle() + { + VertIndex1 = lod.IndexBuffer[i], + VertIndex2 = lod.IndexBuffer[i + 1], + VertIndex3 = lod.IndexBuffer[i + 2], + }); + } + + intermediateLod.Sections.Add(intermediateSection); + } + + + return intermediateLod; + } + + /// + /// glTF uses y up axis conventions. Convert this to the unreal conventions of z up and proper y direction + /// + private static Vector3 Yup2Zup(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y); + } + + private static Vector3 ScaleForGltf(Vector3 input) + { + return input / 100; + } + + private static Vector3 ScaleForME(Vector3 input) + { + return input * 100; + } + + private static Quaternion Yup2Zup(Quaternion input) + { + var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); + return Quaternion.Normalize(input * transformQuat); + } + + + private static Vector3 Zup2Yup(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y); + } + + private static Quaternion Zup2Yup(Quaternion input) + { + // TODO check this works + var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); + return Quaternion.Normalize(input / transformQuat); + } + + private static ModelRoot ToGltf(params IntermediateMesh[] meshes) + { + var scene = new SceneBuilder(); + + foreach (var mesh in meshes) + { + List mats = []; + foreach (var matName in mesh.Materials) + { + var mat = new MaterialBuilder(matName) + .WithBaseColor(new Vector4(.5f, .5f, .5f, 1)); + // TODO support diff, norm, etc here + mats.Add(mat); + } + + // skeleton + var baseSkeletonNode = new NodeBuilder(mesh.Name); + scene.AddNode(baseSkeletonNode); + var skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; + // one pass to create all the nodes without the hirarchy + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var bone = mesh.Skeleton[i]; + var nb = new NodeBuilder(bone.Name); + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; change the local transform to account for the coordiante system differences + nb.WithLocalTranslation(ScaleForGltf(Zup2Yup(bone.Position))) + .WithLocalRotation(Zup2Yup(bone.Rotation)); + baseSkeletonNode.AddNode(nb); + } + else + { + nb.WithLocalTranslation(ScaleForGltf(bone.Position)) + .WithLocalRotation(bone.Rotation); + } + skeletonNodes[i] = nb; + } + // another pass to connect the hierarchy up + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var bone = mesh.Skeleton[i]; + var nb = skeletonNodes[i]; + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; we don't need to do anything here + continue; + } + else + { + var parent = skeletonNodes[bone.ParentIndex]; + parent.AddNode(nb); + } + } + + + foreach (var lod in mesh.LODs) + { + var name = $"{mesh.Name}_LOD_{lod.Index}"; + // TODO this is skeletal mesh data; make it work for static meshes too + var mb = new MeshBuilder(name); + + foreach (var section in lod.Sections) + { + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + foreach (var tri in section.Triangles) + { + VertexBuilder GetVert(int i) + { + var intermediateVert = section.Vertices[i]; + return new VertexBuilder() + .WithGeometry(ScaleForGltf(Zup2Yup(intermediateVert.Position)), Zup2Yup(intermediateVert.Normal.Value)) + .WithMaterial([..intermediateVert.UVs]) + .WithSkinning(intermediateVert.Influences); + } + // TODO check order + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + } + } + + var skinnedMesh = scene.AddSkinnedMesh(mb, Matrix4x4.Identity, skeletonNodes); + skinnedMesh.WithName(name); + } + } + + var gltf = scene.ToGltf2(); + // TODO add version info? + gltf.Asset.Generator = "Legendary Explorer"; + + return gltf; + } + private static IntermediateMesh ToIntermediateMesh(params Node[] nodes) + { + if (nodes.Length == 0) + { + throw new ArgumentException(); + } + int vertIndex = 0; + int triangleIndex = 0; + int lodIndex = 0; + int boneIndex = 0; + // maps from material index within the gltf file to materials within this mesh (in the array order) + List materialMap = []; + + // maps from bone order within the gltf file to bone order within this mesh + List boneMap = []; + + var intermediateMesh = new IntermediateMesh(); + + if (nodes[0].Skin != null) + { + intermediateMesh.Skeleton = []; + foreach (var joint in nodes[0].Skin.Joints) + { + boneMap.Add(joint.LogicalIndex); + intermediateMesh.Skeleton.Add(new IntermediateBone() + { + Index = boneIndex++, + Name = joint.Name, + // position is relative to the parent bone in both storage systems, so we only need to flip y to account for the different y direction convention + Position = joint.LocalTransform.Translation with { Y = -joint.LocalTransform.Translation.Y }, + // rotation is also relative to the parent, and not messed up by the y axis difference, so we need to leave it alone + Rotation = joint.LocalTransform.Rotation, + }); + } + // reconstruct the hierarchy of bones from the node hierarchy. There can be other nodes in between joints, so we have to check all ancestors + List rootJoints = []; + foreach (var joint in nodes[0].Skin.Joints) + { + Node FindJointParent(Node node) + { + Node jointParent = null; + while (node.VisualParent != null) + { + node = node.VisualParent; + + if (nodes[0].Skin.Joints.Contains(node)) + { + return node; + } + } + return jointParent; + } + var parent = FindJointParent(joint); + var intermediateJointIndex = boneMap.IndexOf(joint.LogicalIndex); + + if (parent == null) + { + rootJoints.Add(intermediateJointIndex); + // for root bones, we need to adjust the rotation and position to account for the coordinate system differences + intermediateMesh.Skeleton[intermediateJointIndex].ParentIndex = -1; + intermediateMesh.Skeleton[intermediateJointIndex].Position = ScaleForME(Yup2Zup(joint.LocalTransform.Translation)); + // we need to rotate the root node's rotation to account for the difference in coordinate systems between ME and glTF (glTF uses y up) + intermediateMesh.Skeleton[intermediateJointIndex].Rotation = Yup2Zup(joint.LocalTransform.Rotation); + } + else + { + var intermediateParentIndex = boneMap.IndexOf(parent.LogicalIndex); + intermediateMesh.Skeleton[intermediateJointIndex].ParentIndex = intermediateParentIndex; + intermediateMesh.Skeleton[intermediateParentIndex].NumChildren++; + } + } + if (rootJoints.Count > 1) + { + // TODO make a new fake root bone? + // just leave it alone? does ME technically require a single root bone? + throw new NotImplementedException("This skeleton doesn't seem to have a single root bone, and I don't know how to handle that yet."); + } + } + + foreach (var node in nodes) + { + var LOD = new IntermediateLOD() { Index = lodIndex++ }; + // a primitive, for our uses, will roughly correspond to a material + // technically it corresponds to a GPU rendering pass, which can be other things, but is most likely to be a material for us. + foreach (var prim in node.Mesh.Primitives) + { + switch (prim.DrawPrimitiveType) + { + // we do not support points or lines outside the context of a triangle; ignore these if they come up, which is unlikely + case PrimitiveType.POINTS: + case PrimitiveType.LINES: + case PrimitiveType.LINE_STRIP: + case PrimitiveType.LINE_LOOP: + continue; + } + // material; the material indices in the glTF are global to the file, shared between meshes. We need to get to a list of materials for just this mesh + // each time we encounter a new material index, we will put it in an array + // we will count a null material as having an index of -1 and leave this material empty in LEX + var gltfMatIndex = prim.Material?.LogicalIndex ?? -1; + var meshMatIndex = materialMap.IndexOf(gltfMatIndex); + if (meshMatIndex == -1) + { + // TODO make sure this is not off by 1 + meshMatIndex = materialMap.Count; + materialMap.Add(gltfMatIndex); + intermediateMesh.Materials.Add(prim.Material?.Name ?? "Null"); + } + + var meshSection = new IntermediateMeshSection() + { + MaterialIndex = meshMatIndex + }; + + // this gets us all the attributes of each vertex in order, each in their own array, where all arrays are the same size + var vertColumns = prim.GetVertexColumns(); + + for (int i = 0; i < vertColumns.Positions.Count; i++) + { + var vert = new IntermediateVertex + { + Index = vertIndex++, + Position = ScaleForME(Yup2Zup(vertColumns.Positions[i])) + }; + + // Normals + // usually present, but not required + if (vertColumns.Normals != null) + { + vert.Normal = Yup2Zup(vertColumns.Normals[i]); + } + + // Tangents + // not required, but will be imported if present, calculated otherwise + if (vertColumns.Tangents != null) + { + var tanX = new Vector3(vertColumns.Tangents[i].X, vertColumns.Tangents[i].Y, vertColumns.Tangents[i].Z); + vert.Tangent = Yup2Zup(tanX); + vert.BiTangentDirection = vertColumns.Tangents[i].W; + } + + // UVs + void AddUV(IList? column) + { + if (column != null) + { + // TODO any signs to deal with? + vert.UVs.Add(column[i]); + } + } + // usually present + AddUV(vertColumns.TexCoords0); + // only present for some static meshes + AddUV(vertColumns.TexCoords1); + AddUV(vertColumns.TexCoords2); + AddUV(vertColumns.TexCoords3); + + // weights + // only present for skeletal meshes + if (vertColumns.Joints0 != null) + { + var bones = vertColumns.Joints0[i]; + var weights = vertColumns.Weights0[i]; + for (int j = 0; j < 4; j++) + { + vert.Influences.Add((int)bones[j], weights[j]); + } + } + // unlikely to be present, we just need to add them to make sure we cull the right ones later + if (vertColumns.Joints1 != null) + { + var bones = vertColumns.Joints1[i]; + var weights = vertColumns.Weights1[i]; + for (int j = 0; j < 4; j++) + { + var intermediateBoneIndex = boneMap.IndexOf((int)bones[j]); + vert.Influences.Add(intermediateBoneIndex, weights[j]); + } + } + + //meshSection.Vertices.Add(vert); + } + + // this gets us a list of int triplets; the indices of each triangle + var triIndices = prim.GetTriangleIndices(); + + foreach (var (v1, v2, v3) in triIndices) + { + // I think the vertex order is correct but need to check + var tri = new IntermediateTriangle() + { + //Index = triangleIndex++, + //MaterialIndex = meshMatIndex, + VertIndex1 = v2, + VertIndex2 = v3, + VertIndex3 = v1, + }; + meshSection.Triangles.Add(tri); + } + LOD.Sections.Add(meshSection); + } + intermediateMesh.LODs.Add(LOD); + } + + return intermediateMesh; + } + + private class IntermediateMesh + { + public string Name; + public List Materials = []; + // will be null for static meshes + public List Skeleton; + public List LODs = []; + // TODO collision mesh(es)? + + public IntermediateMesh() + { + } + } + + private class IntermediateMeshSection + { + public int MaterialIndex; + public List Triangles = []; + public List Vertices = []; + + public IntermediateMeshSection() + { + } + } + + private struct IntermediateTriangle + { + //public int Index; + public int VertIndex1; + public int VertIndex2; + public int VertIndex3; + } + + private class IntermediateLOD + { + public int Index; + public List Sections = []; + + public IntermediateLOD() + { + } + } + + private struct IntermediateVertex + { + public int Index; + // always required + public Vector3 Position; + // can be imported or calculated if need be + public Vector3? Normal; + // will be calculated + public Vector3? Tangent; + // will be calculated + public float BiTangentDirection; + // will usually be present. Expect length 1 for skeletal meshes, but static meshes can have multiple + public List UVs = []; + // only present for skeletal meshes. The engine supports a maximum of four influences, so that is the max length + public List<(int influenceBone, float weight)> Influences = []; + // no known use yet, but static meshes might support it + //Vector4 Color; + // used to store the original index when we export it from ME to glTF; can hopefully help us reconsitutue it later + public int OriginalIndex; + public IntermediateVertex() + { + } + } + + private class IntermediateBone + { + public int Index; + public string Name; + public int NumChildren; + public int ParentIndex; + public Vector3 Position; + public Quaternion Rotation; + } + + private static void ImportSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package) + { + var meshBin = SkeletalMesh.Create(); + SetupSkeleton(intermediateMesh.Skeleton, meshBin); + SetupBounds(intermediateMesh, meshBin); + SetupMaterials(intermediateMesh.Materials, meshBin, package); + foreach (var lod in intermediateMesh.LODs) + { + SetupLOD(intermediateMesh, lod, meshBin); + } + + static void SetupSkeleton(IList skeleton, SkeletalMesh meshBin) + { + // initialize the array to the right size + meshBin.RefSkeleton = new MeshBone[skeleton.Count]; + // keep track of the depth of each bone so we can get the overall skeletal depth + var skeletalDepth = Enumerable.Repeat(-1, skeleton.Count).ToArray(); + + int GetDepth(int i) + { + // check if we have already calculated this one + if (skeletalDepth[i] != -1) + { + return skeletalDepth[i]; + } + var parentIndex = skeleton[i].ParentIndex; + // check for the case that this is the root bone of the skeleton, where it points to itself (usually 0) as its own parent + if (parentIndex == -1 || parentIndex == i) + { + skeletalDepth[i] = 1; + return 1; + } + // next, get the depth of the parent + 1 + skeletalDepth[i] = GetDepth(parentIndex) + 1; + return skeletalDepth[i]; + } + + for (var i = 0; i < skeleton.Count; i++) + { + var currentBone = skeleton[i]; + meshBin.NameIndexMap.Add(currentBone.Name, i); + meshBin.RefSkeleton[i] = new MeshBone() + { + Name = currentBone.Name, + NumChildren = currentBone.NumChildren, + BoneColor = new LegendaryExplorerCore.SharpDX.Color(new Vector4(1, 1, 1, 1)), + // TODO do I need anything here? + Flags = 0, + ParentIndex = currentBone.ParentIndex, + Position = new Vector3(currentBone.Position.X, currentBone.Position.Y * -1, currentBone.Position.Z), + Orientation = new Quaternion(currentBone.Rotation.X, currentBone.Rotation.Y * -1, currentBone.Rotation.Z, currentBone.Rotation.W) + }; + + // make sure we calculate the depth + GetDepth(i); + } + + // now find the maximum depth and set that as the skeletal depth + meshBin.SkeletalDepth = skeletalDepth.Max(); + } + + static void SetupBounds(IntermediateMesh intermediateMesh, SkeletalMesh meshBin) + { + //// bounds are important at least for the camera display preview in LEX, and possibly important for when to cull meshes based on visibility in game + //// separate out the coordinates for each axis so we can operate on them + //var xCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.X); + //var yCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.Y); + //var zCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.Z); + + //// get the origin by averaging all vertex positions; it'll probably be close enough + //var origin = new Vector3(xCoords.Average(), yCoords.Average(), zCoords.Average()); + + //var xRange = xCoords.Select(coord => Math.Abs(coord - origin.X)).Max(); + //var yRange = yCoords.Select(coord => Math.Abs(coord - origin.Y)).Max(); + //var zRange = zCoords.Select(coord => Math.Abs(coord - origin.Z)).Max(); + //var boxExtent = new Vector3(xRange, yRange, zRange); + + //var sphereRad = boxExtent.Length(); + //meshBin.Bounds = new BoxSphereBounds + //{ + // Origin = origin, + // // best guess at a reasonable margin + // BoxExtent = boxExtent * 2, + // SphereRadius = sphereRad * 2 + //}; + } + + static void SetupMaterials(IList materials, SkeletalMesh meshBin, IMEPackage package) + { + SetNumMaterialSlots(meshBin, materials.Count); + for (int i = 0; i < materials.Count; i++) + { + // Does not work because it is looking for the full instanced path; can I export using that? + var entry = package.FindEntry(materials[i]); + // a good enough heuristic for now + entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); + entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); + if (entry != null) + { + meshBin.Materials[i] = entry.UIndex; + } + } + } + + static void SetupLOD(IntermediateMesh intermediateMesh, IntermediateLOD lod, SkeletalMesh meshBin) + { + // // TODO implement normal generation, maybe even with welding, angle threshold? + // if (lod.Vertices[0].Normal == null) + // { + // throw new NotImplementedException("I haven't implemented normal generation yet. export your glTF with normals."); + // } + // // TODO implement normal generation, maybe even with welding, angle threshold? + // if (lod.Vertices[0].Tangent == null) + // { + // throw new NotImplementedException("I haven't implemented tangent generation yet. export your glTF with tangents."); + // } + // SetupSectionsAndChunks(); + + // void SetupSectionsAndChunks() + // { + // if (intermediateMesh.Materials.Count == 1) + // { + + // } + // else + // { + // // TODO make this optional? + // // this is useful for draw order stuff, but not the only way to do it, and it might be nice to preserve ordering too + // if (true) + // { + // //lod.Triangles = [.. lod.Triangles.OrderBy(x => x.MaterialIndex)]; + // } + + // List> matGroups = []; + // //var currentMat = lod.Triangles[0].MaterialIndex; + // var currentGroup = new List(); + // foreach (var triangle in lod.Triangles) + // { + // if (triangle.MaterialIndex == currentMat) + // { + // currentGroup.Add(triangle); + // } + // else + // { + // currentMat = triangle.MaterialIndex; + // matGroups.Add(currentGroup); + // currentGroup = [triangle]; + // } + // } + + // List sections = []; + // var startIndex = 0; + // foreach (var matGroup in matGroups) + // { + // var mat = matGroup[0].MaterialIndex; + // var section = new MeshSection + // { + // Triangles = [.. matGroup], + // BaseTriIndex = startIndex, + // MatIndex = mat, + // }; + + // // calculate the min and max vertex indices within this section + // var sectionIndices = matGroup.SelectMany(x => [vertsInWedgeOrder[x.WedgeIdx0].Index, vertsInWedgeOrder[x.WedgeIdx1].Index, vertsInWedgeOrder[x.WedgeIdx2].Index]); + // section.MinVertIndex = sectionIndices.Min(); + // section.MaxVertIndex = sectionIndices.Max(); + + // sections.Add(section); + // startIndex += matGroup.Count(); + // } + // } + + // var LOD = new StaticLODModel + // { + // IndexBuffer = [.. lod.Triangles.SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3])], + // // TODO filter this down to bones that actually have any weighting? + // RequiredBones = [.. Enumerable.Range(0, intermediateMesh.Skeleton.Count).Select(x => (byte)x)] + // }; + + + // } + } + } + + private struct MeshSection + { + public IntermediateTriangle[] Triangles; + public int BaseTriIndex; + public int ChunkIndex; + public int MatIndex; + public int MinVertIndex; + public int MaxVertIndex; + } + + private static void SetNumMaterialSlots(SkeletalMesh meshBinary, int numMaterials) + { + if (meshBinary.Materials.Length == numMaterials) + { + return; + } + + var tempMaterials = meshBinary.Materials; + + meshBinary.Materials = new int[numMaterials]; + for (int i = 0; i < numMaterials && i < tempMaterials.Length; i++) + { + meshBinary.Materials[i] = tempMaterials[i]; + } + } + + private static bool GetGltfFromFile(out ModelRoot gltf, out string filePath) + { + var d = new OpenFileDialog + { + Filter = "gLTF|*.gltf;*.glb", + Title = "Select a gLTF or glb file" + }; + if (d.ShowDialog() == true) + { + filePath = d.FileName; + gltf = ModelRoot.Load(filePath); + return true; + } + + gltf = null; + filePath = null; + return false; + } + } +} diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml index 6330c655d..fe7140136 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml @@ -174,6 +174,7 @@ + @@ -183,6 +184,7 @@ + diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 1e1ede1a0..7797dcd26 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1328,6 +1328,16 @@ private void ReplaceAllWems_Click(object sender, RoutedEventArgs e) // EXPERIMENTS: DropTheSquid #region DropTheSquid's Experiments + + private void ExportGltf_Click(object sender, RoutedEventArgs args) + { + SquidGltf.ExportSkeletetalMeshToGltf(GetPEWindow()); + } + private void ImportGltf_Click(object sender, RoutedEventArgs args) + { + SquidGltf.ImportGltf(GetPEWindow()); + } + private void ImportAnimSet_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.ImportAnimSet(GetPEWindow()); diff --git a/LegendaryExplorer/LegendaryExplorerCore/LegendaryExplorerCore.csproj b/LegendaryExplorer/LegendaryExplorerCore/LegendaryExplorerCore.csproj index 9908480bb..2f1203e5e 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/LegendaryExplorerCore.csproj +++ b/LegendaryExplorer/LegendaryExplorerCore/LegendaryExplorerCore.csproj @@ -102,6 +102,7 @@ + From 2cb790b4b68f78772cb23366a4f785a839f785b5 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Wed, 21 Jan 2026 16:13:28 -0500 Subject: [PATCH 10/34] got the skeletal mesh export to match the output of UModel very well. --- .../PackageEditor/Experiments/SquidGltf.cs | 89 ++++++++++--------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index b8496cc63..32df6d99c 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -163,43 +163,6 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) return intermediateLod; } - /// - /// glTF uses y up axis conventions. Convert this to the unreal conventions of z up and proper y direction - /// - private static Vector3 Yup2Zup(Vector3 input) - { - return new Vector3(input.X, input.Z, input.Y); - } - - private static Vector3 ScaleForGltf(Vector3 input) - { - return input / 100; - } - - private static Vector3 ScaleForME(Vector3 input) - { - return input * 100; - } - - private static Quaternion Yup2Zup(Quaternion input) - { - var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); - return Quaternion.Normalize(input * transformQuat); - } - - - private static Vector3 Zup2Yup(Vector3 input) - { - return new Vector3(input.X, input.Z, input.Y); - } - - private static Quaternion Zup2Yup(Quaternion input) - { - // TODO check this works - var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); - return Quaternion.Normalize(input / transformQuat); - } - private static ModelRoot ToGltf(params IntermediateMesh[] meshes) { var scene = new SceneBuilder(); @@ -227,14 +190,14 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) if (bone.ParentIndex == -1 || bone.ParentIndex == i) { // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(ScaleForGltf(Zup2Yup(bone.Position))) - .WithLocalRotation(Zup2Yup(bone.Rotation)); + nb.WithLocalTranslation(TransformPosition(bone.Position)) + .WithLocalRotation(TransformRotation(Quaternion.Conjugate(bone.Rotation))); baseSkeletonNode.AddNode(nb); } else { - nb.WithLocalTranslation(ScaleForGltf(bone.Position)) - .WithLocalRotation(bone.Rotation); + nb.WithLocalTranslation(TransformPosition(bone.Position)) + .WithLocalRotation(TransformRotation(bone.Rotation)); } skeletonNodes[i] = nb; } @@ -271,7 +234,7 @@ VertexBuilder GetVert(int i { var intermediateVert = section.Vertices[i]; return new VertexBuilder() - .WithGeometry(ScaleForGltf(Zup2Yup(intermediateVert.Position)), Zup2Yup(intermediateVert.Normal.Value)) + .WithGeometry(TransformPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value)) .WithMaterial([..intermediateVert.UVs]) .WithSkinning(intermediateVert.Influences); } @@ -802,5 +765,47 @@ private static bool GetGltfFromFile(out ModelRoot gltf, out string filePath) filePath = null; return false; } + + + #region from glTF + private static Vector3 Yup2Zup(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y); + } + + private static Quaternion Yup2Zup(Quaternion input) + { + var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); + return Quaternion.Normalize(input * transformQuat); + } + + private static Vector3 ScaleForME(Vector3 input) + { + return input * 100; + } + #endregion + + #region to glTF + private static Vector3 TransformPosition(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y) / 100; + } + + private static Vector3 TransformDirection(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y); + } + + private static Quaternion TransformRotation(Quaternion input) + { + var result = new Quaternion(input.X, input.Z, input.Y, -input.W); + if (result.X == 0f && result.Y == 0f && result.Z == 0f && result.W == 1f) + { + return new Quaternion(-0f, -0f, 0f, -1f); + } + + return result; + } + #endregion } } From f84fb7ec2eaf3874008c7a35baf46efa1273a039 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Thu, 22 Jan 2026 13:48:06 -0500 Subject: [PATCH 11/34] Got bones rotted so they look much better in Blender. I need to test more thoroughly. --- .../PackageEditor/Experiments/SquidGltf.cs | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 32df6d99c..a3b457916 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1,4 +1,5 @@ -using LegendaryExplorerCore.Gammtek.Extensions; +using DocumentFormat.OpenXml.Presentation; +using LegendaryExplorerCore.Gammtek.Extensions; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; using LegendaryExplorerCore.Unreal.BinaryConverters; @@ -17,6 +18,8 @@ namespace LegendaryExplorer.Tools.PackageEditor.Experiments { public class SquidGltf { + // TODO restore this to 100 like it should be + private const float ScaleFactor = 1; public static void ImportGltf(PackageEditorWindow pew) { if (GetGltfFromFile(out var gltf, out string _)) @@ -190,14 +193,14 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) if (bone.ParentIndex == -1 || bone.ParentIndex == i) { // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformPosition(bone.Position)) - .WithLocalRotation(TransformRotation(Quaternion.Conjugate(bone.Rotation))); + nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) + .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); baseSkeletonNode.AddNode(nb); } else { - nb.WithLocalTranslation(TransformPosition(bone.Position)) - .WithLocalRotation(TransformRotation(bone.Rotation)); + nb.WithLocalTranslation(TransformBonePosition(bone.Position)) + .WithLocalRotation(TransformBoneRotation(bone.Rotation)); } skeletonNodes[i] = nb; } @@ -781,14 +784,14 @@ private static Quaternion Yup2Zup(Quaternion input) private static Vector3 ScaleForME(Vector3 input) { - return input * 100; + return input * ScaleFactor; } #endregion #region to glTF private static Vector3 TransformPosition(Vector3 input) { - return new Vector3(input.X, input.Z, input.Y) / 100; + return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } private static Vector3 TransformDirection(Vector3 input) @@ -798,13 +801,49 @@ private static Vector3 TransformDirection(Vector3 input) private static Quaternion TransformRotation(Quaternion input) { - var result = new Quaternion(input.X, input.Z, input.Y, -input.W); - if (result.X == 0f && result.Y == 0f && result.Z == 0f && result.W == 1f) - { - return new Quaternion(-0f, -0f, 0f, -1f); - } + return new Quaternion(input.X, input.Z, input.Y, -input.W); + } + + private static Vector3 TransformBonePosition(Vector3 input) + { + //var temp = new Vector3(input.X, input.Y, input.Z) / ScaleFactor; + //temp = Vector3.Transform(temp, new Quaternion(QuatHalf, 0, 0, -QuatHalf)); + //return temp; + var temp = new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; + return temp; // new Vector3(temp.X, temp.Z, temp.Y); + } + + private static readonly float QuatHalf = (float)(Math.Sqrt(2) / 2); + + + private static Quaternion TransformRootBoneRotation(Quaternion input) + { + // add a 90 degree rotation around the x axis + var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); + + // rotate the input by this amount + //var result = transform * input; + + //// translate to the new coordinate system + //result = new Quaternion(result.X, result.Z, result.Y, -result.W); + + return transform * input; + } - return result; + private static Vector3 TransformRootBonePosition(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + } + + private static Quaternion TransformBoneRotation(Quaternion input) + { + // first, get it into the form glTF expects due to the swapped axes + var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); + // next, we undo the rotation introduced by the parent + temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + // finally, we rotate the child in its local axes + temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); + return Quaternion.Normalize(temp); } #endregion } From 08209e95b03635bc3c3ee7c70d4da0a3c09960c5 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 23 Jan 2026 16:30:05 -0500 Subject: [PATCH 12/34] minor refactors, bug fixes. gltf Export now takes LOD material maps into account and normalizes Vectors and Quaternions when appropriate. --- .../PackageEditorExperimentsSquid.cs | 90 +++-- .../PackageEditor/Experiments/SquidGltf.cs | 366 ++++++++++-------- 2 files changed, 257 insertions(+), 199 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 91668edd7..a7ecbfc61 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -795,48 +795,23 @@ public static void ExportTexturesFromMaterial(PackageEditorWindow pew) } } - private static void ExportMaterialTextures(ExportEntry materialExport, string exportDirectory, Dictionary textureExports = null) + public static void GetMaterialTextures(ExportEntry materialExport, out Dictionary textureExports, out List baseTextures, bool includeUniformExpressionTextures = true) { - textureExports ??= []; - List baseTextures = []; + Dictionary tempTextureExports = []; + List tempBaseTextures = []; var cache = new PackageCache(); delegateByType(materialExport); - foreach (var tex in baseTextures) - { - if (!tex.IsA("Texture2D")) - { - continue; - } - var texture = new Texture2D(tex); - var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); - if (!File.Exists(exportPath)) - { - texture.ExportToPNG(exportPath); - } - } - - foreach (var tex in textureExports.Values) - { - if (!tex.IsA("Texture2D")) - { - continue; - } - var texture = new Texture2D(tex); - var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); - if (!File.Exists(exportPath)) - { - texture.ExportToPNG(exportPath); - } - } + textureExports = tempTextureExports; + baseTextures = tempBaseTextures; void delegateByType(ExportEntry materialEntry) { var selectedEntryClass = materialEntry.ClassName; if (materialEntry.ClassName == "Material") { - ExportBaseMaterialTextures(materialEntry); + ExportBaseMaterialTextures(materialEntry, includeUniformExpressionTextures); } else if (materialEntry.IsA("MaterialInstanceConstant")) { @@ -871,9 +846,12 @@ void ExportMICTextures(ExportEntry micExport) foreach (var texParam in texParamsProp) { var paramName = texParam.GetProp("ParameterName").Value.Instanced; - if (!textureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) + if (!tempTextureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) { - textureExports.Add(paramName, value); + if (value.IsA("Texture2D")) + { + tempTextureExports.Add(paramName, value); + } } } } @@ -900,7 +878,10 @@ void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExp { continue; } - baseTextures.Add(tex); + if (tex.IsA("Texture2D")) + { + tempBaseTextures.Add(tex); + } } } } @@ -916,7 +897,10 @@ void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExp { continue; } - baseTextures.Add(tex); + if (tex.IsA("Texture2D")) + { + tempBaseTextures.Add(tex); + } } } } @@ -933,13 +917,45 @@ void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExp { var paramName = expr.GetProperty("ParameterName")?.Value.Instanced ?? "None"; - if (!textureExports.ContainsKey(paramName) && expr.GetProperty("Texture").TryResolveExport(baseMatEntry.FileRef, cache, out var value)) + if (!tempTextureExports.ContainsKey(paramName) && expr.GetProperty("Texture").TryResolveExport(baseMatEntry.FileRef, cache, out var value)) { - textureExports.Add(paramName, value); + tempTextureExports.Add(paramName, value); } } } } + public static void ExportMaterialTextures(ExportEntry materialExport, string exportDirectory) + { + GetMaterialTextures(materialExport, out var textureExports, out var baseTextures, true); + + foreach (var tex in baseTextures) + { + if (!tex.IsA("Texture2D")) + { + continue; + } + var texture = new Texture2D(tex); + var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); + if (!File.Exists(exportPath)) + { + texture.ExportToPNG(exportPath); + } + } + + foreach (var tex in textureExports.Values) + { + if (!tex.IsA("Texture2D")) + { + continue; + } + var texture = new Texture2D(tex); + var exportPath = Path.Combine(exportDirectory, $"{tex.ObjectNameString}.png"); + if (!File.Exists(exportPath)) + { + texture.ExportToPNG(exportPath); + } + } + } private class TempVertex { diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index a3b457916..b80fb2ae9 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1,8 +1,10 @@ -using DocumentFormat.OpenXml.Presentation; -using LegendaryExplorerCore.Gammtek.Extensions; +using LegendaryExplorerCore.Gammtek.Extensions; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Unreal; using LegendaryExplorerCore.Unreal.BinaryConverters; +using LegendaryExplorerCore.Unreal.Classes; +using LegendaryExplorerCore.Unreal.ObjectInfo; using Microsoft.Win32; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; @@ -18,31 +20,10 @@ namespace LegendaryExplorer.Tools.PackageEditor.Experiments { public class SquidGltf { - // TODO restore this to 100 like it should be - private const float ScaleFactor = 1; - public static void ImportGltf(PackageEditorWindow pew) - { - if (GetGltfFromFile(out var gltf, out string _)) - { - foreach (var node in gltf.LogicalNodes) - { - // TODO sort meshes to group them into LODs - if (!node.Mesh.IsNull()) - { - var intermediateMesh = ToIntermediateMesh(node); - if (node.Skin.IsNull()) - { - //ImportStaticMesh(node); - } - else - { - ImportSkeletalMesh(intermediateMesh, pew.Pcc); - } - } - } - } - } + private const float ScaleFactor = 100; + const float weightUnpackScale = 1f / 255; + #region export public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) { var d = new SaveFileDialog { Filter = "glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; @@ -66,6 +47,7 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) // materials foreach (var mat in mesh.Materials) { + var intermediateMat = new IntermediateMaterial(); string name; if (mat == 0) { @@ -73,9 +55,17 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) } else { - name = mesh.Export.FileRef.GetEntry(mat).MemoryFullPath; + var materialEntry = mesh.Export.FileRef.GetEntry(mat); + name = materialEntry.MemoryFullPath; + //if (materialEntry is ImportEntry imp) + //{ + // materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); + //} + //var textureDirectory = $"{d.FileName[..^5]}_Textures"; + //PackageEditorExperimentsSquid.ExportMaterialTextures(materialEntry as ExportEntry, textureDirectory); + } - intermediateMesh.Materials.Add(name); + intermediateMesh.Materials.Add(new IntermediateMaterial(name)); } // skeleton @@ -97,13 +87,26 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) // LODs for (int i = 0; i < mesh.LODModels.Length; i++) { - intermediateMesh.LODs.Add(ToIntermediateLod(mesh.LODModels[i], i)); + // some vanilla meshes switch around the material order in lower LODs. I don't know why, but we need to account for it in the export process + int[] materialMapping = [.. Enumerable.Range(0, mesh.Materials.Length)]; + if (mesh.Export != null) + { + var LODInfo = mesh.Export.GetProperty>("LODInfo"); + if (LODInfo != null && LODInfo.Count > i) + { + var matMap = LODInfo[i].GetProp>("LODMaterialMap"); + if (matMap != null && matMap.Count > 0) + { + materialMapping = [.. matMap.Select(x => x.Value)]; + } + } + } + intermediateMesh.LODs.Add(ToIntermediateLod(mesh.LODModels[i], i, materialMapping)); } return intermediateMesh; } - const float weightUnpackScale = 1f / 255; - private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) + private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, int[] materialMapping) { var intermediateLod = new IntermediateLOD() { @@ -111,7 +114,7 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) }; List vertices = []; - + for (int i = 0; i < lod.VertexBufferGPUSkin.VertexData.Length; i++) { var originalVertex = lod.VertexBufferGPUSkin.VertexData[i]; @@ -144,7 +147,7 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) { var intermediateSection = new IntermediateMeshSection { - MaterialIndex = section.MaterialIndex, + MaterialIndex = materialMapping[section.MaterialIndex], // use the same vertices for all mesh sections so we don't need to reindex all the triangles Vertices = vertices }; @@ -162,7 +165,6 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index) intermediateLod.Sections.Add(intermediateSection); } - return intermediateLod; } @@ -173,9 +175,9 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) foreach (var mesh in meshes) { List mats = []; - foreach (var matName in mesh.Materials) + foreach (var intermediateMat in mesh.Materials) { - var mat = new MaterialBuilder(matName) + var mat = new MaterialBuilder(intermediateMat.Name) .WithBaseColor(new Vector4(.5f, .5f, .5f, 1)); // TODO support diff, norm, etc here mats.Add(mat); @@ -221,13 +223,12 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) } } - foreach (var lod in mesh.LODs) { - var name = $"{mesh.Name}_LOD_{lod.Index}"; + var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD_{lod.Index}"; // TODO this is skeletal mesh data; make it work for static meshes too var mb = new MeshBuilder(name); - + foreach (var section in lod.Sections) { var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); @@ -238,7 +239,7 @@ VertexBuilder GetVert(int i var intermediateVert = section.Vertices[i]; return new VertexBuilder() .WithGeometry(TransformPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value)) - .WithMaterial([..intermediateVert.UVs]) + .WithMaterial([.. intermediateVert.UVs]) .WithSkinning(intermediateVert.Influences); } // TODO check order @@ -257,6 +258,74 @@ VertexBuilder GetVert(int i return gltf; } + + private static Vector3 TransformPosition(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + } + + private static Vector3 TransformDirection(Vector3 input) + { + return Vector3.Normalize(new Vector3(input.X, input.Z, input.Y)); + } + + private static Vector3 TransformBonePosition(Vector3 input) + { + return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; + } + + // sqrt(2)/2 comes up repeatedly in 90 degree quaternion rotations + private static readonly float QuatHalf = (float)(Math.Sqrt(2) / 2); + + + private static Quaternion TransformRootBoneRotation(Quaternion input) + { + // add a 90 degree rotation around the x axis + var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); + return Quaternion.Normalize(transform * input); + } + + private static Vector3 TransformRootBonePosition(Vector3 input) + { + return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + } + + private static Quaternion TransformBoneRotation(Quaternion input) + { + // first, get it into the form glTF expects due to the swapped axes + var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); + // next, we undo the rotation introduced by the parent + temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + // finally, we rotate the child in its local axes + temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); + return Quaternion.Normalize(temp); + } + #endregion + + #region import + public static void ImportGltf(PackageEditorWindow pew) + { + if (GetGltfFromFile(out var gltf, out string _)) + { + foreach (var node in gltf.LogicalNodes) + { + // TODO sort meshes to group them into LODs + if (!node.Mesh.IsNull()) + { + var intermediateMesh = ToIntermediateMesh(node); + if (node.Skin.IsNull()) + { + //ImportStaticMesh(node); + } + else + { + ImportSkeletalMesh(intermediateMesh, pew.Pcc); + } + } + } + } + } + private static IntermediateMesh ToIntermediateMesh(params Node[] nodes) { if (nodes.Length == 0) @@ -362,7 +431,7 @@ Node FindJointParent(Node node) // TODO make sure this is not off by 1 meshMatIndex = materialMap.Count; materialMap.Add(gltfMatIndex); - intermediateMesh.Materials.Add(prim.Material?.Name ?? "Null"); + intermediateMesh.Materials.Add(new IntermediateMaterial(prim.Material?.Name ?? "null")); } var meshSection = new IntermediateMeshSection() @@ -463,83 +532,6 @@ void AddUV(IList? column) return intermediateMesh; } - private class IntermediateMesh - { - public string Name; - public List Materials = []; - // will be null for static meshes - public List Skeleton; - public List LODs = []; - // TODO collision mesh(es)? - - public IntermediateMesh() - { - } - } - - private class IntermediateMeshSection - { - public int MaterialIndex; - public List Triangles = []; - public List Vertices = []; - - public IntermediateMeshSection() - { - } - } - - private struct IntermediateTriangle - { - //public int Index; - public int VertIndex1; - public int VertIndex2; - public int VertIndex3; - } - - private class IntermediateLOD - { - public int Index; - public List Sections = []; - - public IntermediateLOD() - { - } - } - - private struct IntermediateVertex - { - public int Index; - // always required - public Vector3 Position; - // can be imported or calculated if need be - public Vector3? Normal; - // will be calculated - public Vector3? Tangent; - // will be calculated - public float BiTangentDirection; - // will usually be present. Expect length 1 for skeletal meshes, but static meshes can have multiple - public List UVs = []; - // only present for skeletal meshes. The engine supports a maximum of four influences, so that is the max length - public List<(int influenceBone, float weight)> Influences = []; - // no known use yet, but static meshes might support it - //Vector4 Color; - // used to store the original index when we export it from ME to glTF; can hopefully help us reconsitutue it later - public int OriginalIndex; - public IntermediateVertex() - { - } - } - - private class IntermediateBone - { - public int Index; - public string Name; - public int NumChildren; - public int ParentIndex; - public Vector3 Position; - public Quaternion Rotation; - } - private static void ImportSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package) { var meshBin = SkeletalMesh.Create(); @@ -627,16 +619,16 @@ static void SetupBounds(IntermediateMesh intermediateMesh, SkeletalMesh meshBin) //}; } - static void SetupMaterials(IList materials, SkeletalMesh meshBin, IMEPackage package) + static void SetupMaterials(IList materials, SkeletalMesh meshBin, IMEPackage package) { SetNumMaterialSlots(meshBin, materials.Count); for (int i = 0; i < materials.Count; i++) { - // Does not work because it is looking for the full instanced path; can I export using that? - var entry = package.FindEntry(materials[i]); - // a good enough heuristic for now - entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); - entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materials[i] && x.ClassName.Contains("Material")); + if (materials[i].Name == "null") + { + continue; + } + var entry = FindEntryByMemeroryFullPath(package, materials[i].Name, "MaterialInterface"); if (entry != null) { meshBin.Materials[i] = entry.UIndex; @@ -769,8 +761,6 @@ private static bool GetGltfFromFile(out ModelRoot gltf, out string filePath) return false; } - - #region from glTF private static Vector3 Yup2Zup(Vector3 input) { return new Vector3(input.X, input.Z, input.Y); @@ -788,63 +778,115 @@ private static Vector3 ScaleForME(Vector3 input) } #endregion - #region to glTF - private static Vector3 TransformPosition(Vector3 input) + #region intermediate + private class IntermediateMesh { - return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + public string Name; + public List Materials = []; + // will be null for static meshes + public List Skeleton; + public List LODs = []; + // TODO collision mesh(es)? + + public IntermediateMesh() + { + } } - private static Vector3 TransformDirection(Vector3 input) + private class IntermediateMaterial { - return new Vector3(input.X, input.Z, input.Y); + public IntermediateMaterial() { } + public IntermediateMaterial(string name) + { + Name = name; + } + public string Name; + // export only + public Texture2D DiffTexture; + public Texture2D NormalTexture; } - private static Quaternion TransformRotation(Quaternion input) + private class IntermediateMeshSection { - return new Quaternion(input.X, input.Z, input.Y, -input.W); + public int MaterialIndex; + public List Triangles = []; + public List Vertices = []; + + public IntermediateMeshSection() + { + } } - private static Vector3 TransformBonePosition(Vector3 input) + private struct IntermediateTriangle { - //var temp = new Vector3(input.X, input.Y, input.Z) / ScaleFactor; - //temp = Vector3.Transform(temp, new Quaternion(QuatHalf, 0, 0, -QuatHalf)); - //return temp; - var temp = new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; - return temp; // new Vector3(temp.X, temp.Z, temp.Y); + //public int Index; + public int VertIndex1; + public int VertIndex2; + public int VertIndex3; } - private static readonly float QuatHalf = (float)(Math.Sqrt(2) / 2); - - - private static Quaternion TransformRootBoneRotation(Quaternion input) + private class IntermediateLOD { - // add a 90 degree rotation around the x axis - var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); - - // rotate the input by this amount - //var result = transform * input; - - //// translate to the new coordinate system - //result = new Quaternion(result.X, result.Z, result.Y, -result.W); + public int Index; + public List Sections = []; - return transform * input; + public IntermediateLOD() + { + } } - private static Vector3 TransformRootBonePosition(Vector3 input) + private struct IntermediateVertex { - return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + public int Index; + // always required + public Vector3 Position; + // can be imported or calculated if need be + public Vector3? Normal; + // will be calculated + public Vector3? Tangent; + // will be calculated + public float BiTangentDirection; + // will usually be present. Expect length 1 for skeletal meshes, but static meshes can have multiple + public List UVs = []; + // only present for skeletal meshes. The engine supports a maximum of four influences, so that is the max length + public List<(int influenceBone, float weight)> Influences = []; + // no known use yet, but static meshes might support it + //Vector4 Color; + // used to store the original index when we export it from ME to glTF; can hopefully help us reconsitutue it later + public int OriginalIndex; + public IntermediateVertex() + { + } } - private static Quaternion TransformBoneRotation(Quaternion input) + private class IntermediateBone { - // first, get it into the form glTF expects due to the swapped axes - var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); - // next, we undo the rotation introduced by the parent - temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; - // finally, we rotate the child in its local axes - temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); - return Quaternion.Normalize(temp); + public int Index; + public string Name; + public int NumChildren; + public int ParentIndex; + public Vector3 Position; + public Quaternion Rotation; } + #endregion + + + // TODO this is probably broadly useful and could live somewhere else as an extension method + public static IEntry FindEntryByMemeroryFullPath(IMEPackage pachage, string memoryFullPath, string className = null) + { + foreach (IEntry entry in pachage.Exports.Concat(pachage.Imports)) + { + if (entry.MemoryFullPath.CaseInsensitiveEquals(memoryFullPath)) + { + if (className != null && !(entry.ClassName.CaseInsensitiveEquals(className) || entry.IsA(className))) + { + continue; + } + return entry; + } + } + return null; + } } } From 71c63f92da966eee1682c31ba1fa927ff4974beb Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 23 Jan 2026 22:01:58 -0500 Subject: [PATCH 13/34] Quick POC of attaching textures. it seems to work, though it needs more fine tuning. --- .../PackageEditorExperimentsSquid.cs | 5 ++ .../PackageEditor/Experiments/SquidGltf.cs | 57 ++++++++++++++----- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index a7ecbfc61..feca05cb2 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -848,6 +848,11 @@ void ExportMICTextures(ExportEntry micExport) var paramName = texParam.GetProp("ParameterName").Value.Instanced; if (!tempTextureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) { + // skip the really dumb textures + if (value.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } if (value.IsA("Texture2D")) { tempTextureExports.Add(paramName, value); diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index b80fb2ae9..005b7772a 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1,6 +1,7 @@ using LegendaryExplorerCore.Gammtek.Extensions; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Packages.CloningImportingAndRelinking; using LegendaryExplorerCore.Unreal; using LegendaryExplorerCore.Unreal.BinaryConverters; using LegendaryExplorerCore.Unreal.Classes; @@ -48,24 +49,21 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) foreach (var mat in mesh.Materials) { var intermediateMat = new IntermediateMaterial(); - string name; if (mat == 0) { - name = "null"; + intermediateMat.Name = "null"; } else { var materialEntry = mesh.Export.FileRef.GetEntry(mat); - name = materialEntry.MemoryFullPath; - //if (materialEntry is ImportEntry imp) - //{ - // materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); - //} - //var textureDirectory = $"{d.FileName[..^5]}_Textures"; - //PackageEditorExperimentsSquid.ExportMaterialTextures(materialEntry as ExportEntry, textureDirectory); - + intermediateMat.Name = materialEntry.MemoryFullPath; + if (materialEntry is ImportEntry imp) + { + materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); + } + FindBestDiffAndNormForMaterial(intermediateMat, materialEntry as ExportEntry); } - intermediateMesh.Materials.Add(new IntermediateMaterial(name)); + intermediateMesh.Materials.Add(intermediateMat); } // skeleton @@ -177,8 +175,13 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) List mats = []; foreach (var intermediateMat in mesh.Materials) { - var mat = new MaterialBuilder(intermediateMat.Name) - .WithBaseColor(new Vector4(.5f, .5f, .5f, 1)); + var mat = new MaterialBuilder(intermediateMat.Name); + if (intermediateMat.DiffTexture != null) + { + var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); + var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); + mat.WithBaseColor(diffImage); + } // TODO support diff, norm, etc here mats.Add(mat); } @@ -871,6 +874,34 @@ private class IntermediateBone #endregion + private static void FindBestDiffAndNormForMaterial(IntermediateMaterial mat, ExportEntry matEntry) + { + // TODO hardcode in what params to look for for specific known materials to avoid the stupid gold bars texture, among other things. + PackageEditorExperimentsSquid.GetMaterialTextures(matEntry, out var textures, out var baseTextures); + foreach (var (param, tex) in textures) + { + // don't look at the params, it'll pull in things like teeth diff for the scalp which are not what you want + if (/*param.Contains("Diff", StringComparison.InvariantCultureIgnoreCase) || */tex.ObjectName.ToString().Contains("Diff", StringComparison.InvariantCultureIgnoreCase)) + { + mat.DiffTexture ??= new Texture2D(tex); + } + else if (/*param.Contains("Norm", StringComparison.InvariantCultureIgnoreCase) ||*/ tex.ObjectName.ToString().Contains("Norm", StringComparison.InvariantCultureIgnoreCase)) + { + mat.NormalTexture ??= new Texture2D(tex); + } + } + foreach (var tex in baseTextures) + { + if (tex.ObjectName.ToString().Contains("Diff", StringComparison.InvariantCultureIgnoreCase)) + { + mat.DiffTexture ??= new Texture2D(tex); + } + else if (tex.ObjectName.ToString().Contains("Norm", StringComparison.InvariantCultureIgnoreCase)) + { + mat.NormalTexture ??= new Texture2D(tex); + } + } + } // TODO this is probably broadly useful and could live somewhere else as an extension method public static IEntry FindEntryByMemeroryFullPath(IMEPackage pachage, string memoryFullPath, string className = null) From 660a6f73d56fdd670b061e808e7d92a4cb835545 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Sun, 25 Jan 2026 10:39:16 -0500 Subject: [PATCH 14/34] implemented attaching a normal texture in a format that is glTF compliant (flipped green channel compared to ME). --- .../PackageEditor/Experiments/SquidGltf.cs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 005b7772a..ba28a56b8 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -12,10 +12,16 @@ using SharpGLTF.Materials; using SharpGLTF.Scenes; using SharpGLTF.Schema2; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Filters; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Numerics; +using IsImage = SixLabors.ImageSharp.Image; namespace LegendaryExplorer.Tools.PackageEditor.Experiments { @@ -176,13 +182,39 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) foreach (var intermediateMat in mesh.Materials) { var mat = new MaterialBuilder(intermediateMat.Name); + if (intermediateMat.TwoSided) + { + mat.WithDoubleSide(true); + } if (intermediateMat.DiffTexture != null) { var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); + diffImage.AlternateWriteFileName = $"{intermediateMat.DiffTexture.Export.ObjectNameString}.*"; mat.WithBaseColor(diffImage); } - // TODO support diff, norm, etc here + if (intermediateMat.NormalTexture != null) + { + var normalMapBytes = intermediateMat.NormalTexture.GetPNG(intermediateMat.NormalTexture.GetTopMip()); + // flip the green channel to match the convention glTF uses + var img = IsImage.Load(normalMapBytes); + var colorMatrix = new ColorMatrix( + 1, 0, 0, 0, + 0, -1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + 0, 1, 0, 0 + ); + img.Mutate(x => x.ApplyProcessor(new FilterProcessor(colorMatrix))); + using (var ms = new MemoryStream()) + { + img.SaveAsPng(ms); + normalMapBytes = ms.ToArray(); + } + var normImage = ImageBuilder.From(normalMapBytes, $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped"); + normImage.AlternateWriteFileName = $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped.*"; + mat.WithNormal(normImage); + } mats.Add(mat); } @@ -807,6 +839,7 @@ public IntermediateMaterial(string name) // export only public Texture2D DiffTexture; public Texture2D NormalTexture; + public bool TwoSided; } private class IntermediateMeshSection From 61be603454c5fd739a0b603856677a79c863fd5a Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Sun, 25 Jan 2026 18:09:22 -0500 Subject: [PATCH 15/34] added support for exporting sockets. the results visually match manually setting them up in UDK. Need to test more to make sure it works. --- .../PackageEditor/Experiments/SquidGltf.cs | 100 ++++++++++++++++-- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index ba28a56b8..5db31b818 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -88,6 +88,51 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) }); } + // Sockets + var socketProp = mesh.Export.GetProperty>("Sockets"); + if (socketProp != null) + { + foreach (var socket in socketProp) + { + var socketObject = socket.ResolveToExport(mesh.Export.FileRef, new PackageCache()); + var intermediateSocket = new IntermediateSocket() + { + Name = socketObject.GetProperty("SocketName").Value.Instanced, + Bone = socketObject.GetProperty("BoneName").Value.Instanced, + RelativeLocation = Vector3.Zero, + RelativeRotation = Quaternion.Identity + }; + var locationProp = socketObject.GetProperty("RelativeLocation"); + if (locationProp != null) + { + intermediateSocket.RelativeLocation = new Vector3(locationProp.GetProp("X"), locationProp.GetProp("Y"), locationProp.GetProp("Z")); + } + var rotationProp = socketObject.GetProperty("RelativeRotation"); + if (rotationProp != null) + { + static Quaternion FromYawPitchRoll(int yaw, int pitch, int roll) + { + var rot = Quaternion.Identity; + var yawRad = (yaw % 65536) / 65536f * Math.PI * 2; + var pitchRad = (pitch % 65536) / 65536f * Math.PI * 2; + var rollRad = (roll % 65536) / 65536f * Math.PI * 2; + // apply yaw + rot = rot * new Quaternion(0, (float)Math.Sin(yawRad / 2), 0, -(float)Math.Cos(yawRad / 2)); + // apply pitch + rot = rot * new Quaternion(0, 0, (float)Math.Sin(pitchRad / 2), (float)Math.Cos(pitchRad / 2)); + // apply roll + rot = rot * new Quaternion((float)Math.Sin(rollRad / 2), 0, 0, (float)Math.Cos(rollRad / 2)); + return Quaternion.Normalize(rot); + } + intermediateSocket.RelativeRotation = FromYawPitchRoll( + rotationProp.GetProp("Yaw").Value, + rotationProp.GetProp("Pitch").Value, + rotationProp.GetProp("Roll").Value); + } + intermediateMesh.Sockets.Add(intermediateSocket); + } + } + // LODs for (int i = 0; i < mesh.LODModels.Length; i++) { @@ -182,10 +227,7 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) foreach (var intermediateMat in mesh.Materials) { var mat = new MaterialBuilder(intermediateMat.Name); - if (intermediateMat.TwoSided) - { - mat.WithDoubleSide(true); - } + mat.WithDoubleSide(intermediateMat.TwoSided); if (intermediateMat.DiffTexture != null) { var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); @@ -222,7 +264,7 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) var baseSkeletonNode = new NodeBuilder(mesh.Name); scene.AddNode(baseSkeletonNode); var skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; - // one pass to create all the nodes without the hirarchy + // one pass to create all the nodes without the hierarchy for (int i = 0; i < mesh.Skeleton.Count; i++) { var bone = mesh.Skeleton[i]; @@ -237,7 +279,7 @@ private static ModelRoot ToGltf(params IntermediateMesh[] meshes) else { nb.WithLocalTranslation(TransformBonePosition(bone.Position)) - .WithLocalRotation(TransformBoneRotation(bone.Rotation)); + .WithLocalRotation(TransformBoneRotation(bone.Rotation)); } skeletonNodes[i] = nb; } @@ -273,7 +315,7 @@ VertexBuilder GetVert(int i { var intermediateVert = section.Vertices[i]; return new VertexBuilder() - .WithGeometry(TransformPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value)) + .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) .WithMaterial([.. intermediateVert.UVs]) .WithSkinning(intermediateVert.Influences); } @@ -281,10 +323,24 @@ VertexBuilder GetVert(int i primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } } - var skinnedMesh = scene.AddSkinnedMesh(mb, Matrix4x4.Identity, skeletonNodes); skinnedMesh.WithName(name); } + + // finish sockets by creating nodes under the bones they are attached to + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var nb = skeletonNodes[i]; + var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); + foreach (var socket in sockets) + { + var socketBuilder = new NodeBuilder(socket.Name) + .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)); + // TODO support relative scale here + nb.AddNode(socketBuilder); + } + } } var gltf = scene.ToGltf2(); @@ -294,7 +350,7 @@ VertexBuilder GetVert(int i return gltf; } - private static Vector3 TransformPosition(Vector3 input) + private static Vector3 TransformVertexPosition(Vector3 input) { return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } @@ -315,7 +371,7 @@ private static Vector3 TransformBonePosition(Vector3 input) private static Quaternion TransformRootBoneRotation(Quaternion input) { - // add a 90 degree rotation around the x axis + // add a -90 degree rotation around the x axis var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); return Quaternion.Normalize(transform * input); } @@ -325,6 +381,21 @@ private static Vector3 TransformRootBonePosition(Vector3 input) return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } + private static Quaternion TransformSocketRotation(Quaternion input) + { + //// first, get it into the form glTF expects due to the swapped axes + //var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); + //// next, we undo the rotation introduced by the parent + //temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + //// finally, we rotate the child in its local axes + //temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); + //return Quaternion.Normalize(temp); + + // add a 90 degree rotation around the x axis + var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); + return Quaternion.Normalize(transform * input); + } + private static Quaternion TransformBoneRotation(Quaternion input) { // first, get it into the form glTF expects due to the swapped axes @@ -822,6 +893,7 @@ private class IntermediateMesh public List Skeleton; public List LODs = []; // TODO collision mesh(es)? + public List Sockets = []; public IntermediateMesh() { @@ -905,6 +977,14 @@ private class IntermediateBone public Quaternion Rotation; } + private class IntermediateSocket + { + public string Name; + public string Bone; + public Vector3 RelativeLocation; + public Quaternion RelativeRotation; + } + #endregion private static void FindBestDiffAndNormForMaterial(IntermediateMaterial mat, ExportEntry matEntry) From 12e3a9f1015d7e5b0a2ac54a9b28919071a85c7b Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Mon, 26 Jan 2026 11:30:34 -0500 Subject: [PATCH 16/34] Rearranged a bit, added support for exporting as glb, started implementing static mesh export. --- .../PackageEditorExperimentsSquid.cs | 55 +++++++ .../PackageEditor/Experiments/SquidGltf.cs | 145 +++++++++++------- .../ExperimentsMenuControl.xaml | 4 +- .../ExperimentsMenuControl.xaml.cs | 19 +-- 4 files changed, 151 insertions(+), 72 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index feca05cb2..23609c149 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -1,5 +1,6 @@ using CommunityToolkit.HighPerformance; using LegendaryExplorer.Dialogs; +using LegendaryExplorer.Misc; using LegendaryExplorer.Misc.ExperimentsTools; using LegendaryExplorerCore.GameFilesystem; using LegendaryExplorerCore.Gammtek.Extensions.Collections.Generic; @@ -109,6 +110,60 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } + public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) + { + if (pew.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (pew.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetSelectedMeshBinary(pew, out var _, out var meshBin)) + { + var d = new SaveFileDialog { Filter = "glTF|*.glTF,*.glb", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}"}; + if (d.ShowDialog() == true) + { + SquidGltf.ConvertSkeletalMeshToGltf(meshBin, d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + } + } + } + + public static void ImportGltf(PackageEditorWindow pew) + { + if (pew.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (pew.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetGltfFromFile(out var gltf, out string _)) + { + SquidGltf.ConvertGltfToMesh(gltf, pew.Pcc); + } + } + private static bool GetGltfFromFile(out SharpGLTF.Schema2.ModelRoot gltf, out string filePath) + { + var d = new OpenFileDialog + { + Filter = "gLTF|*.gltf;*.glb", + Title = "Select a gLTF or glb file" + }; + if (d.ShowDialog() == true) + { + filePath = d.FileName; + gltf = SharpGLTF.Schema2.ModelRoot.Load(filePath); + return true; + } + + gltf = null; + filePath = null; + return false; + } + private static SkeletalMesh CreateSkeletalMeshFromPsks(PackageEditorWindow pew, PSK[] psks, out ArrayProperty lodInfoProp) { var meshBin = SkeletalMesh.Create(); diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 5db31b818..e1ffb4f7f 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -6,7 +6,6 @@ using LegendaryExplorerCore.Unreal.BinaryConverters; using LegendaryExplorerCore.Unreal.Classes; using LegendaryExplorerCore.Unreal.ObjectInfo; -using Microsoft.Win32; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Materials; @@ -31,19 +30,67 @@ public class SquidGltf const float weightUnpackScale = 1f / 255; #region export - public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) + public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, string versionInfo = null) { - var d = new SaveFileDialog { Filter = "glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectNameString}" }; - if (d.ShowDialog() == true) + var intermediateMesh = ToIntermediateMesh(mesh); + var gltf = ToGltf([intermediateMesh], versionInfo); + // allow saving as glTF (human readable json, outputs a bin file and textures next to it) + // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable + if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) { - // TODO check that the selected item is a skeletal mesh - var meshBin = ((ExportEntry)pew.SelectedItem.Entry).GetBinaryData(); - var intermediateMesh = ToIntermediateMesh(meshBin); - var gltf = ToGltf(intermediateMesh); - gltf.SaveGLTF(d.FileName); + gltf.SaveGLB(filePath); + } + else + { + gltf.SaveGLTF(filePath); } } + //public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, string versionInfo = null) + //{ + // var intermediateMesh = ToIntermediateMesh(mesh); + // var gltf = ToGltf([intermediateMesh], versionInfo); + // // allow saving as glTF (human readable json, outputs a bin file and textures next to it) + // // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable + // if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) + // { + // gltf.SaveGLB(filePath); + // } + // else + // { + // gltf.SaveGLTF(filePath); + // } + //} + + //private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) + //{ + // var intermediateMesh = new IntermediateMesh() + // { + // Name = mesh.Export.ObjectName.Instanced + // }; + + // // materials + // foreach (var mat in mesh.Materials) + // { + // var intermediateMat = new IntermediateMaterial(); + // if (mat == 0) + // { + // intermediateMat.Name = "null"; + // } + // else + // { + // var materialEntry = mesh.Export.FileRef.GetEntry(mat); + // intermediateMat.Name = materialEntry.MemoryFullPath; + // if (materialEntry is ImportEntry imp) + // { + // materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); + // } + // FindBestDiffAndNormForMaterial(intermediateMat, materialEntry as ExportEntry); + // } + // intermediateMesh.Materials.Add(intermediateMat); + // } + //} + private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) { var intermediateMesh = new IntermediateMesh() @@ -100,7 +147,8 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) Name = socketObject.GetProperty("SocketName").Value.Instanced, Bone = socketObject.GetProperty("BoneName").Value.Instanced, RelativeLocation = Vector3.Zero, - RelativeRotation = Quaternion.Identity + RelativeRotation = Quaternion.Identity, + RelativeScale = Vector3.One }; var locationProp = socketObject.GetProperty("RelativeLocation"); if (locationProp != null) @@ -129,6 +177,11 @@ static Quaternion FromYawPitchRoll(int yaw, int pitch, int roll) rotationProp.GetProp("Pitch").Value, rotationProp.GetProp("Roll").Value); } + var scaleProp = socketObject.GetProperty("RelativeScale"); + if (scaleProp != null) + { + intermediateSocket.RelativeScale = new Vector3(scaleProp.GetProp("X"), scaleProp.GetProp("Y"), scaleProp.GetProp("Z")); + } intermediateMesh.Sockets.Add(intermediateSocket); } } @@ -217,7 +270,7 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, return intermediateLod; } - private static ModelRoot ToGltf(params IntermediateMesh[] meshes) + private static ModelRoot ToGltf(IntermediateMesh[] meshes, string versionInfo = null) { var scene = new SceneBuilder(); @@ -319,7 +372,6 @@ VertexBuilder GetVert(int i .WithMaterial([.. intermediateVert.UVs]) .WithSkinning(intermediateVert.Influences); } - // TODO check order primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } } @@ -336,16 +388,15 @@ VertexBuilder GetVert(int i { var socketBuilder = new NodeBuilder(socket.Name) .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)); - // TODO support relative scale here + .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) + .WithLocalScale(TransformScale(socket.RelativeScale)); nb.AddNode(socketBuilder); } } } var gltf = scene.ToGltf2(); - // TODO add version info? - gltf.Asset.Generator = "Legendary Explorer"; + gltf.Asset.Generator = $"{versionInfo ?? "Legendary Explorer Core"}"; return gltf; } @@ -381,16 +432,14 @@ private static Vector3 TransformRootBonePosition(Vector3 input) return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } - private static Quaternion TransformSocketRotation(Quaternion input) + private static Vector3 TransformScale(Vector3 input) { - //// first, get it into the form glTF expects due to the swapped axes - //var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); - //// next, we undo the rotation introduced by the parent - //temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; - //// finally, we rotate the child in its local axes - //temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); - //return Quaternion.Normalize(temp); + // TODO check if this is actually the right transform + return new Vector3(input.X, input.Z, input.Y); + } + private static Quaternion TransformSocketRotation(Quaternion input) + { // add a 90 degree rotation around the x axis var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); return Quaternion.Normalize(transform * input); @@ -409,24 +458,22 @@ private static Quaternion TransformBoneRotation(Quaternion input) #endregion #region import - public static void ImportGltf(PackageEditorWindow pew) + + public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) { - if (GetGltfFromFile(out var gltf, out string _)) + foreach (var node in gltf.LogicalNodes) { - foreach (var node in gltf.LogicalNodes) + // TODO sort meshes to group them into LODs + if (!node.Mesh.IsNull()) { - // TODO sort meshes to group them into LODs - if (!node.Mesh.IsNull()) + var intermediateMesh = ToIntermediateMesh(node); + if (node.Skin.IsNull()) { - var intermediateMesh = ToIntermediateMesh(node); - if (node.Skin.IsNull()) - { - //ImportStaticMesh(node); - } - else - { - ImportSkeletalMesh(intermediateMesh, pew.Pcc); - } + //ImportStaticMesh(node); + } + else + { + ImportSkeletalMesh(intermediateMesh, pcc); } } } @@ -577,7 +624,6 @@ void AddUV(IList? column) { if (column != null) { - // TODO any signs to deal with? vert.UVs.Add(column[i]); } } @@ -684,7 +730,6 @@ int GetDepth(int i) Name = currentBone.Name, NumChildren = currentBone.NumChildren, BoneColor = new LegendaryExplorerCore.SharpDX.Color(new Vector4(1, 1, 1, 1)), - // TODO do I need anything here? Flags = 0, ParentIndex = currentBone.ParentIndex, Position = new Vector3(currentBone.Position.X, currentBone.Position.Y * -1, currentBone.Position.Z), @@ -848,25 +893,6 @@ private static void SetNumMaterialSlots(SkeletalMesh meshBinary, int numMaterial } } - private static bool GetGltfFromFile(out ModelRoot gltf, out string filePath) - { - var d = new OpenFileDialog - { - Filter = "gLTF|*.gltf;*.glb", - Title = "Select a gLTF or glb file" - }; - if (d.ShowDialog() == true) - { - filePath = d.FileName; - gltf = ModelRoot.Load(filePath); - return true; - } - - gltf = null; - filePath = null; - return false; - } - private static Vector3 Yup2Zup(Vector3 input) { return new Vector3(input.X, input.Z, input.Y); @@ -983,6 +1009,7 @@ private class IntermediateSocket public string Bone; public Vector3 RelativeLocation; public Quaternion RelativeRotation; + public Vector3 RelativeScale; } #endregion diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml index fe7140136..c9fd8af24 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml @@ -174,7 +174,8 @@ - + + @@ -184,7 +185,6 @@ - diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 7797dcd26..081e568f0 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1329,15 +1329,6 @@ private void ReplaceAllWems_Click(object sender, RoutedEventArgs e) // EXPERIMENTS: DropTheSquid #region DropTheSquid's Experiments - private void ExportGltf_Click(object sender, RoutedEventArgs args) - { - SquidGltf.ExportSkeletetalMeshToGltf(GetPEWindow()); - } - private void ImportGltf_Click(object sender, RoutedEventArgs args) - { - SquidGltf.ImportGltf(GetPEWindow()); - } - private void ImportAnimSet_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.ImportAnimSet(GetPEWindow()); @@ -1354,7 +1345,10 @@ private void GetMeshMaterials_Click(object sender, RoutedEventArgs e) } // export mesh - + private void ExportGltf_Click(object sender, RoutedEventArgs args) + { + PackageEditorExperimentsSquid.ExportSkeletetalMeshToGltf(GetPEWindow()); + } private void ExportSelectedToPsx_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.ExportSelectedToPsx(GetPEWindow()); @@ -1380,7 +1374,10 @@ private void ImportPskAsNewMesh_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.ImportPskAsNewMesh(GetPEWindow()); } - + private void ImportGltf_Click(object sender, RoutedEventArgs args) + { + PackageEditorExperimentsSquid.ImportGltf(GetPEWindow()); + } private void MakeHeterochromia_Click(object sender, RoutedEventArgs e) { From ebc5d8839110939c8d74754d137d83b83505dc72 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Mon, 26 Jan 2026 15:52:05 -0500 Subject: [PATCH 17/34] Implemented most of Static mesh export to gltf. --- .../PackageEditorExperimentsSquid.cs | 24 +- .../PackageEditor/Experiments/SquidGltf.cs | 490 ++++++++++++++---- .../ExperimentsMenuControl.xaml.cs | 2 +- .../LegendaryExplorerCore/Unreal/PSK.cs | 2 +- 4 files changed, 401 insertions(+), 117 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 23609c149..208c359ea 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -110,7 +110,7 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } - public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) + public static void ExportMeshToGltf(PackageEditorWindow pew) { if (pew.Pcc.Game == MEGame.ME1) { @@ -120,14 +120,30 @@ public static void ExportSkeletetalMeshToGltf(PackageEditorWindow pew) { ShowError("This experiment does not support UDK files;"); } - if (GetSelectedMeshBinary(pew, out var _, out var meshBin)) + if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh"], out var export)) { - var d = new SaveFileDialog { Filter = "glTF|*.glTF,*.glb", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}"}; + var d = new SaveFileDialog { Filter = "glTF|*.glTF,*.glb", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}.glb"}; if (d.ShowDialog() == true) { - SquidGltf.ConvertSkeletalMeshToGltf(meshBin, d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + if (export.ClassName == "SkeletalMesh") + { + SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + } + // TODO support other closely related types? + else if (export.ClassName == "StaticMesh") + { + if (!(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) + { + ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + } + SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + } } } + else + { + ShowError("You must select a skeletal mesh or static mesh"); + } } public static void ImportGltf(PackageEditorWindow pew) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index e1ffb4f7f..bfbb28ba0 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1,4 +1,5 @@ using LegendaryExplorerCore.Gammtek.Extensions; +using LegendaryExplorerCore.Gammtek.Extensions.Collections.Generic; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; using LegendaryExplorerCore.Packages.CloningImportingAndRelinking; @@ -9,6 +10,7 @@ using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Materials; +using SharpGLTF.Memory; using SharpGLTF.Scenes; using SharpGLTF.Schema2; using SixLabors.ImageSharp; @@ -46,52 +48,23 @@ public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, } } - //public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, string versionInfo = null) - //{ - // var intermediateMesh = ToIntermediateMesh(mesh); - // var gltf = ToGltf([intermediateMesh], versionInfo); - // // allow saving as glTF (human readable json, outputs a bin file and textures next to it) - // // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable - // if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) - // { - // gltf.SaveGLB(filePath); - // } - // else - // { - // gltf.SaveGLTF(filePath); - // } - //} - - //private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) - //{ - // var intermediateMesh = new IntermediateMesh() - // { - // Name = mesh.Export.ObjectName.Instanced - // }; - - // // materials - // foreach (var mat in mesh.Materials) - // { - // var intermediateMat = new IntermediateMaterial(); - // if (mat == 0) - // { - // intermediateMat.Name = "null"; - // } - // else - // { - // var materialEntry = mesh.Export.FileRef.GetEntry(mat); - // intermediateMat.Name = materialEntry.MemoryFullPath; - // if (materialEntry is ImportEntry imp) - // { - // materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); - // } - // FindBestDiffAndNormForMaterial(intermediateMat, materialEntry as ExportEntry); - // } - // intermediateMesh.Materials.Add(intermediateMat); - // } - //} + public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, string versionInfo = null) + { + var intermediateMesh = ToIntermediateMesh(mesh); + var gltf = ToGltf([intermediateMesh], versionInfo); + // allow saving as glTF (human readable json, outputs a bin file and textures next to it) + // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable + if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) + { + gltf.SaveGLB(filePath); + } + else + { + gltf.SaveGLTF(filePath); + } + } - private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) + private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) { var intermediateMesh = new IntermediateMesh() { @@ -99,23 +72,127 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) }; // materials - foreach (var mat in mesh.Materials) + List materialMap = []; + foreach (var lod in mesh.LODModels) { - var intermediateMat = new IntermediateMaterial(); - if (mat == 0) + foreach (var element in lod.Elements) { - intermediateMat.Name = "null"; + var matUIndex = element.Material; + var matMapIndex = materialMap.IndexOf(matUIndex); + // The first time we encounter any material (by UIndex) add it to the mapping list + if (matMapIndex == -1) + { + materialMap.Add(matUIndex); + } + } + } + foreach (var mat in materialMap) + { + var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat)); + intermediateMesh.Materials.Add(intermediateMat); + } + + // LODs + for (int i = 0; i < mesh.LODModels.Length; i++) + { + intermediateMesh.LODs.Add(ToIntermediateLod(mesh.LODModels[i], i, materialMap)); + } + + // TODO collision mesh + + return intermediateMesh; + } + + private static IntermediateLOD ToIntermediateLod(StaticMeshRenderData lod, int index, IEnumerable materialMapping) + { + var intermediateLod = new IntermediateLOD() + { + Index = index + }; + + // shared vertices across all sections + List vertices = []; + for (int i = 0; i < lod.VertexBuffer.VertexData.Length; i++) + { + var vert = lod.VertexBuffer.VertexData[i]; + List uvs = []; + if (lod.VertexBuffer.bUseFullPrecisionUVs) + { + uvs.AddRange(vert.FullPrecisionUVs); } else { - var materialEntry = mesh.Export.FileRef.GetEntry(mat); - intermediateMat.Name = materialEntry.MemoryFullPath; - if (materialEntry is ImportEntry imp) + uvs.AddRange(vert.HalfPrecisionUVs.Select(x => (Vector2)x)); + } + + var intermediateVert = new IntermediateVertex() + { + Index = i, + OriginalIndex = i, + Position = lod.PositionVertexBuffer.VertexData[i], + Normal = (Vector3)vert.TangentZ, + Tangent = (Vector3)vert.TangentX, + BiTangentDirection = vert.TangentZ.W / 127.5f - 1, + UVs = uvs, + }; + vertices.Add(intermediateVert); + } + + foreach (var element in lod.Elements) + { + var intermediateSection = new IntermediateMeshSection + { + Vertices = vertices, + MaterialIndex = materialMapping.IndexOf(element.Material) + }; + + // TODO other code comments indicate that sometimes the index buffer is not present and we need to look at the kdops data for the triangles + for (int i = (int)element.FirstIndex; i < element.FirstIndex + element.NumTriangles * 3; i += 3) + { + intermediateSection.Triangles.Add(new IntermediateTriangle() { - materialEntry = EntryImporter.ResolveImport(imp, new PackageCache()); - } - FindBestDiffAndNormForMaterial(intermediateMat, materialEntry as ExportEntry); + VertIndex1 = lod.IndexBuffer[i], + VertIndex2 = lod.IndexBuffer[i + 1], + VertIndex3 = lod.IndexBuffer[i + 2], + }); } + + intermediateLod.Sections.Add(intermediateSection); + } + + return intermediateLod; + } + + private static IntermediateMaterial ToIntermediateMaterial(IEntry material) + { + var intermediateMat = new IntermediateMaterial(); + if (material == null) + { + intermediateMat.Name = "null"; + } + else + { + intermediateMat.Name = material.MemoryFullPath; + if (material is ImportEntry imp) + { + material = EntryImporter.ResolveImport(imp, new PackageCache()); + } + FindBestDiffAndNormForMaterial(intermediateMat, material as ExportEntry); + } + return intermediateMat; + } + + private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) + { + var intermediateMesh = new IntermediateMesh() + { + Name = mesh.Export.ObjectName.Instanced + }; + + // materials + foreach (var mat in mesh.Materials) + { + var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat)); intermediateMesh.Materials.Add(intermediateMat); } @@ -314,85 +391,123 @@ private static ModelRoot ToGltf(IntermediateMesh[] meshes, string versionInfo = } // skeleton - var baseSkeletonNode = new NodeBuilder(mesh.Name); - scene.AddNode(baseSkeletonNode); - var skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; - // one pass to create all the nodes without the hierarchy - for (int i = 0; i < mesh.Skeleton.Count; i++) - { - var bone = mesh.Skeleton[i]; - var nb = new NodeBuilder(bone.Name); - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) - .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); - baseSkeletonNode.AddNode(nb); - } - else - { - nb.WithLocalTranslation(TransformBonePosition(bone.Position)) - .WithLocalRotation(TransformBoneRotation(bone.Rotation)); - } - skeletonNodes[i] = nb; - } - // another pass to connect the hierarchy up - for (int i = 0; i < mesh.Skeleton.Count; i++) + NodeBuilder[] skeletonNodes = []; + if (mesh.Skeleton != null) { - var bone = mesh.Skeleton[i]; - var nb = skeletonNodes[i]; - if (bone.ParentIndex == -1 || bone.ParentIndex == i) + var baseSkeletonNode = new NodeBuilder(mesh.Name); + scene.AddNode(baseSkeletonNode); + skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; + // one pass to create all the nodes without the hierarchy + for (int i = 0; i < mesh.Skeleton.Count; i++) { - // this is a root bone; we don't need to do anything here - continue; + var bone = mesh.Skeleton[i]; + var nb = new NodeBuilder(bone.Name); + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; change the local transform to account for the coordiante system differences + nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) + .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); + baseSkeletonNode.AddNode(nb); + } + else + { + nb.WithLocalTranslation(TransformBonePosition(bone.Position)) + .WithLocalRotation(TransformBoneRotation(bone.Rotation)); + } + skeletonNodes[i] = nb; } - else + // another pass to connect the hierarchy up + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var parent = skeletonNodes[bone.ParentIndex]; - parent.AddNode(nb); + var bone = mesh.Skeleton[i]; + var nb = skeletonNodes[i]; + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; we don't need to do anything here + continue; + } + else + { + var parent = skeletonNodes[bone.ParentIndex]; + parent.AddNode(nb); + } } } foreach (var lod in mesh.LODs) { var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD_{lod.Index}"; - // TODO this is skeletal mesh data; make it work for static meshes too - var mb = new MeshBuilder(name); + if (mesh.Skeleton != null) + { + var mb = new MeshBuilder(name); - foreach (var section in lod.Sections) + foreach (var section in lod.Sections) + { + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + foreach (var tri in section.Triangles) + { + VertexBuilder GetVert(int i) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]) + .WithSkinning(intermediateVert.Influences); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + return vb; + } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + } + } + var skinnedMesh = scene.AddSkinnedMesh(mb, Matrix4x4.Identity, skeletonNodes); + skinnedMesh.WithName(name); + } + else { - var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); - foreach (var tri in section.Triangles) + var mb = new MeshBuilder(name); + + foreach (var section in lod.Sections) { - VertexBuilder GetVert(int i) + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + foreach (var tri in section.Triangles) { - var intermediateVert = section.Vertices[i]; - return new VertexBuilder() - .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]) - .WithSkinning(intermediateVert.Influences); + VertexBuilder GetVert(int i) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + return vb; + } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } + // TODO parent all the LODs to the same node? + var skinnedMesh = scene.AddRigidMesh(mb, Matrix4x4.Identity); + skinnedMesh.WithName(name); } - var skinnedMesh = scene.AddSkinnedMesh(mb, Matrix4x4.Identity, skeletonNodes); - skinnedMesh.WithName(name); } - // finish sockets by creating nodes under the bones they are attached to - for (int i = 0; i < mesh.Skeleton.Count; i++) + if (mesh.Skeleton != null) { - var nb = skeletonNodes[i]; - var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); - foreach (var socket in sockets) + // finish sockets by creating nodes under the bones they are attached to + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var socketBuilder = new NodeBuilder(socket.Name) - .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) - .WithLocalScale(TransformScale(socket.RelativeScale)); - nb.AddNode(socketBuilder); + var nb = skeletonNodes[i]; + var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); + foreach (var socket in sockets) + { + var socketBuilder = new NodeBuilder(socket.Name) + .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) + .WithLocalScale(TransformScale(socket.RelativeScale)); + nb.AddNode(socketBuilder); + } } } + + // TODO collision mesh? } var gltf = scene.ToGltf2(); @@ -1060,4 +1175,157 @@ public static IEntry FindEntryByMemeroryFullPath(IMEPackage pachage, string memo return null; } } + + public struct VertexTextureNOriginalIndex : IVertexCustom + { + #region constructors + + public VertexTextureNOriginalIndex(int originalIndex, IEnumerable UVs) + { + _originalIndex = originalIndex; + _texCoords = UVs.ToList(); + } + //public static implicit operator VertexTextureNOriginalIndex((Vector4 color, Vector2 tex, Single customId) tuple) + //{ + // return new VertexTextureNOriginalIndex(tuple.color, tuple.tex, tuple.customId); + //} + + //public VertexTextureNOriginalIndex(Vector4 color, Vector2 tex, Single customId) + //{ + // Color = color; + // TexCoord = tex; + // CustomId = customId; + //} + + //public VertexTextureNOriginalIndex(IVertexMaterial src) + //{ + // this.Color = src.MaxColors > 0 ? src.GetColor(0) : Vector4.One; + // this.TexCoord = src.MaxTextCoords > 0 ? src.GetTexCoord(0) : Vector2.Zero; + + // this.CustomId = 0; + + // if (src is VertexTextureNOriginalIndex custom) + // { + // this.CustomId = custom.CustomId; + // } + // else if (src is IVertexCustom otherx) + // { + // if (otherx.TryGetCustomAttribute(CUSTOMATTRIBUTENAME, out object attr0) && attr0 is float c0) this.CustomId = c0; + // } + //} + + #endregion + + #region data + + public const string OriginalIndexAttributeName = "_original_index"; + + private List _texCoords = []; + private int _originalIndex = -1; + + public int OriginalIndex + { + get => _originalIndex; + set => _originalIndex = value; + } + + IEnumerable> IVertexReflection.GetEncodingAttributes() + { + for (int i = 0; i < _texCoords.Count; i++) + { + yield return new KeyValuePair($"TEXCOORD_{i}", new AttributeFormat(DimensionType.VEC2)); + } + yield return new KeyValuePair(OriginalIndexAttributeName, new AttributeFormat(DimensionType.SCALAR)); + } + + public int MaxColors => 0; + + public int MaxTextCoords => _texCoords.Count; + + private static readonly string[] _CustomNames = { OriginalIndexAttributeName }; + public IEnumerable CustomAttributes => _CustomNames; + + #endregion + + #region API + + /// + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + { + return this.Subtract((VertexTextureNOriginalIndex)baseValue); + } + + /// + public VertexMaterialDelta Subtract(in VertexTextureNOriginalIndex baseValue) + { + throw new NotImplementedException(); + } + + /// + public void Add(in VertexMaterialDelta delta) + { + throw new NotImplementedException(); + } + + void IVertexMaterial.SetColor(int setIndex, Vector4 color) + { + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + void IVertexMaterial.SetTexCoord(int setIndex, Vector2 coord) + { + _texCoords ??= []; + if (setIndex < _texCoords.Count - 1) + { + _texCoords[setIndex] = coord; + } + else if (setIndex == _texCoords.Count) + { + _texCoords.Add(coord); + } + else + { + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + } + + public Vector4 GetColor(int index) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public Vector2 GetTexCoord(int index) + { + _texCoords ??= []; + if (index <= _texCoords.Count - 1) + { + return _texCoords[index]; + } + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public void Validate() + { + // TODO do I need this? + } + + public bool TryGetCustomAttribute(string attribute, out object value) + { + if (attribute != OriginalIndexAttributeName) + { + value = null; return false; + } + value = (float)_originalIndex; + return true; + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == OriginalIndexAttributeName && value is float floatValue) + { + _originalIndex = (int)floatValue; + } + } + #endregion + } } diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 081e568f0..82f69a0df 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1347,7 +1347,7 @@ private void GetMeshMaterials_Click(object sender, RoutedEventArgs e) // export mesh private void ExportGltf_Click(object sender, RoutedEventArgs args) { - PackageEditorExperimentsSquid.ExportSkeletetalMeshToGltf(GetPEWindow()); + PackageEditorExperimentsSquid.ExportMeshToGltf(GetPEWindow()); } private void ExportSelectedToPsx_Click(object sender, RoutedEventArgs e) { diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs index 648417ba2..b1bec5a9d 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/PSK.cs @@ -222,7 +222,7 @@ public static PSK CreateFromStaticMesh(StaticMesh staticMesh, int lodIdx = 0) numTriangles += (int)element.NumTriangles; for (uint t = 0; t < element.NumTriangles; t++) { - // TODO first index vs minVertexIndex? seem to match in all cases I have seen + // FirstIndex is the index within the index buffer. divide by three to get the triangle number uint baseIndex = element.FirstIndex; // TODO sometimes the index buffer might not be there (according to other comments in LEX) in which case we have to look at triangles in KDOPS int i1 = lod.IndexBuffer[baseIndex + t * 3]; From 3237c9784cb14eef8b681726624bfb5184012e21 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Mon, 26 Jan 2026 16:48:17 -0500 Subject: [PATCH 18/34] implemented exporting collision for static meshes. --- .../PackageEditor/Experiments/SquidGltf.cs | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index bfbb28ba0..a11497c0a 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -98,11 +98,49 @@ private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) intermediateMesh.LODs.Add(ToIntermediateLod(mesh.LODModels[i], i, materialMap)); } - // TODO collision mesh + // Collision mesh + var collisionMeshGeometry = mesh.GetCollisionMeshProperty(mesh.Export.FileRef); + if (collisionMeshGeometry != null) { + intermediateMesh.CollisionMeshElements = []; + if (collisionMeshGeometry?.GetProp>("ConvexElems") is ArrayProperty convexElems) + { + foreach (StructProperty convexElem in convexElems) + { + intermediateMesh.CollisionMeshElements.Add(ToIntermediateCollision(convexElem)); + } + } + } return intermediateMesh; } + private static IntermediateCollisionElement ToIntermediateCollision(StructProperty convexElem) + { + var intermediateCollision = new IntermediateCollisionElement(); + + var faceTriData = convexElem.GetProp>("FaceTriData"); + for (int i = 0; i < faceTriData.Count; i += 3) + { + intermediateCollision.Triangles.Add(new IntermediateTriangle() + { + VertIndex1 = faceTriData[i].Value, + VertIndex2 = faceTriData[i + 1].Value, + VertIndex3 = faceTriData[i + 2].Value + }); + } + + var vertexData = convexElem.GetProp>("VertexData"); + foreach (StructProperty vertex in vertexData) + { + float x = vertex.GetProp("X").Value; + float y = vertex.GetProp("Y").Value; + float z = vertex.GetProp("Z").Value; + intermediateCollision.Vertices.Add(new Vector3(x, z, y)); + } + + return intermediateCollision; + } + private static IntermediateLOD ToIntermediateLod(StaticMeshRenderData lod, int index, IEnumerable materialMapping) { var intermediateLod = new IntermediateLOD() @@ -484,8 +522,8 @@ VertexBuilder(name); + var primitive = mb.UsePrimitive(collisionMat); + + foreach (var tri in collisionElement.Triangles) + { + VertexBuilder GetVert(int i) + { + var intermediateVert = collisionElement.Vertices[i]; + var position = new Vector3(intermediateVert.X, intermediateVert.Y, intermediateVert.Z) / ScaleFactor; + var vb = new VertexBuilder() + .WithGeometry(position); + return vb; + } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + } + + // TODO parent it to the same node as the static mesh? + var rigidMesh = scene.AddRigidMesh(mb, Matrix4x4.Identity); + rigidMesh.WithName(name); + } + } } var gltf = scene.ToGltf2(); @@ -1033,14 +1100,22 @@ private class IntermediateMesh // will be null for static meshes public List Skeleton; public List LODs = []; - // TODO collision mesh(es)? public List Sockets = []; + public List CollisionMeshElements; public IntermediateMesh() { } } + // a collision mesh is made up of one or more convex elements + // they have vertices and triangles, but no LODs, materials, UVs, etc + private class IntermediateCollisionElement + { + public List Vertices = []; + public List Triangles = []; + } + private class IntermediateMaterial { public IntermediateMaterial() { } From e854caf0ae641532703fdb306a69b2986297d0b6 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 27 Jan 2026 07:56:47 -0500 Subject: [PATCH 19/34] fixed various bugs, got things mostly working how I want them to work. --- .../PackageEditorExperimentsSquid.cs | 6 +- .../PackageEditor/Experiments/SquidGltf.cs | 330 ++++++++++-------- 2 files changed, 184 insertions(+), 152 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 208c359ea..67ce0a6a1 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -122,12 +122,12 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) } if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh"], out var export)) { - var d = new SaveFileDialog { Filter = "glTF|*.glTF,*.glb", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}.glb"}; + var d = new SaveFileDialog { Filter = "glTF binary|*.glb|glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}.glb"}; if (d.ShowDialog() == true) { if (export.ClassName == "SkeletalMesh") { - SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } // TODO support other closely related types? else if (export.ClassName == "StaticMesh") @@ -136,7 +136,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) { ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); } - SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.FullDisplayedVersion}"); + SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } } } diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index a11497c0a..ecf3323b0 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -35,7 +35,7 @@ public class SquidGltf public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, string versionInfo = null) { var intermediateMesh = ToIntermediateMesh(mesh); - var gltf = ToGltf([intermediateMesh], versionInfo); + var gltf = ToGltf(intermediateMesh, versionInfo); // allow saving as glTF (human readable json, outputs a bin file and textures next to it) // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) @@ -51,7 +51,7 @@ public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, string versionInfo = null) { var intermediateMesh = ToIntermediateMesh(mesh); - var gltf = ToGltf([intermediateMesh], versionInfo); + var gltf = ToGltf(intermediateMesh, versionInfo); // allow saving as glTF (human readable json, outputs a bin file and textures next to it) // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) @@ -101,7 +101,8 @@ private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) // Collision mesh var collisionMeshGeometry = mesh.GetCollisionMeshProperty(mesh.Export.FileRef); - if (collisionMeshGeometry != null) { + if (collisionMeshGeometry != null) + { intermediateMesh.CollisionMeshElements = []; if (collisionMeshGeometry?.GetProp>("ConvexElems") is ArrayProperty convexElems) { @@ -385,201 +386,232 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, return intermediateLod; } - private static ModelRoot ToGltf(IntermediateMesh[] meshes, string versionInfo = null) + private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null) { var scene = new SceneBuilder(); - foreach (var mesh in meshes) + // TODO do I need this? + var containerNode = new NodeBuilder(mesh.Name); + scene.AddNode(containerNode); + + // Materials + List mats = []; + foreach (var intermediateMat in mesh.Materials) + { + var mat = new MaterialBuilder(intermediateMat.Name); + mat.WithDoubleSide(intermediateMat.TwoSided); + if (intermediateMat.DiffTexture != null) + { + var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); + var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); + diffImage.AlternateWriteFileName = $"{intermediateMat.DiffTexture.Export.ObjectNameString}.*"; + mat.WithBaseColor(diffImage); + } + if (intermediateMat.NormalTexture != null) + { + var normalMapBytes = intermediateMat.NormalTexture.GetPNG(intermediateMat.NormalTexture.GetTopMip()); + // flip the green channel to match the convention glTF uses + var img = IsImage.Load(normalMapBytes); + var colorMatrix = new ColorMatrix( + 1, 0, 0, 0, + 0, -1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + 0, 1, 0, 0 + ); + img.Mutate(x => x.ApplyProcessor(new FilterProcessor(colorMatrix))); + using (var ms = new MemoryStream()) + { + img.SaveAsPng(ms); + normalMapBytes = ms.ToArray(); + } + var normImage = ImageBuilder.From(normalMapBytes, $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped"); + normImage.AlternateWriteFileName = $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped.*"; + mat.WithNormal(normImage); + } + mats.Add(mat); + } + + // skeleton + NodeBuilder[] skeletonNodes = []; + if (mesh.Skeleton != null) { - List mats = []; - foreach (var intermediateMat in mesh.Materials) + skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; + // one pass to create all the nodes without the hierarchy + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var mat = new MaterialBuilder(intermediateMat.Name); - mat.WithDoubleSide(intermediateMat.TwoSided); - if (intermediateMat.DiffTexture != null) + var bone = mesh.Skeleton[i]; + var nb = new NodeBuilder(bone.Name); + if (bone.ParentIndex == -1 || bone.ParentIndex == i) { - var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); - var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); - diffImage.AlternateWriteFileName = $"{intermediateMat.DiffTexture.Export.ObjectNameString}.*"; - mat.WithBaseColor(diffImage); + // this is a root bone; change the local transform to account for the coordiante system differences + nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) + .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); + containerNode.AddNode(nb); } - if (intermediateMat.NormalTexture != null) + else { - var normalMapBytes = intermediateMat.NormalTexture.GetPNG(intermediateMat.NormalTexture.GetTopMip()); - // flip the green channel to match the convention glTF uses - var img = IsImage.Load(normalMapBytes); - var colorMatrix = new ColorMatrix( - 1, 0, 0, 0, - 0, -1, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - 0, 1, 0, 0 - ); - img.Mutate(x => x.ApplyProcessor(new FilterProcessor(colorMatrix))); - using (var ms = new MemoryStream()) - { - img.SaveAsPng(ms); - normalMapBytes = ms.ToArray(); - } - var normImage = ImageBuilder.From(normalMapBytes, $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped"); - normImage.AlternateWriteFileName = $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped.*"; - mat.WithNormal(normImage); + nb.WithLocalTranslation(TransformBonePosition(bone.Position)) + .WithLocalRotation(TransformBoneRotation(bone.Rotation)); } - mats.Add(mat); + skeletonNodes[i] = nb; } - - // skeleton - NodeBuilder[] skeletonNodes = []; - if (mesh.Skeleton != null) + // another pass to connect the hierarchy up + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var baseSkeletonNode = new NodeBuilder(mesh.Name); - scene.AddNode(baseSkeletonNode); - skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; - // one pass to create all the nodes without the hierarchy - for (int i = 0; i < mesh.Skeleton.Count; i++) + var bone = mesh.Skeleton[i]; + var nb = skeletonNodes[i]; + if (bone.ParentIndex == -1 || bone.ParentIndex == i) { - var bone = mesh.Skeleton[i]; - var nb = new NodeBuilder(bone.Name); - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) - .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); - baseSkeletonNode.AddNode(nb); - } - else - { - nb.WithLocalTranslation(TransformBonePosition(bone.Position)) - .WithLocalRotation(TransformBoneRotation(bone.Rotation)); - } - skeletonNodes[i] = nb; + // this is a root bone; we don't need to do anything here + continue; } - // another pass to connect the hierarchy up - for (int i = 0; i < mesh.Skeleton.Count; i++) + else { - var bone = mesh.Skeleton[i]; - var nb = skeletonNodes[i]; - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; we don't need to do anything here - continue; - } - else - { - var parent = skeletonNodes[bone.ParentIndex]; - parent.AddNode(nb); - } + var parent = skeletonNodes[bone.ParentIndex]; + parent.AddNode(nb); } } + } - foreach (var lod in mesh.LODs) + // LODs + foreach (var lod in mesh.LODs) + { + var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD_{lod.Index}"; + // SkeletalMesh version + if (mesh.Skeleton != null) { - var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD_{lod.Index}"; - if (mesh.Skeleton != null) - { - var mb = new MeshBuilder(name); + var mb = new MeshBuilder(name); - foreach (var section in lod.Sections) + foreach (var section in lod.Sections) + { + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + foreach (var tri in section.Triangles) { - var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); - foreach (var tri in section.Triangles) + VertexBuilder GetVert(int i) { - VertexBuilder GetVert(int i) - { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]) - .WithSkinning(intermediateVert.Influences); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - return vb; - } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPosition(intermediateVert.Position), + TransformDirection(intermediateVert.Normal.Value), + new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]) + .WithSkinning(intermediateVert.Influences); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + return vb; } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } + } + // create the skin for the first LOD, but not the others to avoid creating duplicate skins in the output file + if (lod.Index == 0) + { var skinnedMesh = scene.AddSkinnedMesh(mb, Matrix4x4.Identity, skeletonNodes); skinnedMesh.WithName(name); } else { - var mb = new MeshBuilder(name); + var rigidMesh = scene.AddRigidMesh(mb, Matrix4x4.Identity); + rigidMesh.WithName(name); + } + } + // StaticMesh version + else + { + var mb = new MeshBuilder(name); - foreach (var section in lod.Sections) + foreach (var section in lod.Sections) + { + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + foreach (var tri in section.Triangles) { - var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); - foreach (var tri in section.Triangles) + VertexBuilder GetVert(int i) { - VertexBuilder GetVert(int i) - { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry(TransformVertexPosition(intermediateVert.Position), TransformDirection(intermediateVert.Normal.Value), new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - return vb; - } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPosition(intermediateVert.Position), + TransformDirection(intermediateVert.Normal.Value), + new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + return vb; } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } - // TODO parent all the LODs to the same node? - var rigidMesh = scene.AddRigidMesh(mb, Matrix4x4.Identity); - rigidMesh.WithName(name); } + var meshNode = new NodeBuilder(); + containerNode.AddNode(meshNode); + var rigidMesh = scene.AddRigidMesh(mb, meshNode); + rigidMesh.WithName(name); } + } - if (mesh.Skeleton != null) + if (mesh.Skeleton != null) + { + // finish sockets by creating nodes under the bones they are attached to + for (int i = 0; i < mesh.Skeleton.Count; i++) { - // finish sockets by creating nodes under the bones they are attached to - for (int i = 0; i < mesh.Skeleton.Count; i++) + var nb = skeletonNodes[i]; + var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); + foreach (var socket in sockets) { - var nb = skeletonNodes[i]; - var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); - foreach (var socket in sockets) - { - var socketBuilder = new NodeBuilder(socket.Name) - .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) - .WithLocalScale(TransformScale(socket.RelativeScale)); - nb.AddNode(socketBuilder); - } + var socketBuilder = new NodeBuilder(socket.Name) + .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) + .WithLocalScale(TransformScale(socket.RelativeScale)); + nb.AddNode(socketBuilder); } } + } - if (mesh.CollisionMeshElements != null) - { - var collisionMat = new MaterialBuilder(); + if (mesh.CollisionMeshElements != null) + { + var collisionMat = new MaterialBuilder("CollisionMaterial"); - for (int i = 0; i < mesh.CollisionMeshElements.Count; i++) - { - var name = $"{mesh.Name}_Collision_{i}"; - var collisionElement = mesh.CollisionMeshElements[i]; + for (int i = 0; i < mesh.CollisionMeshElements.Count; i++) + { + var name = $"{mesh.Name}_Collision_{i}"; + var collisionElement = mesh.CollisionMeshElements[i]; - var mb = new MeshBuilder(name); - var primitive = mb.UsePrimitive(collisionMat); + var mb = new MeshBuilder(name); + var primitive = mb.UsePrimitive(collisionMat); - foreach (var tri in collisionElement.Triangles) + foreach (var tri in collisionElement.Triangles) + { + VertexBuilder GetVert(int i) { - VertexBuilder GetVert(int i) - { - var intermediateVert = collisionElement.Vertices[i]; - var position = new Vector3(intermediateVert.X, intermediateVert.Y, intermediateVert.Z) / ScaleFactor; - var vb = new VertexBuilder() - .WithGeometry(position); - return vb; - } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); + var intermediateVert = collisionElement.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry(intermediateVert / ScaleFactor); + return vb; } - - // TODO parent it to the same node as the static mesh? - var rigidMesh = scene.AddRigidMesh(mb, Matrix4x4.Identity); - rigidMesh.WithName(name); + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } + + var meshNode = new NodeBuilder(); + containerNode.AddNode(meshNode); + var rigidMesh = scene.AddRigidMesh(mb, meshNode); + rigidMesh.WithName(name); } } var gltf = scene.ToGltf2(); gltf.Asset.Generator = $"{versionInfo ?? "Legendary Explorer Core"}"; + // connect up the skin, if present + if (gltf.LogicalSkins.Count == 1) + { + foreach (var node in gltf.LogicalNodes) + { + if (node.Mesh != null && node.Skin == null) + { + node.WithSkin(gltf.LogicalSkins[0]); + } + } + } + return gltf; } @@ -640,7 +672,7 @@ private static Quaternion TransformBoneRotation(Quaternion input) #endregion #region import - + public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) { foreach (var node in gltf.LogicalNodes) @@ -705,7 +737,7 @@ Node FindJointParent(Node node) while (node.VisualParent != null) { node = node.VisualParent; - + if (nodes[0].Skin.Joints.Contains(node)) { return node; @@ -715,7 +747,7 @@ Node FindJointParent(Node node) } var parent = FindJointParent(joint); var intermediateJointIndex = boneMap.IndexOf(joint.LogicalIndex); - + if (parent == null) { rootJoints.Add(intermediateJointIndex); @@ -1387,7 +1419,7 @@ public void Validate() public bool TryGetCustomAttribute(string attribute, out object value) { if (attribute != OriginalIndexAttributeName) - { + { value = null; return false; } value = (float)_originalIndex; From 333474d0384862254ca76d35de29baea8a2c2929 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 27 Jan 2026 09:10:16 -0500 Subject: [PATCH 20/34] finally got it working exactly how I want. --- .../PackageEditor/Experiments/SquidGltf.cs | 146 +++++++++--------- 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index ecf3323b0..1d2959a08 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -432,48 +432,6 @@ private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null mats.Add(mat); } - // skeleton - NodeBuilder[] skeletonNodes = []; - if (mesh.Skeleton != null) - { - skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; - // one pass to create all the nodes without the hierarchy - for (int i = 0; i < mesh.Skeleton.Count; i++) - { - var bone = mesh.Skeleton[i]; - var nb = new NodeBuilder(bone.Name); - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) - .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); - containerNode.AddNode(nb); - } - else - { - nb.WithLocalTranslation(TransformBonePosition(bone.Position)) - .WithLocalRotation(TransformBoneRotation(bone.Rotation)); - } - skeletonNodes[i] = nb; - } - // another pass to connect the hierarchy up - for (int i = 0; i < mesh.Skeleton.Count; i++) - { - var bone = mesh.Skeleton[i]; - var nb = skeletonNodes[i]; - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; we don't need to do anything here - continue; - } - else - { - var parent = skeletonNodes[bone.ParentIndex]; - parent.AddNode(nb); - } - } - } - // LODs foreach (var lod in mesh.LODs) { @@ -504,17 +462,10 @@ VertexBuilder x.Bone == nb.Name); - foreach (var socket in sockets) - { - var socketBuilder = new NodeBuilder(socket.Name) - .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) - .WithLocalScale(TransformScale(socket.RelativeScale)); - nb.AddNode(socketBuilder); - } - } - } if (mesh.CollisionMeshElements != null) { @@ -597,18 +531,76 @@ VertexBuilder GetVert(int i) } } + // skeleton/sockets + NodeBuilder[] skeletonNodes = []; + if (mesh.Skeleton != null) + { + skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; + // one pass to create all the nodes without the hierarchy + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var bone = mesh.Skeleton[i]; + var nb = new NodeBuilder(bone.Name); + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; change the local transform to account for the coordiante system differences + nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) + .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); + containerNode.AddNode(nb); + } + else + { + nb.WithLocalTranslation(TransformBonePosition(bone.Position)) + .WithLocalRotation(TransformBoneRotation(bone.Rotation)); + } + skeletonNodes[i] = nb; + } + // another pass to connect the hierarchy up + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var bone = mesh.Skeleton[i]; + var nb = skeletonNodes[i]; + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; we don't need to do anything here + continue; + } + else + { + var parent = skeletonNodes[bone.ParentIndex]; + parent.AddNode(nb); + } + } + // finish sockets by creating nodes under the bones they are attached to + for (int i = 0; i < mesh.Skeleton.Count; i++) + { + var nb = skeletonNodes[i]; + var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); + foreach (var socket in sockets) + { + var socketBuilder = new NodeBuilder(socket.Name) + .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) + .WithLocalScale(TransformScale(socket.RelativeScale)); + nb.AddNode(socketBuilder); + } + } + } + var gltf = scene.ToGltf2(); gltf.Asset.Generator = $"{versionInfo ?? "Legendary Explorer Core"}"; - // connect up the skin, if present - if (gltf.LogicalSkins.Count == 1) + // collect the real nodes for the skeleton, in the exact same order + var jointNodes = skeletonNodes.Select(x => gltf.LogicalNodes.First(y => y.Name == x.Name)).ToArray(); + + // manually create the skin and then connect it up to the nodes containing the meshes + var skin = gltf.CreateSkin(mesh.Name); + skin.BindJoints(Matrix4x4.Identity, jointNodes); + foreach (var node in gltf.LogicalNodes) { - foreach (var node in gltf.LogicalNodes) + if (node.Mesh != null) { - if (node.Mesh != null && node.Skin == null) - { - node.WithSkin(gltf.LogicalSkins[0]); - } + node.WithSkin(skin); } } From 8b6f511cd47cafb2ae0a94b00501fd3d811a402d Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 27 Jan 2026 09:21:04 -0500 Subject: [PATCH 21/34] fixed static mesh export. --- .../Tools/PackageEditor/Experiments/SquidGltf.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 1d2959a08..5d3014346 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -593,14 +593,17 @@ VertexBuilder GetVert(int i) // collect the real nodes for the skeleton, in the exact same order var jointNodes = skeletonNodes.Select(x => gltf.LogicalNodes.First(y => y.Name == x.Name)).ToArray(); - // manually create the skin and then connect it up to the nodes containing the meshes - var skin = gltf.CreateSkin(mesh.Name); - skin.BindJoints(Matrix4x4.Identity, jointNodes); - foreach (var node in gltf.LogicalNodes) + if (mesh.Skeleton != null && mesh.Skeleton.Count > 0) { - if (node.Mesh != null) + // manually create the skin and then connect it up to the nodes containing the meshes + var skin = gltf.CreateSkin(mesh.Name); + skin.BindJoints(Matrix4x4.Identity, jointNodes); + foreach (var node in gltf.LogicalNodes) { - node.WithSkin(skin); + if (node.Mesh != null) + { + node.WithSkin(skin); + } } } From 38c2a3158754d91e829e8a27c372b877682c4332 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Tue, 27 Jan 2026 17:45:10 -0500 Subject: [PATCH 22/34] Implemented several good things: system to select what kind of materials you want to export. currently none (just the name) or textured with diff and norm. implemented most of glTF to Intermediate mesh. still need to deal with collision meshes and transforms implemented most of restoring the vertices to their original order, which matters a lot for certain use cases. Also, I fixed a bug that would make separate vertices per triangle on export. whoops. memoizing now which should also improve performance? I have also made import much more efficient if shared accessors are used. --- .../PackageEditorExperimentsSquid.cs | 7 +- .../PackageEditor/Experiments/SquidGltf.cs | 535 ++++++++++++------ 2 files changed, 376 insertions(+), 166 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 67ce0a6a1..c0db129b7 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -112,9 +112,10 @@ public static void ImportAnimSet(PackageEditorWindow pew) public static void ExportMeshToGltf(PackageEditorWindow pew) { + // TODO handle no open pcc if (pew.Pcc.Game == MEGame.ME1) { - ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + ShowError("This experiment does not yet support OT1; if you must do this, port it to another game first"); } if (pew.Pcc.Game == MEGame.UDK) { @@ -127,7 +128,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) { if (export.ClassName == "SkeletalMesh") { - SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, SquidGltf.MaterialExportLevel.NameOnly, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } // TODO support other closely related types? else if (export.ClassName == "StaticMesh") @@ -136,7 +137,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) { ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); } - SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, SquidGltf.MaterialExportLevel.NameOnly, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } } } diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 5d3014346..8189ad654 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1,5 +1,4 @@ -using LegendaryExplorerCore.Gammtek.Extensions; -using LegendaryExplorerCore.Gammtek.Extensions.Collections.Generic; +using LegendaryExplorerCore.Gammtek.Extensions.Collections.Generic; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; using LegendaryExplorerCore.Packages.CloningImportingAndRelinking; @@ -32,9 +31,20 @@ public class SquidGltf const float weightUnpackScale = 1f / 255; #region export - public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, string versionInfo = null) + public enum MaterialExportLevel { - var intermediateMesh = ToIntermediateMesh(mesh); + // export with only the name of the material. It will export faster and have much smaller file sizes. useful if you plan to roll your own materials in Blender or don't care about the materials + NameOnly, + // makes a best effort to identify the diff and norm of the material and attach the textures to the export so they can be imported directly into Blender. Exports will be much larger, and this can be very slow if there are a lot of materials. + Basic, + // TODO take a crack at implementing this + // for supported materials, export using diff, norm and spec, ready to be hooked up in Blender to diff, norm, roughness, metallic, emissive. A good start for renders. May be VERY slow to export. + // for unsupported materials, falls back to basic + //Enhanced + } + public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) + { + var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); var gltf = ToGltf(intermediateMesh, versionInfo); // allow saving as glTF (human readable json, outputs a bin file and textures next to it) // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable @@ -48,9 +58,9 @@ public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, } } - public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, string versionInfo = null) + public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) { - var intermediateMesh = ToIntermediateMesh(mesh); + var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); var gltf = ToGltf(intermediateMesh, versionInfo); // allow saving as glTF (human readable json, outputs a bin file and textures next to it) // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable @@ -64,7 +74,7 @@ public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, str } } - private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) + private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh, MaterialExportLevel materialSetting) { var intermediateMesh = new IntermediateMesh() { @@ -88,7 +98,7 @@ private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh) } foreach (var mat in materialMap) { - var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat)); + var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat), materialSetting); intermediateMesh.Materials.Add(intermediateMat); } @@ -202,7 +212,7 @@ private static IntermediateLOD ToIntermediateLod(StaticMeshRenderData lod, int i return intermediateLod; } - private static IntermediateMaterial ToIntermediateMaterial(IEntry material) + private static IntermediateMaterial ToIntermediateMaterial(IEntry material, MaterialExportLevel materialSetting) { var intermediateMat = new IntermediateMaterial(); if (material == null) @@ -212,16 +222,19 @@ private static IntermediateMaterial ToIntermediateMaterial(IEntry material) else { intermediateMat.Name = material.MemoryFullPath; - if (material is ImportEntry imp) + if (materialSetting == MaterialExportLevel.Basic) { - material = EntryImporter.ResolveImport(imp, new PackageCache()); + if (material is ImportEntry imp) + { + material = EntryImporter.ResolveImport(imp, new PackageCache()); + } + FindBestDiffAndNormForMaterial(intermediateMat, material as ExportEntry); } - FindBestDiffAndNormForMaterial(intermediateMat, material as ExportEntry); } return intermediateMat; } - private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) + private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh, MaterialExportLevel materialSetting) { var intermediateMesh = new IntermediateMesh() { @@ -231,7 +244,7 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh) // materials foreach (var mat in mesh.Materials) { - var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat)); + var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat), materialSetting); intermediateMesh.Materials.Add(intermediateMat); } @@ -440,24 +453,28 @@ private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null if (mesh.Skeleton != null) { var mb = new MeshBuilder(name); - foreach (var section in lod.Sections) { var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + var vertexBuilders = new VertexBuilder?[section.Vertices.Count]; foreach (var tri in section.Triangles) { VertexBuilder GetVert(int i) { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry( - TransformVertexPosition(intermediateVert.Position), - TransformDirection(intermediateVert.Normal.Value), - new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]) - .WithSkinning(intermediateVert.Influences); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - return vb; + if (!vertexBuilders[i].HasValue) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPositionToGltf(intermediateVert.Position), + TransformDirectionToGltf(intermediateVert.Normal.Value), + new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]) + .WithSkinning(intermediateVert.Influences); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + vertexBuilders[i] = vb; + } + return vertexBuilders[i].Value; } primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } @@ -475,19 +492,24 @@ VertexBuilder?[section.Vertices.Count]; foreach (var tri in section.Triangles) { VertexBuilder GetVert(int i) { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry( - TransformVertexPosition(intermediateVert.Position), - TransformDirection(intermediateVert.Normal.Value), - new Vector4(TransformDirection(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - return vb; + if (!vertexBuilders[i].HasValue) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPositionToGltf(intermediateVert.Position), + TransformDirectionToGltf(intermediateVert.Normal.Value), + new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + vertexBuilders[i] = vb; + } + return vertexBuilders[i].Value; } primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } @@ -544,14 +566,14 @@ VertexBuilder GetVert(int i) if (bone.ParentIndex == -1 || bone.ParentIndex == i) { // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformRootBonePosition(bone.Position)) - .WithLocalRotation(TransformRootBoneRotation(bone.Rotation)); + nb.WithLocalTranslation(TransformRootBonePositionToGltf(bone.Position)) + .WithLocalRotation(TransformRootBoneRotationToGltf(bone.Rotation)); containerNode.AddNode(nb); } else { - nb.WithLocalTranslation(TransformBonePosition(bone.Position)) - .WithLocalRotation(TransformBoneRotation(bone.Rotation)); + nb.WithLocalTranslation(TransformBonePositionToGltf(bone.Position)) + .WithLocalRotation(TransformBoneRotationToGltf(bone.Rotation)); } skeletonNodes[i] = nb; } @@ -579,9 +601,9 @@ VertexBuilder GetVert(int i) foreach (var socket in sockets) { var socketBuilder = new NodeBuilder(socket.Name) - .WithLocalTranslation(TransformBonePosition(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotation(socket.RelativeRotation)) - .WithLocalScale(TransformScale(socket.RelativeScale)); + .WithLocalTranslation(TransformBonePositionToGltf(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotationToGltf(socket.RelativeRotation)) + .WithLocalScale(TransformScaleToGltf(socket.RelativeScale)); nb.AddNode(socketBuilder); } } @@ -610,17 +632,17 @@ VertexBuilder GetVert(int i) return gltf; } - private static Vector3 TransformVertexPosition(Vector3 input) + private static Vector3 TransformVertexPositionToGltf(Vector3 input) { return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } - private static Vector3 TransformDirection(Vector3 input) + private static Vector3 TransformDirectionToGltf(Vector3 input) { return Vector3.Normalize(new Vector3(input.X, input.Z, input.Y)); } - private static Vector3 TransformBonePosition(Vector3 input) + private static Vector3 TransformBonePositionToGltf(Vector3 input) { return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; } @@ -629,32 +651,32 @@ private static Vector3 TransformBonePosition(Vector3 input) private static readonly float QuatHalf = (float)(Math.Sqrt(2) / 2); - private static Quaternion TransformRootBoneRotation(Quaternion input) + private static Quaternion TransformRootBoneRotationToGltf(Quaternion input) { // add a -90 degree rotation around the x axis var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); return Quaternion.Normalize(transform * input); } - private static Vector3 TransformRootBonePosition(Vector3 input) + private static Vector3 TransformRootBonePositionToGltf(Vector3 input) { return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; } - private static Vector3 TransformScale(Vector3 input) + private static Vector3 TransformScaleToGltf(Vector3 input) { // TODO check if this is actually the right transform return new Vector3(input.X, input.Z, input.Y); } - private static Quaternion TransformSocketRotation(Quaternion input) + private static Quaternion TransformSocketRotationToGltf(Quaternion input) { // add a 90 degree rotation around the x axis var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); return Quaternion.Normalize(transform * input); } - private static Quaternion TransformBoneRotation(Quaternion input) + private static Quaternion TransformBoneRotationToGltf(Quaternion input) { // first, get it into the form glTF expects due to the swapped axes var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); @@ -667,35 +689,153 @@ private static Quaternion TransformBoneRotation(Quaternion input) #endregion #region import - public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) { - foreach (var node in gltf.LogicalNodes) + CollectMeshes(gltf, out var skeletalMeshes, out var staticMeshes); + int totalMeshes = skeletalMeshes.Count + staticMeshes.Count; + if (totalMeshes == 0) + { + // TODO show a warning or something + return; + } + if (totalMeshes > 1) + { + // TODO add support for this? + throw new NotImplementedException("I can't import where there is more than one mesh per file yet"); + } + foreach (var skelMesh in skeletalMeshes) + { + var intermediateMesh = ToIntermediateMesh(skelMesh.Item1, skelMesh.Item2); + CleanUpIntermediateMesh(intermediateMesh); + // TODO to SkeletalMesh! + } + foreach (var statMesh in staticMeshes) + { + var intermediateMesh = ToIntermediateMesh(statMesh.Item1, statMesh.Item2); + CleanUpIntermediateMesh(intermediateMesh); + // TODO to Static Mesh! + } + } + + private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) + { + foreach (var lod in mesh.LODs) + { + ReconstructVertexOrder(lod); + } + // TODO generate tangents, normals if not present + return mesh; + } + + private static bool ReconstructVertexOrder(IntermediateLOD lod) + { + // make sure this uses shared vertices. This is a requirement to reconstruct vertex order + // having only one section also satisfies this + var vertices = lod.Sections[0].Vertices; + if (lod.Sections.Count > 1) { - // TODO sort meshes to group them into LODs - if (!node.Mesh.IsNull()) + if (!lod.Sections.All(x => x.Vertices == vertices)) { - var intermediateMesh = ToIntermediateMesh(node); - if (node.Skin.IsNull()) - { - //ImportStaticMesh(node); - } - else - { - ImportSkeletalMesh(intermediateMesh, pcc); - } + // they do not have shared verts, so we can't reconstruct the original order + return false; + } + } + // now that we know there is only one vert array, check to see if the indices got messed up during editing + // group the vertices by original index + var groupedVerts = vertices.GroupBy(x => x.OriginalIndex); + // if there are any duplicates, we cannot reconstitute the the original order + // this will happen primarily due to topology edits, especially merging vertices + // this will also fail if they did not export the original indices from blender, as they will all be 0, which is fine + if (groupedVerts.Any(x => x.Count() != 1)) + { + // I'm not sure why it is splitting them. The normals appear to be ever so slightly different maybe? + // I bet I could dedpue them based on slight normal differences + // debug + var eval = groupedVerts.Where(x => x.Count() != 1).Select(x => x.ToArray()).ToList(); + return false; + } + // put them in their original order + var verticesOriginalOrder = vertices.OrderBy(x => x.OriginalIndex).ToList(); + // make sure we actually have 0-n, not skipping any + for (int i = 0; i < verticesOriginalOrder.Count; i++) + { + if (verticesOriginalOrder[i].OriginalIndex != i) + { + return false; } } + + // TODO do I have any way to check how many vertices it originally had? technically I could delete vertices from the end of the list and this would still pass + // I can look into storing some metadata about the original vertex count + + // assume at this point it is fine. replace the verts on each section, replace the triangles with new ones that point to the original indices + foreach (var section in lod.Sections) + { + section.Vertices = verticesOriginalOrder; + section.Triangles = [.. section.Triangles.Select(x => new IntermediateTriangle() + { + VertIndex1 = vertices[x.VertIndex1].OriginalIndex, + VertIndex2 = vertices[x.VertIndex2].OriginalIndex, + VertIndex3 = vertices[x.VertIndex3].OriginalIndex + })]; + } + + // at this point, we have restored the vertex order to the original, and the triangle order should also be the same as it is in Blender, which will match exported if you haven't messed with things + + return true; + } + + private static void CollectMeshes(ModelRoot modelRoot, out List<(string, Node[])> skeletalMeshes, out List<(string, Node[])> staticMeshes) + { + var meshes = modelRoot.LogicalNodes.Where(node => node.Mesh != null).GroupBy(node => node.VisualParent); + // TODO make sure they all have the same skin? + skeletalMeshes = [..meshes.Where(x => x.All(node => node.Skin != null)).Select(group => (group.Key.Name, group.ToArray()))]; + // TODO what if they are at the root? What if there is more than one at the root? + staticMeshes = [.. meshes.Where(x => x.All(node => node.Skin == null)).Select(group => (group.Key.Name, group.ToArray()))]; } - private static IntermediateMesh ToIntermediateMesh(params Node[] nodes) + private static bool DoesMeshUseSharedVertexAccessors(Mesh mesh) + { + if (mesh.Primitives.Count == 1) + { + // no way to have separate accessors when there is only one primative + return true; + } + bool IsAttributeShared(string attribute) + { + if (mesh.Primitives[0].VertexAccessors.ContainsKey(attribute)) + { + var accessor = mesh.Primitives[0].VertexAccessors[attribute]; + return mesh.Primitives.All(x => x.VertexAccessors[attribute] == accessor); + } + else + { + return mesh.Primitives.All(x => !x.VertexAccessors.ContainsKey(attribute)); + } + } + // make sure all these accessors are shared, meaning they are reading the same underlying data + return IsAttributeShared("POSITION") + && IsAttributeShared("NORMAL") + && IsAttributeShared("TANGENT") + && IsAttributeShared("TEXCOORD_0") + && IsAttributeShared("TEXCOORD_0") + && IsAttributeShared("TEXCOORD_0") + && IsAttributeShared("TEXCOORD_0") + && IsAttributeShared("JOINTS_0") + && IsAttributeShared("JOINTS_0") + && IsAttributeShared("WEIGHTS_0") + && IsAttributeShared("WEIGHTS_0") + && IsAttributeShared(VertexTextureNOriginalIndex.OriginalIndexAttributeName); + } + private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes) { if (nodes.Length == 0) { - throw new ArgumentException(); + throw new ArgumentException(nameof(nodes)); } + // TODO sort this into groups of LODs vs collision components + var skeletalMesh = nodes[0].Skin != null; int vertIndex = 0; - int triangleIndex = 0; int lodIndex = 0; int boneIndex = 0; // maps from material index within the gltf file to materials within this mesh (in the array order) @@ -706,7 +846,8 @@ private static IntermediateMesh ToIntermediateMesh(params Node[] nodes) var intermediateMesh = new IntermediateMesh(); - if (nodes[0].Skin != null) + // skeleton + if (skeletalMesh) { intermediateMesh.Skeleton = []; foreach (var joint in nodes[0].Skin.Joints) @@ -716,10 +857,6 @@ private static IntermediateMesh ToIntermediateMesh(params Node[] nodes) { Index = boneIndex++, Name = joint.Name, - // position is relative to the parent bone in both storage systems, so we only need to flip y to account for the different y direction convention - Position = joint.LocalTransform.Translation with { Y = -joint.LocalTransform.Translation.Y }, - // rotation is also relative to the parent, and not messed up by the y axis difference, so we need to leave it alone - Rotation = joint.LocalTransform.Rotation, }); } // reconstruct the hierarchy of bones from the node hierarchy. There can be other nodes in between joints, so we have to check all ancestors @@ -746,17 +883,17 @@ Node FindJointParent(Node node) if (parent == null) { rootJoints.Add(intermediateJointIndex); - // for root bones, we need to adjust the rotation and position to account for the coordinate system differences intermediateMesh.Skeleton[intermediateJointIndex].ParentIndex = -1; - intermediateMesh.Skeleton[intermediateJointIndex].Position = ScaleForME(Yup2Zup(joint.LocalTransform.Translation)); - // we need to rotate the root node's rotation to account for the difference in coordinate systems between ME and glTF (glTF uses y up) - intermediateMesh.Skeleton[intermediateJointIndex].Rotation = Yup2Zup(joint.LocalTransform.Rotation); + intermediateMesh.Skeleton[intermediateJointIndex].Position = TransformRootBonePositionFromGltf(joint.LocalTransform.Translation); + intermediateMesh.Skeleton[intermediateJointIndex].Rotation = TransformRootBoneRotationFromGltf(joint.LocalTransform.Rotation); } else { var intermediateParentIndex = boneMap.IndexOf(parent.LogicalIndex); intermediateMesh.Skeleton[intermediateJointIndex].ParentIndex = intermediateParentIndex; intermediateMesh.Skeleton[intermediateParentIndex].NumChildren++; + intermediateMesh.Skeleton[intermediateJointIndex].Position = TransformBonePositionFromGltf(joint.LocalTransform.Translation); + intermediateMesh.Skeleton[intermediateJointIndex].Rotation = TransformBoneRotationFromGltf(joint.LocalTransform.Rotation); } } if (rootJoints.Count > 1) @@ -765,58 +902,68 @@ Node FindJointParent(Node node) // just leave it alone? does ME technically require a single root bone? throw new NotImplementedException("This skeleton doesn't seem to have a single root bone, and I don't know how to handle that yet."); } + + // sockets + Node[] FindSockets(Node joint) + { + // look for direct children of a bone which are not themselves bones + return [..joint.VisualChildren.Where(x => !x.IsSkinJoint)]; + } + foreach (var joint in nodes[0].Skin.Joints) + { + var sockets = FindSockets(joint); + foreach (var socket in sockets) + { + intermediateMesh.Sockets.Add(new IntermediateSocket() + { + Bone = joint.Name, + Name = socket.Name, + RelativeLocation = TransformSocketLocationFromGltf(socket.LocalTransform.Translation), + RelativeRotation = TransformSocketRotationFromGltf(socket.LocalTransform.Rotation), + RelativeScale = TransformScaleFromGltf(socket.LocalTransform.Scale) + }); + } + } } foreach (var node in nodes) { var LOD = new IntermediateLOD() { Index = lodIndex++ }; - // a primitive, for our uses, will roughly correspond to a material - // technically it corresponds to a GPU rendering pass, which can be other things, but is most likely to be a material for us. - foreach (var prim in node.Mesh.Primitives) + + var sharedAccessors = DoesMeshUseSharedVertexAccessors(node.Mesh); + + List sharedVerts = null; + if (sharedAccessors) { - switch (prim.DrawPrimitiveType) - { - // we do not support points or lines outside the context of a triangle; ignore these if they come up, which is unlikely - case PrimitiveType.POINTS: - case PrimitiveType.LINES: - case PrimitiveType.LINE_STRIP: - case PrimitiveType.LINE_LOOP: - continue; - } - // material; the material indices in the glTF are global to the file, shared between meshes. We need to get to a list of materials for just this mesh - // each time we encounter a new material index, we will put it in an array - // we will count a null material as having an index of -1 and leave this material empty in LEX - var gltfMatIndex = prim.Material?.LogicalIndex ?? -1; - var meshMatIndex = materialMap.IndexOf(gltfMatIndex); - if (meshMatIndex == -1) - { - // TODO make sure this is not off by 1 - meshMatIndex = materialMap.Count; - materialMap.Add(gltfMatIndex); - intermediateMesh.Materials.Add(new IntermediateMaterial(prim.Material?.Name ?? "null")); - } + sharedVerts = GetPrimativeVertices(node.Mesh.Primitives[0]); + } - var meshSection = new IntermediateMeshSection() - { - MaterialIndex = meshMatIndex - }; + List GetPrimativeVertices(MeshPrimitive prim) + { + List verts = []; // this gets us all the attributes of each vertex in order, each in their own array, where all arrays are the same size var vertColumns = prim.GetVertexColumns(); + int[] originalIndices = null; + // this is custom metadata I attach on export. It will not be present on re-export from Blender with the default export settings, but can be enabled + if (prim.VertexAccessors.ContainsKey(VertexTextureNOriginalIndex.OriginalIndexAttributeName)) + { + originalIndices = [.. prim.VertexAccessors[VertexTextureNOriginalIndex.OriginalIndexAttributeName].AsScalarArray().Select(x => (int)x)]; + } for (int i = 0; i < vertColumns.Positions.Count; i++) { var vert = new IntermediateVertex { Index = vertIndex++, - Position = ScaleForME(Yup2Zup(vertColumns.Positions[i])) + Position = TransformVertexPositionFromGltf(vertColumns.Positions[i]), }; // Normals // usually present, but not required if (vertColumns.Normals != null) { - vert.Normal = Yup2Zup(vertColumns.Normals[i]); + vert.Normal = TranformDirectionFromGltf(vertColumns.Normals[i]); } // Tangents @@ -824,7 +971,7 @@ Node FindJointParent(Node node) if (vertColumns.Tangents != null) { var tanX = new Vector3(vertColumns.Tangents[i].X, vertColumns.Tangents[i].Y, vertColumns.Tangents[i].Z); - vert.Tangent = Yup2Zup(tanX); + vert.Tangent = TranformDirectionFromGltf(tanX); vert.BiTangentDirection = vertColumns.Tangents[i].W; } @@ -844,31 +991,67 @@ void AddUV(IList? column) AddUV(vertColumns.TexCoords3); // weights - // only present for skeletal meshes - if (vertColumns.Joints0 != null) + void AddWeights(IList jointsList, IList weightsList) { - var bones = vertColumns.Joints0[i]; - var weights = vertColumns.Weights0[i]; + if (jointsList == null || weightsList == null) + { + return; + } + var bones = jointsList[i]; + var weights = weightsList[i]; for (int j = 0; j < 4; j++) { vert.Influences.Add((int)bones[j], weights[j]); } } + // only present for skeletal meshes + AddWeights(vertColumns.Joints0, vertColumns.Weights0); // unlikely to be present, we just need to add them to make sure we cull the right ones later - if (vertColumns.Joints1 != null) + AddWeights(vertColumns.Joints1, vertColumns.Weights1); + + if (originalIndices != null) { - var bones = vertColumns.Joints1[i]; - var weights = vertColumns.Weights1[i]; - for (int j = 0; j < 4; j++) - { - var intermediateBoneIndex = boneMap.IndexOf((int)bones[j]); - vert.Influences.Add(intermediateBoneIndex, weights[j]); - } + vert.OriginalIndex = originalIndices[i]; } - //meshSection.Vertices.Add(vert); + verts.Add(vert); + } + return verts; + } + // a primitive, for our uses, will roughly correspond to a material + // technically it corresponds to a GPU rendering pass, which can be other things, but is most likely to be a material for us. + foreach (var prim in node.Mesh.Primitives) + { + switch (prim.DrawPrimitiveType) + { + // we do not support points or lines outside the context of a triangle; ignore these if they come up, which is unlikely + case PrimitiveType.POINTS: + case PrimitiveType.LINES: + case PrimitiveType.LINE_STRIP: + case PrimitiveType.LINE_LOOP: + continue; + } + // material; the material indices in the glTF are global to the file, shared between meshes. We need to get to a list of materials for just this mesh + // each time we encounter a new material index, we will put it in an array + // we will count a null material as having an index of -1 and leave this material empty in LEX + var gltfMatIndex = prim.Material?.LogicalIndex ?? -1; + var meshMatIndex = materialMap.IndexOf(gltfMatIndex); + if (meshMatIndex == -1) + { + // TODO make sure this is not off by 1 + meshMatIndex = materialMap.Count; + materialMap.Add(gltfMatIndex); + intermediateMesh.Materials.Add(new IntermediateMaterial(prim.Material?.Name ?? "null")); } + var meshSection = new IntermediateMeshSection() + { + MaterialIndex = meshMatIndex + }; + + // use the shared vertices, if available + meshSection.Vertices = sharedVerts ?? GetPrimativeVertices(prim); + // this gets us a list of int triplets; the indices of each triangle var triIndices = prim.GetTriangleIndices(); @@ -890,9 +1073,64 @@ void AddUV(IList? column) intermediateMesh.LODs.Add(LOD); } + // TODO collision mesh components for static meshes return intermediateMesh; } + private static Vector3 TransformSocketLocationFromGltf(Vector3 input) + { + return input * ScaleFactor; + } + private static Quaternion TransformSocketRotationFromGltf(Quaternion input) + { + return input; + } + private static Vector3 TransformScaleFromGltf(Vector3 input) + { + return input; + } + private static Vector3 TranformDirectionFromGltf(Vector3 input) + { + return input; + } + private static Vector3 TransformBonePositionFromGltf(Vector3 input) + { + //return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; + return input * ScaleFactor; + } + + private static Vector3 TransformVertexPositionFromGltf(Vector3 input) + { + //return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; + return input * ScaleFactor; + } + + private static Quaternion TransformRootBoneRotationFromGltf(Quaternion input) + { + //// add a -90 degree rotation around the x axis + //var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); + //return Quaternion.Normalize(transform * input); + return input; + } + + private static Vector3 TransformRootBonePositionFromGltf(Vector3 input) + { + //return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; + return input * ScaleFactor; + } + + private static Quaternion TransformBoneRotationFromGltf(Quaternion input) + { + //// first, get it into the form glTF expects due to the swapped axes + //var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); + //// next, we undo the rotation introduced by the parent + //temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + //// finally, we rotate the child in its local axes + //temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); + //return Quaternion.Normalize(temp); + return input; + } + private static void ImportSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package) { var meshBin = SkeletalMesh.Create(); @@ -1102,21 +1340,21 @@ private static void SetNumMaterialSlots(SkeletalMesh meshBinary, int numMaterial } } - private static Vector3 Yup2Zup(Vector3 input) - { - return new Vector3(input.X, input.Z, input.Y); - } + //private static Vector3 Yup2Zup(Vector3 input) + //{ + // return new Vector3(input.X, input.Z, input.Y); + //} - private static Quaternion Yup2Zup(Quaternion input) - { - var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); - return Quaternion.Normalize(input * transformQuat); - } + //private static Quaternion Yup2Zup(Quaternion input) + //{ + // var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); + // return Quaternion.Normalize(input * transformQuat); + //} - private static Vector3 ScaleForME(Vector3 input) - { - return input * ScaleFactor; - } + //private static Vector3 ScaleForME(Vector3 input) + //{ + // return input * ScaleFactor; + //} #endregion #region intermediate @@ -1201,8 +1439,6 @@ private struct IntermediateVertex public List UVs = []; // only present for skeletal meshes. The engine supports a maximum of four influences, so that is the max length public List<(int influenceBone, float weight)> Influences = []; - // no known use yet, but static meshes might support it - //Vector4 Color; // used to store the original index when we export it from ME to glTF; can hopefully help us reconsitutue it later public int OriginalIndex; public IntermediateVertex() @@ -1278,6 +1514,7 @@ public static IEntry FindEntryByMemeroryFullPath(IMEPackage pachage, string memo } } + // A custom Vertex Type for SharpGltf so we can have a variable number of UVs, plus attach custom metadata about the original index of each vertex to reconstruct the order on import public struct VertexTextureNOriginalIndex : IVertexCustom { #region constructors @@ -1285,42 +1522,14 @@ public struct VertexTextureNOriginalIndex : IVertexCustom public VertexTextureNOriginalIndex(int originalIndex, IEnumerable UVs) { _originalIndex = originalIndex; - _texCoords = UVs.ToList(); + _texCoords = [.. UVs]; } - //public static implicit operator VertexTextureNOriginalIndex((Vector4 color, Vector2 tex, Single customId) tuple) - //{ - // return new VertexTextureNOriginalIndex(tuple.color, tuple.tex, tuple.customId); - //} - - //public VertexTextureNOriginalIndex(Vector4 color, Vector2 tex, Single customId) - //{ - // Color = color; - // TexCoord = tex; - // CustomId = customId; - //} - - //public VertexTextureNOriginalIndex(IVertexMaterial src) - //{ - // this.Color = src.MaxColors > 0 ? src.GetColor(0) : Vector4.One; - // this.TexCoord = src.MaxTextCoords > 0 ? src.GetTexCoord(0) : Vector2.Zero; - - // this.CustomId = 0; - - // if (src is VertexTextureNOriginalIndex custom) - // { - // this.CustomId = custom.CustomId; - // } - // else if (src is IVertexCustom otherx) - // { - // if (otherx.TryGetCustomAttribute(CUSTOMATTRIBUTENAME, out object attr0) && attr0 is float c0) this.CustomId = c0; - // } - //} #endregion #region data - public const string OriginalIndexAttributeName = "_original_index"; + public const string OriginalIndexAttributeName = "_ORIGINAL_INDEX"; private List _texCoords = []; private int _originalIndex = -1; From c8b630df2b30b44f46af34df8a2f0b4397c42c39 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Thu, 29 Jan 2026 10:45:14 -0500 Subject: [PATCH 23/34] implemented most of skeletal mesh import as new export. I still have a few things to clean up. --- .../PackageEditorExperimentsSquid.cs | 11 +- .../PackageEditor/Experiments/SquidGltf.cs | 823 +++++++++++++----- 2 files changed, 629 insertions(+), 205 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index c0db129b7..4935307c5 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -112,7 +112,10 @@ public static void ImportAnimSet(PackageEditorWindow pew) public static void ExportMeshToGltf(PackageEditorWindow pew) { - // TODO handle no open pcc + if (pew.Pcc == null) + { + return; + } if (pew.Pcc.Game == MEGame.ME1) { ShowError("This experiment does not yet support OT1; if you must do this, port it to another game first"); @@ -149,6 +152,10 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) public static void ImportGltf(PackageEditorWindow pew) { + if (pew.Pcc == null) + { + return; + } if (pew.Pcc.Game == MEGame.ME1) { ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); @@ -172,7 +179,7 @@ private static bool GetGltfFromFile(out SharpGLTF.Schema2.ModelRoot gltf, out st if (d.ShowDialog() == true) { filePath = d.FileName; - gltf = SharpGLTF.Schema2.ModelRoot.Load(filePath); + gltf = SharpGLTF.Schema2.ModelRoot.Load(filePath, SharpGLTF.Validation.ValidationMode.Skip); return true; } diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 8189ad654..5793bf053 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -29,6 +29,9 @@ public class SquidGltf { private const float ScaleFactor = 100; const float weightUnpackScale = 1f / 255; + // the Mass Effect binary mesh format enforces there be a maximum of 4 bone influences per vertex + const int MaxBoneInfluences = 4; + static readonly float[] displayFactors = [1.0f, 0.25f, 0.1f]; #region export public enum MaterialExportLevel @@ -46,32 +49,14 @@ public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, { var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); var gltf = ToGltf(intermediateMesh, versionInfo); - // allow saving as glTF (human readable json, outputs a bin file and textures next to it) - // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable - if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) - { - gltf.SaveGLB(filePath); - } - else - { - gltf.SaveGLTF(filePath); - } + gltf.Save(filePath); } public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) { var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); var gltf = ToGltf(intermediateMesh, versionInfo); - // allow saving as glTF (human readable json, outputs a bin file and textures next to it) - // or a glb, which bundles all of that stuff together into a single file. more space efficient and transportable - if (".glb".CaseInsensitiveEquals(Path.GetExtension(filePath))) - { - gltf.SaveGLB(filePath); - } - else - { - gltf.SaveGLTF(filePath); - } + gltf.Save(filePath); } private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh, MaterialExportLevel materialSetting) @@ -176,7 +161,6 @@ private static IntermediateLOD ToIntermediateLod(StaticMeshRenderData lod, int i var intermediateVert = new IntermediateVertex() { - Index = i, OriginalIndex = i, Position = lod.PositionVertexBuffer.VertexData[i], Normal = (Vector3)vert.TangentZ, @@ -363,7 +347,6 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, } vertices.Add(new IntermediateVertex() { - Index = i, Position = originalVertex.Position, Normal = (Vector3)originalVertex.TangentZ, Tangent = (Vector3)originalVertex.TangentX, @@ -689,7 +672,7 @@ private static Quaternion TransformBoneRotationToGltf(Quaternion input) #endregion #region import - public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) + public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry existingMesh = null) { CollectMeshes(gltf, out var skeletalMeshes, out var staticMeshes); int totalMeshes = skeletalMeshes.Count + staticMeshes.Count; @@ -698,16 +681,18 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) // TODO show a warning or something return; } - if (totalMeshes > 1) - { - // TODO add support for this? - throw new NotImplementedException("I can't import where there is more than one mesh per file yet"); - } + // TODO right now I just import them all as new meshes, which seems to work ok for skeletal meshes + // but if they are replacing a mesh I will need to make them pick one + //if (totalMeshes > 1) + //{ + // // TODO add support for this? + // throw new NotImplementedException("I can't import where there is more than one mesh per file yet"); + //} foreach (var skelMesh in skeletalMeshes) { var intermediateMesh = ToIntermediateMesh(skelMesh.Item1, skelMesh.Item2); CleanUpIntermediateMesh(intermediateMesh); - // TODO to SkeletalMesh! + ToSkeletalMesh(intermediateMesh, pcc, existingMesh); } foreach (var statMesh in staticMeshes) { @@ -719,43 +704,170 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc) private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) { + // TODO make sure everything is normalized properly? foreach (var lod in mesh.LODs) { + // fill in data that was not provided on import + CalculateNormalsIfNeeded(lod); + CalculateTangentIfNeeded(lod); + // TODO keep track of whether this succeeded so we can leave the vertex order alone in the future ReconstructVertexOrder(lod); + // make the data much easier to process + MergeVertexLists(lod); } // TODO generate tangents, normals if not present return mesh; } - private static bool ReconstructVertexOrder(IntermediateLOD lod) + private static void MergeVertexLists(IntermediateLOD lod) + { + // make sure we have a single vertex list for all sections and the triangle indices are remapped to account for this + if (lod.Sections.Count == 1) + { + // nothing to do, there is only one vertex list + return; + } + + if (lod.Sections.Count > 1) + { + var firstSectionVerts = lod.Sections[0].Vertices; + if (lod.Sections.Skip(1).All(x => x.Vertices == firstSectionVerts)) + { + // they already have shared vertices + return; + } + } + + List vertList = []; + foreach (var section in lod.Sections) + { + if (vertList.Any()) + { + section.Triangles = [..section.Triangles.Select(x => new IntermediateTriangle() + { + VertIndex1 = x.VertIndex1 + vertList.Count, + VertIndex2 = x.VertIndex2 + vertList.Count, + VertIndex3 = x.VertIndex3 + vertList.Count + })]; + } + vertList.AddRange(section.Vertices); + section.Vertices = vertList; + } + } + + private static void CalculateNormalsIfNeeded(IntermediateLOD lod) + { + // if we do not already have normals + if (lod.Sections[0].Vertices[0].Normal == null) + { + // TODO is this worth doing? without some welding, it is going to have seams all over the place. I could just enforce that you include them in the export, even if they are + // just autogenerated by Blender. It will do a better job and can take into account merged vertices that will get duplicated by the glTF export. + throw new NotImplementedException("You need to export your model with normals for now. I have not implemented generating them"); + } + // If the normals are not present already, calculate them here by averaging the normals of the faces containing each vertex, weighted by the angle containing that vertex, so as not to introduce artifacts due to triangulation + //if (psk.VertexNormals == null || psk.VertexNormals.Count == 0) + //{ + // // things we need per triangle: + // // normal vector + // // point index/angle pairs + // float GetAngle(Vector3 p0, Vector3 p1, Vector3 p2) + // { + // var dot = Vector3.Dot(p1 - p0, p2 - p0); + // var m1 = Vector3.Distance(p0, p1); + // var m2 = Vector3.Distance(p0, p2); + // var temp = dot / (m1 * m2); + // return (float)Math.Acos(temp); + // } + + // // need to calculate the normal per face + // // need to group faces by point index, but with dupes + // var summedNormals = new Vector3[psk.Points.Count]; + // foreach (var face in psk.Faces) + // { + // // point index of each vertex of the triangle + // var i0 = psk.Wedges[face.WedgeIdx0].PointIndex; + // var i1 = psk.Wedges[face.WedgeIdx1].PointIndex; + // var i2 = psk.Wedges[face.WedgeIdx2].PointIndex; + // // position of each vertex of the triangle + // var p0 = psk.Points[i0]; + // var p1 = psk.Points[i1]; + // var p2 = psk.Points[i2]; + + // // angle (in rad) of each angle of the triangle by point it contains + // var a0 = GetAngle(p0, p1, p2); + // var a1 = GetAngle(p1, p0, p2); + // var a2 = GetAngle(p2, p1, p0); + + // var faceNormal = Vector3.Normalize(Vector3.Cross(p2 - p0, p1 - p0)); + + // // accumulate the face normals for each point, weighted by the angle + // summedNormals[i0] += faceNormal * a0; + // summedNormals[i1] += faceNormal * a1; + // summedNormals[i2] += faceNormal * a2; + // } + // psk.VertexNormals = [.. summedNormals.Select(x => Vector3.Normalize(x))]; + //} + } + + private static void CalculateTangentIfNeeded(IntermediateLOD lod) + { + if (lod.Sections[0].Vertices[0].Tangent == null) + { + throw new NotImplementedException("You need to export your model with tangents for now. I have not implemented generating them"); + } + } + + private static List GetAllVertices(IntermediateLOD lod) { - // make sure this uses shared vertices. This is a requirement to reconstruct vertex order - // having only one section also satisfies this var vertices = lod.Sections[0].Vertices; if (lod.Sections.Count > 1) { - if (!lod.Sections.All(x => x.Vertices == vertices)) + if (!lod.Sections.Skip(1).All(x => x.Vertices == vertices)) { - // they do not have shared verts, so we can't reconstruct the original order - return false; + // they do not have shared accessors, so we need to concatenate the verts from each section + vertices = [.. lod.Sections.SelectMany(x => x.Vertices)]; } } + return vertices; + } + + private static bool ReconstructVertexOrder(IntermediateLOD lod) + { + var vertices = GetAllVertices(lod); + // now that we know there is only one vert array, check to see if the indices got messed up during editing // group the vertices by original index var groupedVerts = vertices.GroupBy(x => x.OriginalIndex); // if there are any duplicates, we cannot reconstitute the the original order // this will happen primarily due to topology edits, especially merging vertices // this will also fail if they did not export the original indices from blender, as they will all be 0, which is fine + List dedupedVerts; if (groupedVerts.Any(x => x.Count() != 1)) { - // I'm not sure why it is splitting them. The normals appear to be ever so slightly different maybe? - // I bet I could dedpue them based on slight normal differences - // debug - var eval = groupedVerts.Where(x => x.Count() != 1).Select(x => x.ToArray()).ToList(); - return false; + // trying to dedupe + // this is all the ones that already weren't duplicated + dedupedVerts = [.. groupedVerts.Where(x => x.Count() == 1).Select(x => x.Single())]; + + var dupes = groupedVerts.Where(x => x.Count() != 1).Select(x => x.ToArray()).ToList(); + foreach (var dupe in dupes) + { + if (DedupeVertices(dupe, out var newVert)) + { + dedupedVerts.Add(newVert); + } + else + { + // we cannot dedupe them + return false; + } + } + } + else + { + dedupedVerts = [.. vertices]; } // put them in their original order - var verticesOriginalOrder = vertices.OrderBy(x => x.OriginalIndex).ToList(); + var verticesOriginalOrder = dedupedVerts.OrderBy(x => x.OriginalIndex).ToList(); // make sure we actually have 0-n, not skipping any for (int i = 0; i < verticesOriginalOrder.Count; i++) { @@ -771,25 +883,118 @@ private static bool ReconstructVertexOrder(IntermediateLOD lod) // assume at this point it is fine. replace the verts on each section, replace the triangles with new ones that point to the original indices foreach (var section in lod.Sections) { + var originalSectionVerts = section.Vertices; section.Vertices = verticesOriginalOrder; section.Triangles = [.. section.Triangles.Select(x => new IntermediateTriangle() { - VertIndex1 = vertices[x.VertIndex1].OriginalIndex, - VertIndex2 = vertices[x.VertIndex2].OriginalIndex, - VertIndex3 = vertices[x.VertIndex3].OriginalIndex + VertIndex1 = originalSectionVerts[x.VertIndex1].OriginalIndex, + VertIndex2 = originalSectionVerts[x.VertIndex2].OriginalIndex, + VertIndex3 = originalSectionVerts[x.VertIndex3].OriginalIndex })]; } // at this point, we have restored the vertex order to the original, and the triangle order should also be the same as it is in Blender, which will match exported if you haven't messed with things + return true; + } + + private static bool DedupeVertices(IEnumerable vertices, out IntermediateVertex dedupe) + { + var numVerts = vertices.Count(); + if (numVerts == 0) + { + dedupe = default; + return false; + } + else if (numVerts == 1) + { + dedupe = vertices.Single(); + return true; + } + var first = vertices.First(); + dedupe = default; + foreach (var vert in vertices.Skip(1)) + { + if (!(ApproximatelyEquals(first.Position, vert.Position) + && ApproximatelyEquals(first.Normal, vert.Normal) + && ApproximatelyEquals(first.Tangent, vert.Tangent) + && (int)first.BiTangentDirection == (int)vert.BiTangentDirection + && ApproximatelyEquals(first.UVs, vert.UVs) + && ApproximatelyEquals(first.Influences, vert.Influences))) + { + return false; + } + } + // TODO average stuff? it shouldn't matter since they are all very very close by definition + dedupe = first; + return true; + } + + private static bool ApproximatelyEquals(Vector3? first, Vector3? second) + { + // both are null + if (!first.HasValue && !second.HasValue) + { + return true; + } + // only one is null + if (!first.HasValue || !second.HasValue) + { + return false; + } + // TODO check this epsilon + return Vector3.DistanceSquared(first.Value, second.Value) < 0.00001; + } + + private static bool ApproximatelyEquals(List first, List second) + { + if (first.Count != second.Count) + { + return false; + } + for (int i = 0; i < first.Count; i++) + { + if (!ApproximatelyEquals(first[i], second[i])) + { + return false; + } + } + return true; + } + private static bool ApproximatelyEquals(Vector2 first, Vector2 second) + { + // TODO check this epsilon + return Vector2.DistanceSquared(first, second) < 0.00001; + } + + private static bool ApproximatelyEquals(List<(int influenceBone, float weight)> first, List<(int influenceBone, float weight)> second) + { + if (first.Count != second.Count) + { + return false; + } + for (int i = 0; i < first.Count; i++) + { + if (first[i].influenceBone != second[i].influenceBone + || !ApproximatelyEquals(first[i].weight, second[i].weight)) + { + return false; + } + } return true; } + private static bool ApproximatelyEquals(float first, float second) + { + // TODO check this epsilon + return first - second < 0.00001; + } + private static void CollectMeshes(ModelRoot modelRoot, out List<(string, Node[])> skeletalMeshes, out List<(string, Node[])> staticMeshes) { var meshes = modelRoot.LogicalNodes.Where(node => node.Mesh != null).GroupBy(node => node.VisualParent); // TODO make sure they all have the same skin? - skeletalMeshes = [..meshes.Where(x => x.All(node => node.Skin != null)).Select(group => (group.Key.Name, group.ToArray()))]; + skeletalMeshes = [.. meshes.Where(x => x.All(node => node.Skin != null)).Select(group => (group.Key.Name, group.ToArray()))]; // TODO what if they are at the root? What if there is more than one at the root? staticMeshes = [.. meshes.Where(x => x.All(node => node.Skin == null)).Select(group => (group.Key.Name, group.ToArray()))]; } @@ -835,7 +1040,6 @@ private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes } // TODO sort this into groups of LODs vs collision components var skeletalMesh = nodes[0].Skin != null; - int vertIndex = 0; int lodIndex = 0; int boneIndex = 0; // maps from material index within the gltf file to materials within this mesh (in the array order) @@ -844,7 +1048,10 @@ private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes // maps from bone order within the gltf file to bone order within this mesh List boneMap = []; - var intermediateMesh = new IntermediateMesh(); + var intermediateMesh = new IntermediateMesh() + { + Name = meshName ?? "gltfMesh" + }; // skeleton if (skeletalMesh) @@ -907,7 +1114,7 @@ Node FindJointParent(Node node) Node[] FindSockets(Node joint) { // look for direct children of a bone which are not themselves bones - return [..joint.VisualChildren.Where(x => !x.IsSkinJoint)]; + return [.. joint.VisualChildren.Where(x => !x.IsSkinJoint)]; } foreach (var joint in nodes[0].Skin.Joints) { @@ -955,7 +1162,6 @@ List GetPrimativeVertices(MeshPrimitive prim) { var vert = new IntermediateVertex { - Index = vertIndex++, Position = TransformVertexPositionFromGltf(vertColumns.Positions[i]), }; @@ -1057,14 +1263,11 @@ void AddWeights(IList jointsList, IList weightsList) foreach (var (v1, v2, v3) in triIndices) { - // I think the vertex order is correct but need to check var tri = new IntermediateTriangle() { - //Index = triangleIndex++, - //MaterialIndex = meshMatIndex, - VertIndex1 = v2, - VertIndex2 = v3, - VertIndex3 = v1, + VertIndex1 = v1, + VertIndex2 = v2, + VertIndex3 = v3, }; meshSection.Triangles.Add(tri); } @@ -1079,10 +1282,11 @@ void AddWeights(IList jointsList, IList weightsList) private static Vector3 TransformSocketLocationFromGltf(Vector3 input) { - return input * ScaleFactor; + return new Vector3(input.X, -input.Y, input.Z) * ScaleFactor; } private static Quaternion TransformSocketRotationFromGltf(Quaternion input) { + // TODO this still needs to be done and I need to implement actually importing this return input; } private static Vector3 TransformScaleFromGltf(Vector3 input) @@ -1091,56 +1295,58 @@ private static Vector3 TransformScaleFromGltf(Vector3 input) } private static Vector3 TranformDirectionFromGltf(Vector3 input) { - return input; + return Vector3.Normalize(new Vector3(input.X, input.Z, input.Y)); } private static Vector3 TransformBonePositionFromGltf(Vector3 input) { - //return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; - return input * ScaleFactor; + return new Vector3(input.X, -input.Y, input.Z) * ScaleFactor; } private static Vector3 TransformVertexPositionFromGltf(Vector3 input) { - //return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; - return input * ScaleFactor; + return new Vector3(input.X, input.Z, input.Y) * ScaleFactor; } private static Quaternion TransformRootBoneRotationFromGltf(Quaternion input) { - //// add a -90 degree rotation around the x axis - //var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); - //return Quaternion.Normalize(transform * input); - return input; + // add a 90 degree rotation around the x axis + var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); + return Quaternion.Normalize(transform * input); } private static Vector3 TransformRootBonePositionFromGltf(Vector3 input) { - //return new Vector3(input.X, input.Z, input.Y) / ScaleFactor; - return input * ScaleFactor; + return new Vector3(input.X, -input.Z, -input.Y) * ScaleFactor; } private static Quaternion TransformBoneRotationFromGltf(Quaternion input) { - //// first, get it into the form glTF expects due to the swapped axes - //var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); - //// next, we undo the rotation introduced by the parent - //temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; - //// finally, we rotate the child in its local axes - //temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); - //return Quaternion.Normalize(temp); - return input; + var temp = input * new Quaternion(QuatHalf, 0, 0, QuatHalf); + temp = new Quaternion(QuatHalf, 0, 0, -QuatHalf) * temp; + temp = new Quaternion(temp.X, temp.Z, temp.Y, -temp.W); + + return Quaternion.Normalize(temp); } - private static void ImportSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package) + private static ExportEntry ToSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package, ExportEntry existingEntry) { var meshBin = SkeletalMesh.Create(); SetupSkeleton(intermediateMesh.Skeleton, meshBin); SetupBounds(intermediateMesh, meshBin); SetupMaterials(intermediateMesh.Materials, meshBin, package); - foreach (var lod in intermediateMesh.LODs) - { - SetupLOD(intermediateMesh, lod, meshBin); - } + meshBin.LODModels = [.. intermediateMesh.LODs.Select(lod => SetupLOD(intermediateMesh, lod, meshBin))]; + + // we now have the complete binary; get or create the export and write out properties + var export = existingEntry ?? ExportCreator.CreateExport(package, intermediateMesh.Name, "SkeletalMesh"); + + export.WriteBinary(meshBin); + + var lodInfo = GetLodInfoProp(intermediateMesh, package); + + export.WriteProperty(lodInfo); + WriteSockets(intermediateMesh, export); + + return export; static void SetupSkeleton(IList skeleton, SkeletalMesh meshBin) { @@ -1179,8 +1385,8 @@ int GetDepth(int i) BoneColor = new LegendaryExplorerCore.SharpDX.Color(new Vector4(1, 1, 1, 1)), Flags = 0, ParentIndex = currentBone.ParentIndex, - Position = new Vector3(currentBone.Position.X, currentBone.Position.Y * -1, currentBone.Position.Z), - Orientation = new Quaternion(currentBone.Rotation.X, currentBone.Rotation.Y * -1, currentBone.Rotation.Z, currentBone.Rotation.W) + Position = currentBone.Position, + Orientation = currentBone.Rotation }; // make sure we calculate the depth @@ -1193,28 +1399,29 @@ int GetDepth(int i) static void SetupBounds(IntermediateMesh intermediateMesh, SkeletalMesh meshBin) { - //// bounds are important at least for the camera display preview in LEX, and possibly important for when to cull meshes based on visibility in game - //// separate out the coordinates for each axis so we can operate on them - //var xCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.X); - //var yCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.Y); - //var zCoords = intermediateMesh.LODs[0].Vertices.Select(x => x.Position.Z); + // bounds are important at least for the camera display preview in LEX, and possibly important for when to cull meshes based on visibility in game + // separate out the coordinates for each axis so we can operate on them + var vertices = GetAllVertices(intermediateMesh.LODs[0]); + var xCoords = vertices.Select(x => x.Position.X); + var yCoords = vertices.Select(x => x.Position.Y); + var zCoords = vertices.Select(x => x.Position.Z); - //// get the origin by averaging all vertex positions; it'll probably be close enough - //var origin = new Vector3(xCoords.Average(), yCoords.Average(), zCoords.Average()); + // get the origin by averaging all vertex positions; it'll probably be close enough + var origin = new Vector3(xCoords.Average(), yCoords.Average(), zCoords.Average()); - //var xRange = xCoords.Select(coord => Math.Abs(coord - origin.X)).Max(); - //var yRange = yCoords.Select(coord => Math.Abs(coord - origin.Y)).Max(); - //var zRange = zCoords.Select(coord => Math.Abs(coord - origin.Z)).Max(); - //var boxExtent = new Vector3(xRange, yRange, zRange); + var xRange = xCoords.Select(coord => Math.Abs(coord - origin.X)).Max(); + var yRange = yCoords.Select(coord => Math.Abs(coord - origin.Y)).Max(); + var zRange = zCoords.Select(coord => Math.Abs(coord - origin.Z)).Max(); + var boxExtent = new Vector3(xRange, yRange, zRange); - //var sphereRad = boxExtent.Length(); - //meshBin.Bounds = new BoxSphereBounds - //{ - // Origin = origin, - // // best guess at a reasonable margin - // BoxExtent = boxExtent * 2, - // SphereRadius = sphereRad * 2 - //}; + var sphereRad = boxExtent.Length(); + meshBin.Bounds = new BoxSphereBounds + { + Origin = origin, + // best guess at a reasonable margin + BoxExtent = boxExtent * 2, + SphereRadius = sphereRad * 2 + }; } static void SetupMaterials(IList materials, SkeletalMesh meshBin, IMEPackage package) @@ -1226,6 +1433,7 @@ static void SetupMaterials(IList materials, SkeletalMesh m { continue; } + // TODO fall back to looking for just the end of the path for compatibility with stuff exported as psk previously var entry = FindEntryByMemeroryFullPath(package, materials[i].Name, "MaterialInterface"); if (entry != null) { @@ -1234,89 +1442,307 @@ static void SetupMaterials(IList materials, SkeletalMesh m } } - static void SetupLOD(IntermediateMesh intermediateMesh, IntermediateLOD lod, SkeletalMesh meshBin) - { - // // TODO implement normal generation, maybe even with welding, angle threshold? - // if (lod.Vertices[0].Normal == null) - // { - // throw new NotImplementedException("I haven't implemented normal generation yet. export your glTF with normals."); - // } - // // TODO implement normal generation, maybe even with welding, angle threshold? - // if (lod.Vertices[0].Tangent == null) - // { - // throw new NotImplementedException("I haven't implemented tangent generation yet. export your glTF with tangents."); - // } - // SetupSectionsAndChunks(); - - // void SetupSectionsAndChunks() - // { - // if (intermediateMesh.Materials.Count == 1) - // { - - // } - // else - // { - // // TODO make this optional? - // // this is useful for draw order stuff, but not the only way to do it, and it might be nice to preserve ordering too - // if (true) - // { - // //lod.Triangles = [.. lod.Triangles.OrderBy(x => x.MaterialIndex)]; - // } - - // List> matGroups = []; - // //var currentMat = lod.Triangles[0].MaterialIndex; - // var currentGroup = new List(); - // foreach (var triangle in lod.Triangles) - // { - // if (triangle.MaterialIndex == currentMat) - // { - // currentGroup.Add(triangle); - // } - // else - // { - // currentMat = triangle.MaterialIndex; - // matGroups.Add(currentGroup); - // currentGroup = [triangle]; - // } - // } - - // List sections = []; - // var startIndex = 0; - // foreach (var matGroup in matGroups) - // { - // var mat = matGroup[0].MaterialIndex; - // var section = new MeshSection - // { - // Triangles = [.. matGroup], - // BaseTriIndex = startIndex, - // MatIndex = mat, - // }; - - // // calculate the min and max vertex indices within this section - // var sectionIndices = matGroup.SelectMany(x => [vertsInWedgeOrder[x.WedgeIdx0].Index, vertsInWedgeOrder[x.WedgeIdx1].Index, vertsInWedgeOrder[x.WedgeIdx2].Index]); - // section.MinVertIndex = sectionIndices.Min(); - // section.MaxVertIndex = sectionIndices.Max(); - - // sections.Add(section); - // startIndex += matGroup.Count(); - // } - // } - - // var LOD = new StaticLODModel - // { - // IndexBuffer = [.. lod.Triangles.SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3])], - // // TODO filter this down to bones that actually have any weighting? - // RequiredBones = [.. Enumerable.Range(0, intermediateMesh.Skeleton.Count).Select(x => (byte)x)] - // }; - - - // } - } - } - - private struct MeshSection - { - public IntermediateTriangle[] Triangles; + static StaticLODModel SetupLOD(IntermediateMesh intermediateMesh, IntermediateLOD lod, SkeletalMesh meshBin) + { + var intermediateVerts = GetAllVertices(lod); + + var LOD = new StaticLODModel + { + // TODO filter this down to bones that actually have any weighting? + RequiredBones = [.. Enumerable.Range(0, intermediateMesh.Skeleton.Count).Select(x => (byte)x)], + ActiveBoneIndices = [.. Enumerable.Range(0, intermediateMesh.Skeleton.Count).Select(x => (ushort)x)], + NumVertices = (uint)intermediateVerts.Count, + VertexBufferGPUSkin = new SkeletalMeshVertexBuffer + { + VertexData = new GPUSkinVertex[intermediateVerts.Count], + MeshExtension = new Vector3(1, 1, 1) + } + }; + + SetupSectionsAndChunks(); + + SetupVerts(); + + void SetupSectionsAndChunks() + { + LOD.IndexBuffer = [.. lod.Sections.SelectMany(x => x.Triangles).SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3])]; + //TODO if there is only one section, it is trivial to set up + + // just make the sections 1:1 with the sections in the intermediate mesh + var cumulativeTriangleCount = 0; + List skelMeshSections = []; + foreach (var intermediateSection in lod.Sections) + { + var vertIndices = intermediateSection.Triangles.SelectMany(x => [x.VertIndex1, x.VertIndex2, x.VertIndex3]); + var section = new TempSkelMeshSection() + { + TriCount = intermediateSection.Triangles.Count, + BaseTriIndex = cumulativeTriangleCount, + MatIndex = intermediateSection.MaterialIndex, + MaxVertIndex = vertIndices.Max(), + MinVertIndex = vertIndices.Min(), + ChunkIndex = -1 + }; + cumulativeTriangleCount += intermediateSection.Triangles.Count; + skelMeshSections.Add(section); + } + // TODO if there are consecutive sections with the same material, combine them + + var orderedSection = skelMeshSections.OrderBy(x => x.MinVertIndex).ThenBy(x => x.MaxVertIndex); + List chunks = []; + chunks.Add(new TempSkelMeshChunk + { + VertIndexStart = 0, + VertIndexEnd = orderedSection.First().MaxVertIndex, + InfluenceBones = [] + }); + foreach (var section in orderedSection) + { + if (section.MinVertIndex > chunks[^1].VertIndexEnd) + { + // sections have non overlapping vertices; make a new chunk + chunks.Add(new TempSkelMeshChunk + { + VertIndexStart = section.MinVertIndex, + VertIndexEnd = section.MaxVertIndex, + InfluenceBones = [] + }); + } + else + { + // sections have overlapping vertices and we need to combine the chunks + chunks[^1].VertIndexEnd = Math.Max(section.MaxVertIndex, chunks[^1].VertIndexEnd); + } + } + + // now, assign a chunk index to each section + for (var i = 0; i < skelMeshSections.Count; i++) + { + skelMeshSections[i].ChunkIndex = chunks.FindIndex(x => x.VertIndexStart <= skelMeshSections[i].MinVertIndex && x.VertIndexEnd >= skelMeshSections[i].MaxVertIndex); + } + + var verts = GetAllVertices(lod); + // next, we need to see which bones influence each chunk + // as well as count the rigid and soft vertices (not positive if that matters in game or not, but I am trying to emulate vanilla as closely as possible) + foreach (var chunk in chunks) + { + for (var i = chunk.VertIndexStart; i <= chunk.VertIndexEnd; i++) + { + var weights = verts[i].Influences; + switch (weights.Count) + { + case <= 1: + chunk.RigidVerts++; + break; + default: + chunk.SoftVerts++; + break; + } + if (weights.Count > chunk.maxBoneInfluences) + { + chunk.maxBoneInfluences = weights.Count; + } + // TODO limit this to the 4 influences highest influences? + foreach (var weight in weights) + { + chunk.InfluenceBones.Add((ushort)weight.influenceBone); + } + } + // the indices into the bone mapping array are bytes, so we can't have too many here without splitting the chunk up, which I have not implemented because it is extraorinarily unlikely to come up in real world usage + if (chunk.InfluenceBones.Count > 255) + { + throw new Exception("there are too many influence bones in this chunk; Send the file to Squid and tell him to implement chunk splitting logic."); + } + } + + LOD.Sections = [..skelMeshSections.Select(x => new SkelMeshSection + { + BaseIndex = (uint)(x.BaseTriIndex * 3), + ChunkIndex = (ushort)x.ChunkIndex, + MaterialIndex = (ushort)x.MatIndex, + NumTriangles = x.TriCount + })]; + + LOD.Chunks = [..chunks.Select(x => new SkelMeshChunk + { + BaseVertexIndex = (uint)x.VertIndexStart, + MaxBoneInfluences = Math.Min(x.maxBoneInfluences, 4), + NumRigidVertices = x.RigidVerts, + NumSoftVertices = x.SoftVerts, + BoneMap = [.. x.InfluenceBones.Order()] + })]; + } + void SetupVerts() + { + for (int chunkIndex = 0; chunkIndex < LOD.Chunks.Length; chunkIndex++) + { + var chunk = LOD.Chunks[chunkIndex]; + var nextChunkStart = chunkIndex != LOD.Chunks.Length - 1 ? (int)LOD.Chunks[chunkIndex + 1].BaseVertexIndex : intermediateVerts.Count; + for (int i = (int)chunk.BaseVertexIndex; i < nextChunkStart; i++) + { + var tempVert = intermediateVerts[i]; + var newVert = new GPUSkinVertex + { + UV = new Vector2DHalf(tempVert.UVs[0].X, tempVert.UVs[0].Y), + Position = tempVert.Position + }; + + var packedNorm = (PackedNormal)Vector3.Normalize(tempVert.Normal.Value); + // the w component of the normal stores the bitangent sign, indicating whether the UV mapping is mirorred here + var normalW = tempVert.BiTangentDirection > 0 ? (byte)255 : (byte)0; + newVert.TangentZ = new PackedNormal(packedNorm.X, packedNorm.Y, packedNorm.Z, normalW); + + newVert.TangentX = (PackedNormal)Vector3.Normalize(tempVert.Tangent.Value); + + // add in the bone influences + byte GetMappedBoneIndex(int index) + { + return (byte)chunk.BoneMap.IndexOf((ushort)index); + } + + (newVert.InfluenceBones, newVert.InfluenceWeights) = DistributeWeights(tempVert.Influences.Select(x => (GetMappedBoneIndex(x.influenceBone), x.weight))); + + LOD.VertexBufferGPUSkin.VertexData[i] = newVert; + } + } + } + return LOD; + } + + static ArrayProperty GetLodInfoProp(IntermediateMesh intermediateMesh, IMEPackage package) + { + ArrayProperty lodInfoProp = new("LODInfo"); + + for (int i = 0; i < intermediateMesh.LODs.Count; i++) + { + var currentLod = intermediateMesh.LODs[i]; + var displayFactorProp = new FloatProperty(displayFactors[Math.Min(i, displayFactors.Length - 1)], "DisplayFactor"); + var bEnableShadowCastingProp = new ArrayProperty(Enumerable.Repeat(new BoolProperty(true), intermediateMesh.Materials.Count), "bEnableShadowCasting"); + var TriangleSortingProp = new ArrayProperty(Enumerable.Repeat(new EnumProperty("TRISORT_None", "TriangleSortOption", package.Game), intermediateMesh.Materials.Count), "TriangleSorting"); + + var LODMaterialMapProp = new ArrayProperty(Enumerable.Range(0, intermediateMesh.Materials.Count).Select(x => new IntProperty(x)), "LODMaterialMap"); + + var lodInfo = new StructProperty("SkeletalMeshLODInfo", false, + displayFactorProp, + new FloatProperty(0.2f, "LODHysteresis"), + LODMaterialMapProp, + bEnableShadowCastingProp, + TriangleSortingProp); + lodInfoProp.Add(lodInfo); + } + return lodInfoProp; + } + + static void WriteSockets(IntermediateMesh mesh, ExportEntry export) + { + var oldSocketsProp = export.GetProperty>("Sockets"); + if (oldSocketsProp != null) + { + EntryPruner.TrashEntries(export.FileRef, oldSocketsProp.Select(x => x.ResolveToEntry(export.FileRef)).Where(x => x != null)); + export.RemoveProperty("Sockets"); + } + if (!mesh.Sockets.Any()) + { + return; + } + var meshSocketProp = new ArrayProperty("Sockets"); + foreach (var socket in mesh.Sockets) + { + var socketObj = ExportCreator.CreateExport(export.FileRef, "SkeletalMeshSocket", "SkeletalMeshSocket", export); + var socketProperties = new PropertyCollection() + { + new StructProperty("Vector", true, + new FloatProperty(socket.RelativeLocation.X, "X"), + new FloatProperty(socket.RelativeLocation.Y, "Y"), + new FloatProperty(socket.RelativeLocation.Z, "Z") + ) { Name = "RelativeLocation" }, + // TODO these names are wrong + //new StructProperty("Rotator", true, + // new FloatProperty(socket.RelativeRotation.X, "X"), + // new FloatProperty(socket.RelativeRotation.Y, "Y"), + // new FloatProperty(socket.RelativeRotation.Z, "Z") + //) { Name = "RelativeRotation" }, + // TODO support relative scale? + new NameProperty(socket.Name, "SocketName"), + new NameProperty(socket.Bone, "BoneName") + }; + if (socket.RelativeScale != Vector3.One) + { + socketProperties.Add(new StructProperty("Vector", true, + new FloatProperty(socket.RelativeScale.X, "X"), + new FloatProperty(socket.RelativeScale.Y, "Y"), + new FloatProperty(socket.RelativeScale.Z, "Z") + ) + { Name = "RelativeScale" }); + } + socketObj.WriteProperties(socketProperties); + meshSocketProp.Add(new ObjectProperty(socketObj)); + } + export.WriteProperty(meshSocketProp); + } + } + + static (Influences bones, Influences influences) DistributeWeights(IEnumerable<(byte bone, float weight)> weights) + { + const byte totalInfluence = 255; + // we have some number of bone weights as floats + // we need to convert to 4 or fewer byte weights adding to exactly 255 + + // sort by influence descending + // drop any after the first 4 + var contributingWeights = weights.OrderByDescending(x => x.weight).Take(MaxBoneInfluences).ToArray(); + var sum = contributingWeights.Select(x => x.weight).Sum(); + // normalize remaining to sum to 255 (float) + var floatWeights = contributingWeights.Select(x => (x.bone, floatWeight: x.weight * totalInfluence / sum)).ToArray(); + // start with an empty array of exactly 4 full of byte zeros + var byteWeights = new byte[MaxBoneInfluences]; + var boneIndices = new byte[MaxBoneInfluences]; + // fill in the integer portions of each one + byte remaining = totalInfluence; + for (int i = 0; i < floatWeights.Length; i++) + { + // copy the bone index + boneIndices[i] = floatWeights[i].bone; + // copy the integer portion of the float weight + byteWeights[i] = (byte)floatWeights[i].floatWeight; + // save the remainder of each weight + floatWeights[i].floatWeight -= byteWeights[i]; + // change this to the index within the array; we will need it later + floatWeights[i].bone = (byte)i; + // keep track of the remaining amount to be distributed + remaining -= byteWeights[i]; + } + + // apportion any remaining by greatest remaining non integer portion + if (remaining > 0) + { + foreach (var (bone, floatWeight) in floatWeights.OrderByDescending(x => x.floatWeight)) + { + if (remaining > 0) + { + byteWeights[bone] += 1; + remaining--; + } + } + } + + // if any of the influences fell to 0 in this process, clean up the bone index + for (int i = 0; i < MaxBoneInfluences; i++) + { + if (byteWeights[i] == 0) + { + boneIndices[i] = 0; + } + } + + return ( + new Influences(boneIndices[0], boneIndices[1], boneIndices[2], boneIndices[3]), + new Influences(byteWeights[0], byteWeights[1], byteWeights[2], byteWeights[3])); + } + + private class TempSkelMeshSection + { + public int TriCount; public int BaseTriIndex; public int ChunkIndex; public int MatIndex; @@ -1324,6 +1750,16 @@ private struct MeshSection public int MaxVertIndex; } + private class TempSkelMeshChunk + { + public int VertIndexStart; + public int VertIndexEnd; + public HashSet InfluenceBones; + public int RigidVerts; + public int SoftVerts; + public int maxBoneInfluences; + } + private static void SetNumMaterialSlots(SkeletalMesh meshBinary, int numMaterials) { if (meshBinary.Materials.Length == numMaterials) @@ -1339,22 +1775,6 @@ private static void SetNumMaterialSlots(SkeletalMesh meshBinary, int numMaterial meshBinary.Materials[i] = tempMaterials[i]; } } - - //private static Vector3 Yup2Zup(Vector3 input) - //{ - // return new Vector3(input.X, input.Z, input.Y); - //} - - //private static Quaternion Yup2Zup(Quaternion input) - //{ - // var transformQuat = new Quaternion(MathF.Sqrt(2f) / 2f, 0, 0, MathF.Sqrt(2f) / 2f); - // return Quaternion.Normalize(input * transformQuat); - //} - - //private static Vector3 ScaleForME(Vector3 input) - //{ - // return input * ScaleFactor; - //} #endregion #region intermediate @@ -1426,14 +1846,11 @@ public IntermediateLOD() private struct IntermediateVertex { - public int Index; // always required public Vector3 Position; // can be imported or calculated if need be public Vector3? Normal; - // will be calculated public Vector3? Tangent; - // will be calculated public float BiTangentDirection; // will usually be present. Expect length 1 for skeletal meshes, but static meshes can have multiple public List UVs = []; From 5265c09e92d87e30ab715671fd11eb2b43f57537 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 30 Jan 2026 10:06:14 -0500 Subject: [PATCH 24/34] cleaned up getting sockets into the glTF a bit, tested that everything is working correctly. --- .../PackageEditor/Experiments/SquidGltf.cs | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 5793bf053..4fbee55b8 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -271,24 +271,10 @@ private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh, MaterialEx var rotationProp = socketObject.GetProperty("RelativeRotation"); if (rotationProp != null) { - static Quaternion FromYawPitchRoll(int yaw, int pitch, int roll) - { - var rot = Quaternion.Identity; - var yawRad = (yaw % 65536) / 65536f * Math.PI * 2; - var pitchRad = (pitch % 65536) / 65536f * Math.PI * 2; - var rollRad = (roll % 65536) / 65536f * Math.PI * 2; - // apply yaw - rot = rot * new Quaternion(0, (float)Math.Sin(yawRad / 2), 0, -(float)Math.Cos(yawRad / 2)); - // apply pitch - rot = rot * new Quaternion(0, 0, (float)Math.Sin(pitchRad / 2), (float)Math.Cos(pitchRad / 2)); - // apply roll - rot = rot * new Quaternion((float)Math.Sin(rollRad / 2), 0, 0, (float)Math.Cos(rollRad / 2)); - return Quaternion.Normalize(rot); - } intermediateSocket.RelativeRotation = FromYawPitchRoll( - rotationProp.GetProp("Yaw").Value, - rotationProp.GetProp("Pitch").Value, - rotationProp.GetProp("Roll").Value); + rotationProp.GetProp("Yaw").Value.UnrealRotationUnitsToRadians(), + rotationProp.GetProp("Pitch").Value.UnrealRotationUnitsToRadians(), + rotationProp.GetProp("Roll").Value.UnrealRotationUnitsToRadians()); } var scaleProp = socketObject.GetProperty("RelativeScale"); if (scaleProp != null) @@ -654,9 +640,11 @@ private static Vector3 TransformScaleToGltf(Vector3 input) private static Quaternion TransformSocketRotationToGltf(Quaternion input) { - // add a 90 degree rotation around the x axis - var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); - return Quaternion.Normalize(transform * input); + // fix coordinate system differences + var temp = new Quaternion(input.X, -input.Z, input.Y, input.W); + // add a 90 degree rotation + temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + return Quaternion.Normalize(temp); } private static Quaternion TransformBoneRotationToGltf(Quaternion input) @@ -669,6 +657,21 @@ private static Quaternion TransformBoneRotationToGltf(Quaternion input) temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); return Quaternion.Normalize(temp); } + + static Quaternion FromYawPitchRoll(float yaw, float pitch, float roll) + { + var rot = Quaternion.Identity; + + // Apply Yaw (Z-axis) + rot = rot * new Quaternion(0f, 0f, (float)Math.Sin(yaw / 2), (float)Math.Cos(yaw / 2)); + // Apply Pitch (Y-axis) + rot = rot * new Quaternion(0f, (float)Math.Sin(pitch / 2), 0f, (float)Math.Cos(pitch / 2)); + // Apply Roll (X-axis) + rot = rot * new Quaternion((float)Math.Sin(roll / 2), 0f, 0f, (float)Math.Cos(roll / 2)); + + return Quaternion.Normalize(rot); + } + #endregion #region import From 257735353eec50e9313824d711b2fec7cfe5de7f Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 30 Jan 2026 10:47:43 -0500 Subject: [PATCH 25/34] got all bone and socket import stuff working correctly I think. --- .../PackageEditor/Experiments/SquidGltf.cs | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 4fbee55b8..3130b3f34 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -1289,8 +1289,8 @@ private static Vector3 TransformSocketLocationFromGltf(Vector3 input) } private static Quaternion TransformSocketRotationFromGltf(Quaternion input) { - // TODO this still needs to be done and I need to implement actually importing this - return input; + var temp = new Quaternion(-QuatHalf, 0, 0, QuatHalf) * input; + return new Quaternion(temp.X, temp.Z, -temp.Y, temp.W); } private static Vector3 TransformScaleFromGltf(Vector3 input) { @@ -1319,7 +1319,7 @@ private static Quaternion TransformRootBoneRotationFromGltf(Quaternion input) private static Vector3 TransformRootBonePositionFromGltf(Vector3 input) { - return new Vector3(input.X, -input.Z, -input.Y) * ScaleFactor; + return new Vector3(input.X, input.Z, input.Y) * ScaleFactor; } private static Quaternion TransformBoneRotationFromGltf(Quaternion input) @@ -1651,6 +1651,7 @@ static void WriteSockets(IntermediateMesh mesh, ExportEntry export) var meshSocketProp = new ArrayProperty("Sockets"); foreach (var socket in mesh.Sockets) { + var (yaw, pitch, roll) = ToYawPitchRoll(socket.RelativeRotation); var socketObj = ExportCreator.CreateExport(export.FileRef, "SkeletalMeshSocket", "SkeletalMeshSocket", export); var socketProperties = new PropertyCollection() { @@ -1659,12 +1660,11 @@ static void WriteSockets(IntermediateMesh mesh, ExportEntry export) new FloatProperty(socket.RelativeLocation.Y, "Y"), new FloatProperty(socket.RelativeLocation.Z, "Z") ) { Name = "RelativeLocation" }, - // TODO these names are wrong - //new StructProperty("Rotator", true, - // new FloatProperty(socket.RelativeRotation.X, "X"), - // new FloatProperty(socket.RelativeRotation.Y, "Y"), - // new FloatProperty(socket.RelativeRotation.Z, "Z") - //) { Name = "RelativeRotation" }, + new StructProperty("Rotator", true, + new IntProperty(pitch.RadiansToUnrealRotationUnits(), "Pitch"), + new IntProperty(yaw.RadiansToUnrealRotationUnits(), "Yaw"), + new IntProperty(roll.RadiansToUnrealRotationUnits(), "Roll") + ) { Name = "RelativeRotation" }, // TODO support relative scale? new NameProperty(socket.Name, "SocketName"), new NameProperty(socket.Bone, "BoneName") @@ -1685,6 +1685,24 @@ static void WriteSockets(IntermediateMesh mesh, ExportEntry export) } } + static (float yaw, float pitch, float roll) ToYawPitchRoll(Quaternion q) + { + float x = q.X; + float y = q.Y; + float z = q.Z; + float w = q.W; + + // yaw + float yaw = (float)Math.Atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)); + + // pitch + float pitch = (float)Math.Asin(Math.Clamp(2 * (w * y - z * x), -1f, 1f)); + + // roll + float roll = (float)Math.Atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)); + + return (yaw, pitch, roll); + } static (Influences bones, Influences influences) DistributeWeights(IEnumerable<(byte bone, float weight)> weights) { const byte totalInfluence = 255; From 3c87cfeb195a118d8940fa3cf9b36fe538a52d75 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 30 Jan 2026 12:36:33 -0500 Subject: [PATCH 26/34] dealt with various small TODOs, finished up socket scale. --- .../PackageEditor/Experiments/SquidGltf.cs | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 3130b3f34..2a36ce5d2 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -372,7 +372,7 @@ private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null { var scene = new SceneBuilder(); - // TODO do I need this? + // Make a root node that all of the LODs will be under. This matches Blender's export of skeletal meshes and makes it easier to group them back up var containerNode = new NodeBuilder(mesh.Name); scene.AddNode(containerNode); @@ -634,7 +634,6 @@ private static Vector3 TransformRootBonePositionToGltf(Vector3 input) private static Vector3 TransformScaleToGltf(Vector3 input) { - // TODO check if this is actually the right transform return new Vector3(input.X, input.Z, input.Y); } @@ -713,12 +712,10 @@ private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) // fill in data that was not provided on import CalculateNormalsIfNeeded(lod); CalculateTangentIfNeeded(lod); - // TODO keep track of whether this succeeded so we can leave the vertex order alone in the future ReconstructVertexOrder(lod); // make the data much easier to process MergeVertexLists(lod); } - // TODO generate tangents, normals if not present return mesh; } @@ -944,7 +941,6 @@ private static bool ApproximatelyEquals(Vector3? first, Vector3? second) { return false; } - // TODO check this epsilon return Vector3.DistanceSquared(first.Value, second.Value) < 0.00001; } @@ -966,7 +962,6 @@ private static bool ApproximatelyEquals(List first, List secon private static bool ApproximatelyEquals(Vector2 first, Vector2 second) { - // TODO check this epsilon return Vector2.DistanceSquared(first, second) < 0.00001; } @@ -989,17 +984,14 @@ private static bool ApproximatelyEquals(List<(int influenceBone, float weight)> private static bool ApproximatelyEquals(float first, float second) { - // TODO check this epsilon return first - second < 0.00001; } private static void CollectMeshes(ModelRoot modelRoot, out List<(string, Node[])> skeletalMeshes, out List<(string, Node[])> staticMeshes) { var meshes = modelRoot.LogicalNodes.Where(node => node.Mesh != null).GroupBy(node => node.VisualParent); - // TODO make sure they all have the same skin? - skeletalMeshes = [.. meshes.Where(x => x.All(node => node.Skin != null)).Select(group => (group.Key.Name, group.ToArray()))]; - // TODO what if they are at the root? What if there is more than one at the root? - staticMeshes = [.. meshes.Where(x => x.All(node => node.Skin == null)).Select(group => (group.Key.Name, group.ToArray()))]; + skeletalMeshes = [..meshes.Where(x => x.All(node => node.Skin != null && node.Skin == x.First().Skin)).Select(group => (group.Key.Name, group.ToArray()))]; + staticMeshes = [.. meshes.Where(x => x.All(node => node.Skin == null)).Select(group => (group.Key?.Name ?? group.First().Name, group.ToArray()))]; } private static bool DoesMeshUseSharedVertexAccessors(Mesh mesh) @@ -1108,8 +1100,6 @@ Node FindJointParent(Node node) } if (rootJoints.Count > 1) { - // TODO make a new fake root bone? - // just leave it alone? does ME technically require a single root bone? throw new NotImplementedException("This skeleton doesn't seem to have a single root bone, and I don't know how to handle that yet."); } @@ -1294,7 +1284,7 @@ private static Quaternion TransformSocketRotationFromGltf(Quaternion input) } private static Vector3 TransformScaleFromGltf(Vector3 input) { - return input; + return new Vector3(input.X, input.Z, input.Y); } private static Vector3 TranformDirectionFromGltf(Vector3 input) { @@ -1436,8 +1426,11 @@ static void SetupMaterials(IList materials, SkeletalMesh m { continue; } - // TODO fall back to looking for just the end of the path for compatibility with stuff exported as psk previously + // first, look for it by full memory path, which is how it exports as gltf var entry = FindEntryByMemeroryFullPath(package, materials[i].Name, "MaterialInterface"); + // fall back to looking for it by just the last segment for compatibility with stuff exported as psk + entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materials[i].Name && x.IsA("MaterialInterface")); + entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materials[i].Name && x.IsA("MaterialInterface")); if (entry != null) { meshBin.Materials[i] = entry.UIndex; @@ -1469,7 +1462,6 @@ static StaticLODModel SetupLOD(IntermediateMesh intermediateMesh, IntermediateLO void SetupSectionsAndChunks() { LOD.IndexBuffer = [.. lod.Sections.SelectMany(x => x.Triangles).SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3])]; - //TODO if there is only one section, it is trivial to set up // just make the sections 1:1 with the sections in the intermediate mesh var cumulativeTriangleCount = 0; @@ -1489,7 +1481,8 @@ void SetupSectionsAndChunks() cumulativeTriangleCount += intermediateSection.Triangles.Count; skelMeshSections.Add(section); } - // TODO if there are consecutive sections with the same material, combine them + + // TODO if there are consecutive sections with the same material, combine them? var orderedSection = skelMeshSections.OrderBy(x => x.MinVertIndex).ThenBy(x => x.MaxVertIndex); List chunks = []; @@ -1665,7 +1658,6 @@ static void WriteSockets(IntermediateMesh mesh, ExportEntry export) new IntProperty(yaw.RadiansToUnrealRotationUnits(), "Yaw"), new IntProperty(roll.RadiansToUnrealRotationUnits(), "Roll") ) { Name = "RelativeRotation" }, - // TODO support relative scale? new NameProperty(socket.Name, "SocketName"), new NameProperty(socket.Bone, "BoneName") }; From 2f4d8fbf9356dd08c85053a465bb9d42f88eea61 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 30 Jan 2026 22:44:53 -0500 Subject: [PATCH 27/34] rearranged things a little bit, got static mesh import mostly working. --- .../PackageEditorExperimentsSquid.cs | 9 +- .../PackageEditor/Experiments/SquidGltf.cs | 343 +++++++++++++++--- .../ExperimentsMenuControl.xaml | 5 +- .../ExperimentsMenuControl.xaml.cs | 5 + 4 files changed, 301 insertions(+), 61 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 4935307c5..b6be026dc 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -110,7 +110,7 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } - public static void ExportMeshToGltf(PackageEditorWindow pew) + public static void ExportMeshToGltf(PackageEditorWindow pew, SquidGltf.MaterialExportLevel materialExportLevel = SquidGltf.MaterialExportLevel.NameOnly) { if (pew.Pcc == null) { @@ -131,7 +131,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) { if (export.ClassName == "SkeletalMesh") { - SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, SquidGltf.MaterialExportLevel.NameOnly, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } // TODO support other closely related types? else if (export.ClassName == "StaticMesh") @@ -140,7 +140,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew) { ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); } - SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, SquidGltf.MaterialExportLevel.NameOnly, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); } } } @@ -166,7 +166,8 @@ public static void ImportGltf(PackageEditorWindow pew) } if (GetGltfFromFile(out var gltf, out string _)) { - SquidGltf.ConvertGltfToMesh(gltf, pew.Pcc); + GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh"], out ExportEntry selectedMeshToReplace); + SquidGltf.ConvertGltfToMesh(gltf, pew.Pcc, selectedMeshToReplace); } } private static bool GetGltfFromFile(out SharpGLTF.Schema2.ModelRoot gltf, out string filePath) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs index 2a36ce5d2..fbfd86864 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs @@ -21,11 +21,13 @@ using System.IO; using System.Linq; using System.Numerics; +using System.Text.RegularExpressions; +using System.Windows.Forms.VisualStyles; using IsImage = SixLabors.ImageSharp.Image; namespace LegendaryExplorer.Tools.PackageEditor.Experiments { - public class SquidGltf + public partial class SquidGltf { private const float ScaleFactor = 100; const float weightUnpackScale = 1f / 255; @@ -33,6 +35,9 @@ public class SquidGltf const int MaxBoneInfluences = 4; static readonly float[] displayFactors = [1.0f, 0.25f, 0.1f]; + [GeneratedRegex(@"\.\d+$")] + private static partial Regex BlenderNameSuffixRegex(); + #region export public enum MaterialExportLevel { @@ -417,7 +422,7 @@ private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null // LODs foreach (var lod in mesh.LODs) { - var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD_{lod.Index}"; + var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD{lod.Index}"; // SkeletalMesh version if (mesh.Skeleton != null) { @@ -683,13 +688,26 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry // TODO show a warning or something return; } - // TODO right now I just import them all as new meshes, which seems to work ok for skeletal meshes - // but if they are replacing a mesh I will need to make them pick one - //if (totalMeshes > 1) + // if there are both skeletal and static meshes in this, you can't have anything selected + // covered by the below + //if (skeletalMeshes.Any() && staticMeshes.Any() && existingMesh != null) //{ - // // TODO add support for this? - // throw new NotImplementedException("I can't import where there is more than one mesh per file yet"); + // throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh and it will import all your meshes as new meshes."); //} + if (totalMeshes > 1 && existingMesh != null) + { + // TODO add support for this? + throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh and it will import all your meshes as new meshes."); + } + // if you are trying to replace a skeletal mesh but you are importing static (or vice versa) count it as nothing selected (not a compatible export selected) + if (skeletalMeshes.Count == 1 && existingMesh != null && existingMesh.ClassName == "StaticMesh") + { + existingMesh = null; + } + if (staticMeshes.Count == 1 && existingMesh != null && existingMesh.ClassName == "SkeletalMesh") + { + existingMesh = null; + } foreach (var skelMesh in skeletalMeshes) { var intermediateMesh = ToIntermediateMesh(skelMesh.Item1, skelMesh.Item2); @@ -700,10 +718,21 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry { var intermediateMesh = ToIntermediateMesh(statMesh.Item1, statMesh.Item2); CleanUpIntermediateMesh(intermediateMesh); - // TODO to Static Mesh! + ToStaticMesh(intermediateMesh, pcc, existingMesh); } } + private static IEntry GetMaterialEntry(IMEPackage package, string materialName) + { + // first, look for it by full memory path, which is how it exports as gltf + var entry = FindEntryByMemeroryFullPath(package, materialName, "MaterialInterface"); + // fall back to looking for it by just the last segment for compatibility with stuff exported as psk + entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materialName && x.IsA("MaterialInterface")); + entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materialName && x.IsA("MaterialInterface")); + + return entry; + } + private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) { // TODO make sure everything is normalized properly? @@ -1027,14 +1056,39 @@ bool IsAttributeShared(string attribute) && IsAttributeShared("WEIGHTS_0") && IsAttributeShared(VertexTextureNOriginalIndex.OriginalIndexAttributeName); } + + private static void SortNodes(string meshName, Node[] nodes, out Node[] lodNodes, out IEnumerable collisionNodes) + { + + if (nodes[0].Skin != null) + { + // all nodes with skins are LODs. Skeletal Meshes do not have separate collision components + // TODO sort these somehow? I think right now it just trusts the order in the gltf, which will be alphabetical coming out of Blender, which will likely work + // I could also look for _LODX suffix, but I don't want to be too strict about naming + lodNodes = nodes; + collisionNodes = []; + } + else + { + // static meshes are sorted on whether they contain "collision" in the name (case insensitive) + collisionNodes = nodes.Where(x => x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase)); + // TODO sort these at all?> same problem as above. + lodNodes = nodes.Where(x => !x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase)).ToArray(); + } + } + private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes) { if (nodes.Length == 0) { throw new ArgumentException(nameof(nodes)); } + + meshName = CleanupName(meshName); + SortNodes(meshName, nodes, out var lodNodes, out var collisionNodes); + // TODO sort this into groups of LODs vs collision components - var skeletalMesh = nodes[0].Skin != null; + var skeletalMesh = lodNodes[0].Skin != null; int lodIndex = 0; int boneIndex = 0; // maps from material index within the gltf file to materials within this mesh (in the array order) @@ -1052,7 +1106,7 @@ private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes if (skeletalMesh) { intermediateMesh.Skeleton = []; - foreach (var joint in nodes[0].Skin.Joints) + foreach (var joint in lodNodes[0].Skin.Joints) { boneMap.Add(joint.LogicalIndex); intermediateMesh.Skeleton.Add(new IntermediateBone() @@ -1063,7 +1117,7 @@ private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes } // reconstruct the hierarchy of bones from the node hierarchy. There can be other nodes in between joints, so we have to check all ancestors List rootJoints = []; - foreach (var joint in nodes[0].Skin.Joints) + foreach (var joint in lodNodes[0].Skin.Joints) { Node FindJointParent(Node node) { @@ -1109,7 +1163,7 @@ Node[] FindSockets(Node joint) // look for direct children of a bone which are not themselves bones return [.. joint.VisualChildren.Where(x => !x.IsSkinJoint)]; } - foreach (var joint in nodes[0].Skin.Joints) + foreach (var joint in lodNodes[0].Skin.Joints) { var sockets = FindSockets(joint); foreach (var socket in sockets) @@ -1117,7 +1171,7 @@ Node[] FindSockets(Node joint) intermediateMesh.Sockets.Add(new IntermediateSocket() { Bone = joint.Name, - Name = socket.Name, + Name = CleanupName(socket.Name), RelativeLocation = TransformSocketLocationFromGltf(socket.LocalTransform.Translation), RelativeRotation = TransformSocketRotationFromGltf(socket.LocalTransform.Rotation), RelativeScale = TransformScaleFromGltf(socket.LocalTransform.Scale) @@ -1126,7 +1180,7 @@ Node[] FindSockets(Node joint) } } - foreach (var node in nodes) + foreach (var node in lodNodes) { var LOD = new IntermediateLOD() { Index = lodIndex++ }; @@ -1237,20 +1291,18 @@ void AddWeights(IList jointsList, IList weightsList) var meshMatIndex = materialMap.IndexOf(gltfMatIndex); if (meshMatIndex == -1) { - // TODO make sure this is not off by 1 meshMatIndex = materialMap.Count; materialMap.Add(gltfMatIndex); - intermediateMesh.Materials.Add(new IntermediateMaterial(prim.Material?.Name ?? "null")); + intermediateMesh.Materials.Add(new IntermediateMaterial(CleanupName(prim.Material?.Name) ?? "null")); } - var meshSection = new IntermediateMeshSection() + var meshSection = new IntermediateMeshSection { - MaterialIndex = meshMatIndex + MaterialIndex = meshMatIndex, + // use the shared vertices, if available + Vertices = sharedVerts ?? GetPrimativeVertices(prim) }; - // use the shared vertices, if available - meshSection.Vertices = sharedVerts ?? GetPrimativeVertices(prim); - // this gets us a list of int triplets; the indices of each triangle var triIndices = prim.GetTriangleIndices(); @@ -1269,10 +1321,71 @@ void AddWeights(IList jointsList, IList weightsList) intermediateMesh.LODs.Add(LOD); } - // TODO collision mesh components for static meshes + if (collisionNodes != null) + { + intermediateMesh.CollisionMeshElements = []; + } + foreach (var collisionNode in collisionNodes) + { + intermediateMesh.CollisionMeshElements.Add(ToIntermediateCollisionElement(collisionNode)); + } + return intermediateMesh; } + private static IntermediateCollisionElement ToIntermediateCollisionElement(Node node) + { + if (DoesMeshUseSharedVertexAccessors(node.Mesh)) + { + var vertices = node.Mesh.Primitives[0].GetVertexAccessor("POSITION").AsVector3Array(); + var collisionElement = new IntermediateCollisionElement() + { + Vertices = [.. vertices], + Triangles = [] + }; + + foreach (var primitive in node.Mesh.Primitives) + { + foreach (var (v1, v2, v3) in primitive.GetTriangleIndices()) + { + collisionElement.Triangles.Add(new IntermediateTriangle() + { + VertIndex1 = v1, + VertIndex2 = v2, + VertIndex3 = v3 + }); + } + } + + return collisionElement; + } + else + { + List vertices = []; + List triangles = []; + + foreach (var primitive in node.Mesh.Primitives) + { + foreach (var (v1, v2, v3) in primitive.GetTriangleIndices()) + { + triangles.Add(new IntermediateTriangle() + { + VertIndex1 = v1 + vertices.Count, + VertIndex2 = v2 + vertices.Count, + VertIndex3 = v3 + vertices.Count + }); + } + vertices.AddRange(primitive.GetVertexAccessor("POSITION").AsVector3Array()); + } + + return new IntermediateCollisionElement() + { + Vertices = vertices, + Triangles = triangles + }; + } + } + private static Vector3 TransformSocketLocationFromGltf(Vector3 input) { return new Vector3(input.X, -input.Y, input.Z) * ScaleFactor; @@ -1321,11 +1434,161 @@ private static Quaternion TransformBoneRotationFromGltf(Quaternion input) return Quaternion.Normalize(temp); } + private static string CleanupName(string name) + { + if (name == null) + { + return name; + } + var match = BlenderNameSuffixRegex().Match(name); + if (match.Success) + { + return name[..match.Index]; + } + + return name; + } + + private static ExportEntry ToStaticMesh(IntermediateMesh intermediateMesh, IMEPackage package, ExportEntry existingEntry) + { + var staticMesh = new StaticMesh + { + Bounds = GetBounds(intermediateMesh), + LODModels = [.. intermediateMesh.LODs.Select(GetLod)], + LightingGuid = Guid.NewGuid() + }; + + var tris = new kDOPCollisionTriangle[staticMesh.LODModels[0].IndexBuffer.Length / 3]; + for (int i = 0, elIdx = 0, triCount = 0; i < staticMesh.LODModels[0].IndexBuffer.Length; i += 3, ++triCount) + { + if (triCount > staticMesh.LODModels[0].Elements[elIdx].NumTriangles) + { + triCount = 0; + ++elIdx; + } + tris[i / 3] = new kDOPCollisionTriangle(staticMesh.LODModels[0].IndexBuffer[i], staticMesh.LODModels[0].IndexBuffer[i + 1], staticMesh.LODModels[0].IndexBuffer[i + 2], + (ushort)intermediateMesh.LODs[0].Sections[elIdx].MaterialIndex); + } + + staticMesh.kDOPTreeME3UDKLE = KDOPTreeBuilder.ToCompact(tris, staticMesh.LODModels[0].PositionVertexBuffer.VertexData); + + StaticMeshRenderData GetLod(IntermediateLOD intermediateLod) + { + var lod = new StaticMeshRenderData() + { + Edges = [], + RawTriangles = [], + ColorVertexBuffer = new ColorVertexBuffer(), + ShadowTriangleDoubleSided = [], + WireframeIndexBuffer = [], + NumVertices = (uint)intermediateLod.Sections[0].Vertices.Count, + ShadowExtrusionVertexBuffer = new ExtrusionVertexBuffer + { + Stride = 4, + VertexData = [] + }, + }; + + var intermediateVerts = intermediateLod.Sections[0].Vertices; + // vertex positions + lod.PositionVertexBuffer = new PositionVertexBuffer() + { + NumVertices = (uint)intermediateVerts.Count, + Stride = 12, + unk = 1, + VertexData = [.. intermediateVerts.Select(x => x.Position)] + }; + // vertex tangents, normal, UVs + var numTexCoords = intermediateVerts[0].UVs.Count; + lod.VertexBuffer = new StaticMeshVertexBuffer() + { + bUseFullPrecisionUVs = false, + NumTexCoords = (uint)numTexCoords, + NumVertices = (uint)intermediateVerts.Count, + Stride = 16, // TODO I bet this depends on the number of UVs and also maybe the precision used + VertexData = [..intermediateVerts.Select(x => + { + var packedNorm = (PackedNormal)Vector3.Normalize(x.Normal.Value); + var normalW = x.BiTangentDirection > 0 ? (byte)255 : (byte)0; + return new StaticMeshVertexBuffer.StaticMeshFullVertex() + { + TangentX = (PackedNormal)x.Tangent.Value, + TangentZ = new PackedNormal(packedNorm.X, packedNorm.Y, packedNorm.Z, normalW), + HalfPrecisionUVs = [..x.UVs.Select(uv => (Vector2DHalf)uv)] + }; + })] + }; + + lod.IndexBuffer = [.. intermediateLod.Sections.SelectMany(x => x.Triangles).SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3])]; + List elements = []; + int triOffset = 0; + foreach (var section in intermediateLod.Sections) + { + var triIndices = section.Triangles.SelectMany(x => [(ushort)x.VertIndex1, (ushort)x.VertIndex2, (ushort)x.VertIndex3]); + var element = new StaticMeshElement() + { + bEnableShadowCasting = true, + EnableCollision = true, + OldEnableCollision = true, + Material = GetMaterialEntry(package, intermediateMesh.Materials[section.MaterialIndex].Name)?.UIndex ?? 0, + MaterialIndex = section.MaterialIndex, + FirstIndex = (uint)triOffset, + NumTriangles = (uint)section.Triangles.Count, + MaxVertexIndex = triIndices.Max(), + MinVertexIndex = triIndices.Min(), + Fragments = [new FragmentRange(triOffset, section.Triangles.Count)] + }; + elements.Add(element); + triOffset += section.Triangles.Count * 3; + } + + lod.Elements = [.. elements]; + + return lod; + } + + existingEntry ??= ExportCreator.CreateExport(package, intermediateMesh.Name, "StaticMesh"); + + existingEntry.WriteBinary(staticMesh); + + // TODO properties, mostly rigidBody + + return existingEntry; + } + + private static BoxSphereBounds GetBounds(IntermediateMesh intermediateMesh) + { + // bounds are important at least for the camera display preview in LEX, and possibly important for when to cull meshes based on visibility in game + // separate out the coordinates for each axis so we can operate on them + var vertices = GetAllVertices(intermediateMesh.LODs[0]); + var xCoords = vertices.Select(x => x.Position.X); + var yCoords = vertices.Select(x => x.Position.Y); + var zCoords = vertices.Select(x => x.Position.Z); + + // get the origin by averaging all vertex positions; it'll probably be close enough + var origin = new Vector3(xCoords.Average(), yCoords.Average(), zCoords.Average()); + + var xRange = xCoords.Select(coord => Math.Abs(coord - origin.X)).Max(); + var yRange = yCoords.Select(coord => Math.Abs(coord - origin.Y)).Max(); + var zRange = zCoords.Select(coord => Math.Abs(coord - origin.Z)).Max(); + var boxExtent = new Vector3(xRange, yRange, zRange); + + var sphereRad = boxExtent.Length(); + return new BoxSphereBounds + { + Origin = origin, + // best guess at a reasonable margin + BoxExtent = boxExtent * 2, + SphereRadius = sphereRad * 2 + }; + } + private static ExportEntry ToSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package, ExportEntry existingEntry) { var meshBin = SkeletalMesh.Create(); SetupSkeleton(intermediateMesh.Skeleton, meshBin); - SetupBounds(intermediateMesh, meshBin); + + meshBin.Bounds = GetBounds(intermediateMesh); SetupMaterials(intermediateMesh.Materials, meshBin, package); meshBin.LODModels = [.. intermediateMesh.LODs.Select(lod => SetupLOD(intermediateMesh, lod, meshBin))]; @@ -1390,33 +1653,6 @@ int GetDepth(int i) meshBin.SkeletalDepth = skeletalDepth.Max(); } - static void SetupBounds(IntermediateMesh intermediateMesh, SkeletalMesh meshBin) - { - // bounds are important at least for the camera display preview in LEX, and possibly important for when to cull meshes based on visibility in game - // separate out the coordinates for each axis so we can operate on them - var vertices = GetAllVertices(intermediateMesh.LODs[0]); - var xCoords = vertices.Select(x => x.Position.X); - var yCoords = vertices.Select(x => x.Position.Y); - var zCoords = vertices.Select(x => x.Position.Z); - - // get the origin by averaging all vertex positions; it'll probably be close enough - var origin = new Vector3(xCoords.Average(), yCoords.Average(), zCoords.Average()); - - var xRange = xCoords.Select(coord => Math.Abs(coord - origin.X)).Max(); - var yRange = yCoords.Select(coord => Math.Abs(coord - origin.Y)).Max(); - var zRange = zCoords.Select(coord => Math.Abs(coord - origin.Z)).Max(); - var boxExtent = new Vector3(xRange, yRange, zRange); - - var sphereRad = boxExtent.Length(); - meshBin.Bounds = new BoxSphereBounds - { - Origin = origin, - // best guess at a reasonable margin - BoxExtent = boxExtent * 2, - SphereRadius = sphereRad * 2 - }; - } - static void SetupMaterials(IList materials, SkeletalMesh meshBin, IMEPackage package) { SetNumMaterialSlots(meshBin, materials.Count); @@ -1427,10 +1663,7 @@ static void SetupMaterials(IList materials, SkeletalMesh m continue; } // first, look for it by full memory path, which is how it exports as gltf - var entry = FindEntryByMemeroryFullPath(package, materials[i].Name, "MaterialInterface"); - // fall back to looking for it by just the last segment for compatibility with stuff exported as psk - entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materials[i].Name && x.IsA("MaterialInterface")); - entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materials[i].Name && x.IsA("MaterialInterface")); + var entry = GetMaterialEntry(package, materials[i].Name); if (entry != null) { meshBin.Materials[i] = entry.UIndex; @@ -1661,7 +1894,7 @@ static void WriteSockets(IntermediateMesh mesh, ExportEntry export) new NameProperty(socket.Name, "SocketName"), new NameProperty(socket.Bone, "BoneName") }; - if (socket.RelativeScale != Vector3.One) + if (Vector3.DistanceSquared(socket.RelativeScale, Vector3.One) > 0.0001) { socketProperties.Add(new StructProperty("Vector", true, new FloatProperty(socket.RelativeScale.X, "X"), diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml index c9fd8af24..315a6a395 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml @@ -174,8 +174,9 @@ - - + + + diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 82f69a0df..7c98e20a3 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1349,6 +1349,11 @@ private void ExportGltf_Click(object sender, RoutedEventArgs args) { PackageEditorExperimentsSquid.ExportMeshToGltf(GetPEWindow()); } + + private void ExportGltf_Textures_Click(object sender, RoutedEventArgs args) + { + PackageEditorExperimentsSquid.ExportMeshToGltf(GetPEWindow(), SquidGltf.MaterialExportLevel.Basic); + } private void ExportSelectedToPsx_Click(object sender, RoutedEventArgs e) { PackageEditorExperimentsSquid.ExportSelectedToPsx(GetPEWindow()); From 02e19c90184e2c3f51a9b5309a8cf10180f30d8e Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Mon, 2 Feb 2026 15:43:48 -0500 Subject: [PATCH 28/34] Lots of progress. Rearrnaged things so that most of the code lives in LEC, made a few shared methods usable by anyone, imrpoved the hueristic to pick diff and norm textures to export, and added better support for exporting multiple meshes at once. I used this to implement exporting all meshes for a BioPawn at once. Also added a busy bar and moved export to the background so it doesn't just freeze the whole UI while it runs. also implemented marking materials as two sided if they are marked such in the game files on the base material. --- .../PackageEditorExperimentsSquid.cs | 167 +----- .../ExperimentsMenuControl.xaml.cs | 2 +- .../Helpers/MaterialHelper.cs | 270 +++++++++ .../Packages/PackageExtensions.cs | 23 + .../Unreal/GLTF.cs} | 550 +++++++++--------- 5 files changed, 603 insertions(+), 409 deletions(-) create mode 100644 LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs rename LegendaryExplorer/{LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs => LegendaryExplorerCore/Unreal/GLTF.cs} (82%) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index b6be026dc..2fb8b57dd 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -24,6 +24,7 @@ using System.Linq; using System.Numerics; using System.Text; +using System.Threading.Tasks; using System.Windows; using static LegendaryExplorerCore.Packages.CloningImportingAndRelinking.EntryImporter; using static LegendaryExplorerCore.Unreal.PSA; @@ -110,7 +111,7 @@ public static void ImportAnimSet(PackageEditorWindow pew) } } - public static void ExportMeshToGltf(PackageEditorWindow pew, SquidGltf.MaterialExportLevel materialExportLevel = SquidGltf.MaterialExportLevel.NameOnly) + public static void ExportMeshToGltf(PackageEditorWindow pew, GLTF.MaterialExportLevel materialExportLevel = GLTF.MaterialExportLevel.NameOnly) { if (pew.Pcc == null) { @@ -124,29 +125,33 @@ public static void ExportMeshToGltf(PackageEditorWindow pew, SquidGltf.MaterialE { ShowError("This experiment does not support UDK files;"); } - if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh"], out var export)) + if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh", "SkeletalMeshComponent", "BioPawn"], out var export)) { + if (export.ClassName == "StaticMesh" && !(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) + { + ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + } var d = new SaveFileDialog { Filter = "glTF binary|*.glb|glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}.glb"}; if (d.ShowDialog() == true) { - if (export.ClassName == "SkeletalMesh") + Task.Run(() => { - SquidGltf.ConvertSkeletalMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); - } - // TODO support other closely related types? - else if (export.ClassName == "StaticMesh") + pew.BusyText = "Exporting to glTF..."; + pew.IsBusy = true; + GLTF.ExportMeshToGltf(export, d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + }).ContinueWithOnUIThread(x => { - if (!(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) + pew.IsBusy = false; + if (x.Exception != null) { - ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + ShowError(x.Exception.FlattenException()); } - SquidGltf.ConvertStaticMeshToGltf(ObjectBinary.From(export, new PackageCache()), d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); - } + }); } } else { - ShowError("You must select a skeletal mesh or static mesh"); + ShowError("You must select a skeletal mesh, static mesh, SkeletalMeshComponent, or BioPawn"); } } @@ -167,7 +172,7 @@ public static void ImportGltf(PackageEditorWindow pew) if (GetGltfFromFile(out var gltf, out string _)) { GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh"], out ExportEntry selectedMeshToReplace); - SquidGltf.ConvertGltfToMesh(gltf, pew.Pcc, selectedMeshToReplace); + GLTF.ConvertGltfToMesh(gltf, pew.Pcc, selectedMeshToReplace); } } private static bool GetGltfFromFile(out SharpGLTF.Schema2.ModelRoot gltf, out string filePath) @@ -875,143 +880,9 @@ public static void ExportTexturesFromMaterial(PackageEditorWindow pew) } } - public static void GetMaterialTextures(ExportEntry materialExport, out Dictionary textureExports, out List baseTextures, bool includeUniformExpressionTextures = true) - { - Dictionary tempTextureExports = []; - List tempBaseTextures = []; - var cache = new PackageCache(); - - delegateByType(materialExport); - - textureExports = tempTextureExports; - baseTextures = tempBaseTextures; - - void delegateByType(ExportEntry materialEntry) - { - var selectedEntryClass = materialEntry.ClassName; - if (materialEntry.ClassName == "Material") - { - ExportBaseMaterialTextures(materialEntry, includeUniformExpressionTextures); - } - else if (materialEntry.IsA("MaterialInstanceConstant")) - { - ExportMICTextures(materialEntry); - } - else if (materialEntry.IsA("RvrEffectsMaterialUser")) - { - ExportEffectMatUserTextures(materialEntry); - - } - else - { - return; - } - } - - void ExportEffectMatUserTextures(ExportEntry effectsMatEntry) - { - // for this, just get the base material stuff - if (effectsMatEntry.GetProperty("m_pBaseMaterial", cache).TryResolveExport(effectsMatEntry.FileRef, cache, out var baseMat)) - { - delegateByType(baseMat); - } - } - - void ExportMICTextures(ExportEntry micExport) - { - // get anything from the texture Parameters - var texParamsProp = micExport.GetProperty>("TextureParameterValues", cache); - if (texParamsProp != null) - { - foreach (var texParam in texParamsProp) - { - var paramName = texParam.GetProp("ParameterName").Value.Instanced; - if (!tempTextureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) - { - // skip the really dumb textures - if (value.ObjectNameString.StartsWith("GBL_ARM_ALL")) - { - continue; - } - if (value.IsA("Texture2D")) - { - tempTextureExports.Add(paramName, value); - } - } - } - } - // then go to the parent, if it exists - if (micExport.GetProperty("Parent", cache).TryResolveExport(micExport.FileRef, cache, out var parent)) - { - delegateByType(parent); - } - } - - void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExpressionTextures = true) - { - if (includeUniformExpressionTextures) - { - var matBin = ObjectBinary.From(baseMatEntry); - if (matBin.SM2MaterialResource.UniformExpressionTextures != null) - { - foreach (var texIdx in matBin.SM2MaterialResource.UniformExpressionTextures) - { - if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) - { - // skip the really dumb textures - if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) - { - continue; - } - if (tex.IsA("Texture2D")) - { - tempBaseTextures.Add(tex); - } - } - } - } - - if (matBin.SM3MaterialResource.UniformExpressionTextures != null) - { - foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) - { - if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) - { - // skip the really dumb textures - if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) - { - continue; - } - if (tex.IsA("Texture2D")) - { - tempBaseTextures.Add(tex); - } - } - } - } - } - - var expressions = baseMatEntry.GetProperty>("Expressions"); - if (expressions == null) - { - return; - } - - // Read default expressions - foreach (var expr in expressions.Select(x => x.ResolveToEntry(baseMatEntry.FileRef)).Where(x => x != null && x.IsA("MaterialExpressionTextureSampleParameter")).OfType()) - { - var paramName = expr.GetProperty("ParameterName")?.Value.Instanced ?? "None"; - - if (!tempTextureExports.ContainsKey(paramName) && expr.GetProperty("Texture").TryResolveExport(baseMatEntry.FileRef, cache, out var value)) - { - tempTextureExports.Add(paramName, value); - } - } - } - } public static void ExportMaterialTextures(ExportEntry materialExport, string exportDirectory) { - GetMaterialTextures(materialExport, out var textureExports, out var baseTextures, true); + var textureExports = materialExport.GetMaterialTextures(out var baseTextures); foreach (var tex in baseTextures) { diff --git a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs index 7c98e20a3..b24f25d38 100644 --- a/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs +++ b/LegendaryExplorer/LegendaryExplorer/UserControls/PackageEditorControls/ExperimentsMenuControl.xaml.cs @@ -1352,7 +1352,7 @@ private void ExportGltf_Click(object sender, RoutedEventArgs args) private void ExportGltf_Textures_Click(object sender, RoutedEventArgs args) { - PackageEditorExperimentsSquid.ExportMeshToGltf(GetPEWindow(), SquidGltf.MaterialExportLevel.Basic); + PackageEditorExperimentsSquid.ExportMeshToGltf(GetPEWindow(), GLTF.MaterialExportLevel.Basic); } private void ExportSelectedToPsx_Click(object sender, RoutedEventArgs e) { diff --git a/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs b/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs new file mode 100644 index 000000000..e3ba20210 --- /dev/null +++ b/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs @@ -0,0 +1,270 @@ +using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Unreal; +using LegendaryExplorerCore.Unreal.BinaryConverters; +using LegendaryExplorerCore.Unreal.ObjectInfo; +using System.Collections.Generic; +using System.Linq; + +namespace LegendaryExplorerCore.Helpers +{ + public static class MaterialHelper + { + /// + /// Gathers all textures that might be used by a given material, grouped by parameter name. If this is a MaterialInstanceConstant that overrides a parent value, it will return the overridden value and not the parent value. + /// Can optionally include the Uniform Expression Textures, which do not have parameter names and might be overridden. In some cases, this is the only place where important textures are located. + /// + /// The material export from which to gather textures. + /// the textures from the base material uniform expression textures. + /// Whether to include the uniform expression textures + private static Dictionary GetMaterialTextures(ExportEntry materialExport, out List baseTextures, bool includeUniformExpressionTextures = true) + { + Dictionary textureExports = []; + List tempBaseTextures = []; + var cache = new PackageCache(); + + delegateByType(materialExport); + baseTextures = tempBaseTextures; + return textureExports; + + void delegateByType(ExportEntry materialEntry) + { + var selectedEntryClass = materialEntry.ClassName; + if (materialEntry.ClassName == "Material") + { + ExportBaseMaterialTextures(materialEntry, includeUniformExpressionTextures); + } + else if (materialEntry.IsA("MaterialInstanceConstant")) + { + ExportMICTextures(materialEntry); + } + else if (materialEntry.IsA("RvrEffectsMaterialUser")) + { + ExportEffectMatUserTextures(materialEntry); + + } + else + { + return; + } + } + + void ExportEffectMatUserTextures(ExportEntry effectsMatEntry) + { + // for this, just get the base material stuff + if (effectsMatEntry.GetProperty("m_pBaseMaterial", cache).TryResolveExport(effectsMatEntry.FileRef, cache, out var baseMat)) + { + delegateByType(baseMat); + } + } + + void ExportMICTextures(ExportEntry micExport) + { + // get anything from the texture Parameters + var texParamsProp = micExport.GetProperty>("TextureParameterValues", cache); + if (texParamsProp != null) + { + foreach (var texParam in texParamsProp) + { + var paramName = texParam.GetProp("ParameterName").Value.Instanced; + if (!textureExports.ContainsKey(paramName) && texParam.GetProp("ParameterValue").TryResolveExport(micExport.FileRef, cache, out var value)) + { + // skip the really dumb textures + if (value.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + if (value.IsA("Texture2D")) + { + textureExports.Add(paramName, value); + } + } + } + } + // then go to the parent, if it exists + if (micExport.GetProperty("Parent", cache).TryResolveExport(micExport.FileRef, cache, out var parent)) + { + delegateByType(parent); + } + } + + void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExpressionTextures = true) + { + if (includeUniformExpressionTextures) + { + var matBin = ObjectBinary.From(baseMatEntry); + if (matBin.SM2MaterialResource.UniformExpressionTextures != null) + { + foreach (var texIdx in matBin.SM2MaterialResource.UniformExpressionTextures) + { + if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + { + // skip the really dumb textures + if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + if (tex.IsA("Texture2D")) + { + tempBaseTextures.Add(tex); + } + } + } + } + + if (matBin.SM3MaterialResource.UniformExpressionTextures != null) + { + foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) + { + if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + { + // skip the really dumb textures + if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + { + continue; + } + if (tex.IsA("Texture2D")) + { + tempBaseTextures.Add(tex); + } + } + } + } + } + + var expressions = baseMatEntry.GetProperty>("Expressions"); + if (expressions == null) + { + return; + } + + // Read default expressions + foreach (var expr in expressions.Select(x => x.ResolveToEntry(baseMatEntry.FileRef)).Where(x => x != null && x.IsA("MaterialExpressionTextureSampleParameter")).OfType()) + { + var paramName = expr.GetProperty("ParameterName")?.Value.Instanced ?? "None"; + + if (!textureExports.ContainsKey(paramName) && expr.GetProperty("Texture").TryResolveExport(baseMatEntry.FileRef, cache, out var value)) + { + if (value.IsA("Texture2D")) + { + textureExports.Add(paramName, value); + } + } + } + } + } + + public static IReadOnlyDictionary GetMaterialTextures(this ExportEntry materialExport) + { + return GetMaterialTextures(materialExport, out var _, false); + } + + public static IReadOnlyDictionary GetMaterialTextures(this ExportEntry materialExport, out IReadOnlyList baseTextures) + { + var baseTextureList = new List(); + baseTextures = baseTextureList; + return GetMaterialTextures(materialExport, out baseTextureList, true); + } + + public static ExportEntry GetBaseMaterial(this ExportEntry materialExport, PackageCache cache = null) + { + switch (materialExport.ClassName) + { + case "BioMaterialInstanceConstant": + case "MaterialInstanceConstant": + cache ??= new PackageCache(); + var parent = materialExport.GetProperty("Parent").ResolveToExport(materialExport.FileRef, cache); + return parent?.GetBaseMaterial(cache); + case "RvrEffectsMaterialUser": + cache ??= new PackageCache(); + var baseMat = materialExport.GetProperty("m_pBaseMaterial").ResolveToExport(materialExport.FileRef, cache); + return baseMat?.GetBaseMaterial(cache); + case "Material": + return materialExport; + default: + return null; + } + } + + public static void FindBestDiffAndNormForMaterial(ExportEntry material, out ExportEntry diffuseTexture, out ExportEntry normalTexture) + { + diffuseTexture = null; + normalTexture = null; + var paramTextures = material.GetMaterialTextures(out var baseTextures); + var baseMaterial = material.GetBaseMaterial().ObjectNameString; + // for specific materials, we know which parameter to look at for norm and diff, and matching by name sometimes picks the wrong one or can't find it at all + // but we can't just blindly go by parameter name either, or we will grab things like Teeth Diff instead of Scalp Diff. + switch (baseMaterial) + { + case "HMN_HED_NPC_Scalp_Mat_1a": + paramTextures.TryGetValue("HED_Scalp_Diff", out diffuseTexture); + paramTextures.TryGetValue("HED_Scalp_Norm", out normalTexture); + break; + case "HMM_CTH_MASTER_MAT": + paramTextures.TryGetValue("HMM_ARM_ALL_Diff_Stack", out diffuseTexture); + paramTextures.TryGetValue("HMM_ARM_ALL_Norm_Stack", out normalTexture); + break; + case "HMN_HED_LASH_Unlit_MASTER_MAT": + paramTextures.TryGetValue("HED_Lash_Diff", out diffuseTexture); + break; + default: + break; + } + string matPackage = null; + if (material.Parent != null) + { + matPackage = material.Parent.FullPath.ToLower(); + } + IEnumerable allTextures = [.. paramTextures.Values, .. baseTextures]; + //search for textures under the same package as the export + foreach (var textureEntry in allTextures) + { + var texObjectName = textureEntry.FullPath.ToLower(); + if ((matPackage == null || texObjectName.StartsWith(matPackage)) && texObjectName.Contains("diff")) + { + // we have found the diffuse texture! + diffuseTexture ??= textureEntry; + } + else if ((matPackage == null || texObjectName.StartsWith(matPackage)) && texObjectName.Contains("norm")) + { + // we have found the normal texture! + normalTexture ??= textureEntry; + } + } + + foreach (var textureEntry in allTextures) + { + var texObjectName = textureEntry.ObjectName.Name.ToLower(); + if (texObjectName.Contains("diff") || texObjectName.Contains("tex")) + { + // we have found the diffuse texture! + diffuseTexture ??= textureEntry; + } + if (texObjectName.Contains("norm")) + { + // we have found the normal texture! + normalTexture ??= textureEntry; + } + } + foreach (var texparam in allTextures) + { + var texObjectName = texparam.ObjectName.Name.ToLower(); + + if (texObjectName.Contains("detail")) + { + // I guess a detail texture is good enough if we didn't return for a diffuse texture earlier... + diffuseTexture ??= texparam; + } + } + foreach (var texparam in allTextures) + { + var texObjectName = texparam.ObjectName.Name.ToLower(); + if (!texObjectName.Contains("norm") && !texObjectName.Contains("opac")) + { + //Anything is better than nothing I suppose + diffuseTexture ??= texparam; + } + } + return; + } + } +} diff --git a/LegendaryExplorer/LegendaryExplorerCore/Packages/PackageExtensions.cs b/LegendaryExplorer/LegendaryExplorerCore/Packages/PackageExtensions.cs index 17575a915..0492437c5 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Packages/PackageExtensions.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Packages/PackageExtensions.cs @@ -542,6 +542,29 @@ void findPropertyReferences(PropertyCollection props, ExportEntry exp) } } + /// + /// Find an entry within the given package by Memory full path, optionally filtering by class + /// + /// The package to search in + /// The memory full path to search for + /// The class to filter on. It will find instances of this class or classes that extend it. + /// the matching entry, or null if none are found. + public static IEntry FindEntryByMemeroryFullPath(this IMEPackage pachage, string memoryFullPath, string className = null) + { + foreach (IEntry entry in pachage.Exports.Concat(pachage.Imports)) + { + if (entry.MemoryFullPath.CaseInsensitiveEquals(memoryFullPath)) + { + if (className != null && !(entry.ClassName.CaseInsensitiveEquals(className) || entry.IsA(className))) + { + continue; + } + return entry; + } + } + return null; + } + private readonly struct ReferenceFinder : IUIndexAction { private readonly int CurrentExportUIndex; diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs similarity index 82% rename from LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs rename to LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs index fbfd86864..0e05fc428 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/SquidGltf.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs @@ -1,8 +1,8 @@ using LegendaryExplorerCore.Gammtek.Extensions.Collections.Generic; +using LegendaryExplorerCore.Gammtek.Paths; using LegendaryExplorerCore.Helpers; using LegendaryExplorerCore.Packages; using LegendaryExplorerCore.Packages.CloningImportingAndRelinking; -using LegendaryExplorerCore.Unreal; using LegendaryExplorerCore.Unreal.BinaryConverters; using LegendaryExplorerCore.Unreal.Classes; using LegendaryExplorerCore.Unreal.ObjectInfo; @@ -22,18 +22,20 @@ using System.Linq; using System.Numerics; using System.Text.RegularExpressions; -using System.Windows.Forms.VisualStyles; using IsImage = SixLabors.ImageSharp.Image; -namespace LegendaryExplorer.Tools.PackageEditor.Experiments +namespace LegendaryExplorerCore.Unreal { - public partial class SquidGltf + public partial class GLTF { private const float ScaleFactor = 100; const float weightUnpackScale = 1f / 255; // the Mass Effect binary mesh format enforces there be a maximum of 4 bone influences per vertex const int MaxBoneInfluences = 4; static readonly float[] displayFactors = [1.0f, 0.25f, 0.1f]; + // sqrt(2)/2 comes up repeatedly in 90 degree quaternion rotations + private static readonly float Root2Over2 = (float)(Math.Sqrt(2) / 2); + private const bool ExportCollision = false; [GeneratedRegex(@"\.\d+$")] private static partial Regex BlenderNameSuffixRegex(); @@ -50,20 +52,69 @@ public enum MaterialExportLevel // for unsupported materials, falls back to basic //Enhanced } - public static void ConvertSkeletalMeshToGltf(SkeletalMesh mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) + + // export a single mesh to a glTF or glb file + public static void ExportMeshToGltf(ExportEntry mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) { - var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); - var gltf = ToGltf(intermediateMesh, versionInfo); - gltf.Save(filePath); + ExportMeshesToGltf([mesh], filePath, materialSetting, versionInfo); } - public static void ConvertStaticMeshToGltf(StaticMesh mesh, string filePath, MaterialExportLevel materialSetting = MaterialExportLevel.Basic, string versionInfo = null) + // export any number of meshes to a glTF or glb file + public static void ExportMeshesToGltf(IEnumerable exports, string filePath, MaterialExportLevel materialSetting, string versionInfo = null) { - var intermediateMesh = ToIntermediateMesh(mesh, materialSetting); - var gltf = ToGltf(intermediateMesh, versionInfo); + List intermediateMeshes = []; + foreach (var export in exports) + { + switch (export.ClassName) + { + case "SkeletalMesh": + intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); + break; + case "StaticMesh": + intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); + break; + case "SkeletalMeshComponent": + intermediateMeshes.Add(SkeletalMeshComponentToIntermediateMesh(export, materialSetting)); + break; + case "BioPawn": + intermediateMeshes.AddRange(BioPawnToIntermediateMeshes(export, materialSetting)); + break; + default: + break; + } + } + + var gltf = ToGltf(intermediateMeshes, versionInfo); gltf.Save(filePath); } + private static IEnumerable BioPawnToIntermediateMeshes(ExportEntry export, MaterialExportLevel materialSetting, PackageCache cache = null) + { + cache ??= new PackageCache(); + var props = export.GetProperties(); + var bodyMesh = props.GetProp("Mesh")?.ResolveToExport(export.FileRef, cache); + var headMesh = props.GetProp("m_oHeadMesh")?.ResolveToExport(export.FileRef, cache); + var hairMesh = props.GetProp("m_oHairMesh").ResolveToExport(export.FileRef, cache); + var headGearMesh = props.GetProp("m_oHeadGearMesh")?.ResolveToExport(export.FileRef, cache); + var visorMesh = props.GetProp("m_oVisorMesh")?.ResolveToExport(export.FileRef, cache); + var faceplateMesh = props.GetProp("m_oFacePlateMesh")?.ResolveToExport(export.FileRef, cache); + var otherMeshes = props.GetProp>("m_aoMeshes").Select(x => x?.ResolveToExport(export.FileRef, cache)); + IEnumerable meshes = [bodyMesh, headMesh, hairMesh, headGearMesh, visorMesh, faceplateMesh, .. otherMeshes]; + + foreach (var meshComponent in meshes.Where(x => x != null).Distinct()) + { + yield return SkeletalMeshComponentToIntermediateMesh(meshComponent, materialSetting, cache); + } + } + + private static IntermediateMesh SkeletalMeshComponentToIntermediateMesh(ExportEntry export, MaterialExportLevel materialSetting, PackageCache cache = null) + { + cache ??= new PackageCache(); + var skelMesh = export.GetProperty("SkeletalMesh").ResolveToExport(export.FileRef, cache); + var materials = export.GetProperty>("Materials")?.Select(x => x.ResolveToExport(export.FileRef, cache)) ?? []; + return ToIntermediateMesh(skelMesh.GetBinaryData(), materialSetting, [.. materials]); + } + private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh, MaterialExportLevel materialSetting) { var intermediateMesh = new IntermediateMesh() @@ -99,16 +150,20 @@ private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh, MaterialExpo } // Collision mesh - var collisionMeshGeometry = mesh.GetCollisionMeshProperty(mesh.Export.FileRef); - - if (collisionMeshGeometry != null) + // disabling until I implement a way to import it back in + if (ExportCollision) { - intermediateMesh.CollisionMeshElements = []; - if (collisionMeshGeometry?.GetProp>("ConvexElems") is ArrayProperty convexElems) + var collisionMeshGeometry = mesh.GetCollisionMeshProperty(mesh.Export.FileRef); + + if (collisionMeshGeometry != null) { - foreach (StructProperty convexElem in convexElems) + intermediateMesh.CollisionMeshElements = []; + if (collisionMeshGeometry?.GetProp>("ConvexElems") is ArrayProperty convexElems) { - intermediateMesh.CollisionMeshElements.Add(ToIntermediateCollision(convexElem)); + foreach (StructProperty convexElem in convexElems) + { + intermediateMesh.CollisionMeshElements.Add(ToIntermediateCollision(convexElem)); + } } } } @@ -217,24 +272,39 @@ private static IntermediateMaterial ToIntermediateMaterial(IEntry material, Mate { material = EntryImporter.ResolveImport(imp, new PackageCache()); } - FindBestDiffAndNormForMaterial(intermediateMat, material as ExportEntry); + MaterialHelper.FindBestDiffAndNormForMaterial(material as ExportEntry, out var diff, out var norm); + if (diff != null) + { + intermediateMat.DiffTexture = new Texture2D(diff); + } + if (norm != null) + { + intermediateMat.NormalTexture = new Texture2D(norm); + } + var baseMat = (material as ExportEntry).GetBaseMaterial(); + var twoSidedProp = baseMat?.GetProperty("TwoSided"); + if (twoSidedProp != null && twoSidedProp.Value) + { + intermediateMat.TwoSided = true; + } } } return intermediateMat; } - private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh, MaterialExportLevel materialSetting) + private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh, MaterialExportLevel materialSetting, ExportEntry[] overrideMaterials = null) { + overrideMaterials ??= []; var intermediateMesh = new IntermediateMesh() { Name = mesh.Export.ObjectName.Instanced }; // materials - foreach (var mat in mesh.Materials) + for (int i = 0; i < mesh.Materials.Length; i++) { - var intermediateMat = ToIntermediateMaterial(mesh.Export.FileRef.GetEntry(mat), materialSetting); - intermediateMesh.Materials.Add(intermediateMat); + var mat = overrideMaterials.Length > i ? overrideMaterials[i] : mesh.Export.FileRef.GetEntry(mesh.Materials[i]); + intermediateMesh.Materials.Add(ToIntermediateMaterial(mat, materialSetting)); } // skeleton @@ -373,228 +443,234 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, return intermediateLod; } - private static ModelRoot ToGltf(IntermediateMesh mesh, string versionInfo = null) + private static ModelRoot ToGltf(IEnumerable meshes, string versionInfo = null) { var scene = new SceneBuilder(); - // Make a root node that all of the LODs will be under. This matches Blender's export of skeletal meshes and makes it easier to group them back up - var containerNode = new NodeBuilder(mesh.Name); - scene.AddNode(containerNode); + Dictionary skeletonMap = []; - // Materials - List mats = []; - foreach (var intermediateMat in mesh.Materials) + foreach (var mesh in meshes) { - var mat = new MaterialBuilder(intermediateMat.Name); - mat.WithDoubleSide(intermediateMat.TwoSided); - if (intermediateMat.DiffTexture != null) - { - var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); - var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); - diffImage.AlternateWriteFileName = $"{intermediateMat.DiffTexture.Export.ObjectNameString}.*"; - mat.WithBaseColor(diffImage); - } - if (intermediateMat.NormalTexture != null) + // Make a root node that all of the LODs will be under. This matches Blender's export of skeletal meshes and makes it easier to group them back up + var containerNode = new NodeBuilder(mesh.Name); + scene.AddNode(containerNode); + + // Materials + List mats = []; + foreach (var intermediateMat in mesh.Materials) { - var normalMapBytes = intermediateMat.NormalTexture.GetPNG(intermediateMat.NormalTexture.GetTopMip()); - // flip the green channel to match the convention glTF uses - var img = IsImage.Load(normalMapBytes); - var colorMatrix = new ColorMatrix( - 1, 0, 0, 0, - 0, -1, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - 0, 1, 0, 0 - ); - img.Mutate(x => x.ApplyProcessor(new FilterProcessor(colorMatrix))); - using (var ms = new MemoryStream()) + var mat = new MaterialBuilder(intermediateMat.Name); + mat.WithDoubleSide(intermediateMat.TwoSided); + if (intermediateMat.DiffTexture != null) { - img.SaveAsPng(ms); - normalMapBytes = ms.ToArray(); + var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); + var diffImage = ImageBuilder.From(imageBytes, intermediateMat.DiffTexture.Export.ObjectNameString); + diffImage.AlternateWriteFileName = $"{intermediateMat.DiffTexture.Export.ObjectNameString}.*"; + mat.WithBaseColor(diffImage); } - var normImage = ImageBuilder.From(normalMapBytes, $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped"); - normImage.AlternateWriteFileName = $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped.*"; - mat.WithNormal(normImage); + if (intermediateMat.NormalTexture != null) + { + var normalMapBytes = intermediateMat.NormalTexture.GetPNG(intermediateMat.NormalTexture.GetTopMip()); + // flip the green channel to match the convention glTF uses + var img = IsImage.Load(normalMapBytes); + var colorMatrix = new ColorMatrix( + 1, 0, 0, 0, + 0, -1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + 0, 1, 0, 0 + ); + img.Mutate(x => x.ApplyProcessor(new FilterProcessor(colorMatrix))); + using (var ms = new MemoryStream()) + { + img.SaveAsPng(ms); + normalMapBytes = ms.ToArray(); + } + var normImage = ImageBuilder.From(normalMapBytes, $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped"); + normImage.AlternateWriteFileName = $"{intermediateMat.NormalTexture.Export.ObjectNameString}_flipped.*"; + mat.WithNormal(normImage); + } + mats.Add(mat); } - mats.Add(mat); - } - // LODs - foreach (var lod in mesh.LODs) - { - var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD{lod.Index}"; - // SkeletalMesh version - if (mesh.Skeleton != null) + // LODs + foreach (var lod in mesh.LODs) { - var mb = new MeshBuilder(name); - foreach (var section in lod.Sections) + var name = lod.Index == 0 ? mesh.Name : $"{mesh.Name}_LOD{lod.Index}"; + // SkeletalMesh version + if (mesh.Skeleton != null) { - var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); - var vertexBuilders = new VertexBuilder?[section.Vertices.Count]; - foreach (var tri in section.Triangles) + var mb = new MeshBuilder(name); + foreach (var section in lod.Sections) { - VertexBuilder GetVert(int i) + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + var vertexBuilders = new VertexBuilder?[section.Vertices.Count]; + foreach (var tri in section.Triangles) { - if (!vertexBuilders[i].HasValue) + VertexBuilder GetVert(int i) { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry( - TransformVertexPositionToGltf(intermediateVert.Position), - TransformDirectionToGltf(intermediateVert.Normal.Value), - new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]) - .WithSkinning(intermediateVert.Influences); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - vertexBuilders[i] = vb; + if (!vertexBuilders[i].HasValue) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPositionToGltf(intermediateVert.Position), + TransformDirectionToGltf(intermediateVert.Normal.Value), + new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]) + .WithSkinning(intermediateVert.Influences); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + vertexBuilders[i] = vb; + } + return vertexBuilders[i].Value; } - return vertexBuilders[i].Value; + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } + var meshNode = new NodeBuilder(); + containerNode.AddNode(meshNode); + var rigidMesh = scene.AddRigidMesh(mb, meshNode); + rigidMesh.WithName(name); } - var meshNode = new NodeBuilder(); - containerNode.AddNode(meshNode); - var rigidMesh = scene.AddRigidMesh(mb, meshNode); - rigidMesh.WithName(name); - } - // StaticMesh version - else - { - var mb = new MeshBuilder(name); - - foreach (var section in lod.Sections) + // StaticMesh version + else { - var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); - var vertexBuilders = new VertexBuilder?[section.Vertices.Count]; - foreach (var tri in section.Triangles) + var mb = new MeshBuilder(name); + + foreach (var section in lod.Sections) { - VertexBuilder GetVert(int i) + var primitive = mb.UsePrimitive(mats[section.MaterialIndex]); + var vertexBuilders = new VertexBuilder?[section.Vertices.Count]; + foreach (var tri in section.Triangles) { - if (!vertexBuilders[i].HasValue) + VertexBuilder GetVert(int i) { - var intermediateVert = section.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry( - TransformVertexPositionToGltf(intermediateVert.Position), - TransformDirectionToGltf(intermediateVert.Normal.Value), - new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) - .WithMaterial([.. intermediateVert.UVs]); - vb.Material.OriginalIndex = intermediateVert.OriginalIndex; - vertexBuilders[i] = vb; + if (!vertexBuilders[i].HasValue) + { + var intermediateVert = section.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry( + TransformVertexPositionToGltf(intermediateVert.Position), + TransformDirectionToGltf(intermediateVert.Normal.Value), + new Vector4(TransformDirectionToGltf(intermediateVert.Tangent.Value), intermediateVert.BiTangentDirection)) + .WithMaterial([.. intermediateVert.UVs]); + vb.Material.OriginalIndex = intermediateVert.OriginalIndex; + vertexBuilders[i] = vb; + } + return vertexBuilders[i].Value; } - return vertexBuilders[i].Value; + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } + var meshNode = new NodeBuilder(); + containerNode.AddNode(meshNode); + var rigidMesh = scene.AddRigidMesh(mb, meshNode); + rigidMesh.WithName(name); } - var meshNode = new NodeBuilder(); - containerNode.AddNode(meshNode); - var rigidMesh = scene.AddRigidMesh(mb, meshNode); - rigidMesh.WithName(name); } - } - if (mesh.CollisionMeshElements != null) - { - var collisionMat = new MaterialBuilder("CollisionMaterial"); - - for (int i = 0; i < mesh.CollisionMeshElements.Count; i++) + if (mesh.CollisionMeshElements != null) { - var name = $"{mesh.Name}_Collision_{i}"; - var collisionElement = mesh.CollisionMeshElements[i]; + var collisionMat = new MaterialBuilder("CollisionMaterial"); - var mb = new MeshBuilder(name); - var primitive = mb.UsePrimitive(collisionMat); - - foreach (var tri in collisionElement.Triangles) + for (int i = 0; i < mesh.CollisionMeshElements.Count; i++) { - VertexBuilder GetVert(int i) + var name = $"{mesh.Name}_Collision_{i}"; + var collisionElement = mesh.CollisionMeshElements[i]; + + var mb = new MeshBuilder(name); + var primitive = mb.UsePrimitive(collisionMat); + + foreach (var tri in collisionElement.Triangles) { - var intermediateVert = collisionElement.Vertices[i]; - var vb = new VertexBuilder() - .WithGeometry(intermediateVert / ScaleFactor); - return vb; + VertexBuilder GetVert(int i) + { + var intermediateVert = collisionElement.Vertices[i]; + var vb = new VertexBuilder() + .WithGeometry(intermediateVert / ScaleFactor); + return vb; + } + primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); } - primitive.AddTriangle(GetVert(tri.VertIndex1), GetVert(tri.VertIndex2), GetVert(tri.VertIndex3)); - } - - var meshNode = new NodeBuilder(); - containerNode.AddNode(meshNode); - var rigidMesh = scene.AddRigidMesh(mb, meshNode); - rigidMesh.WithName(name); - } - } - // skeleton/sockets - NodeBuilder[] skeletonNodes = []; - if (mesh.Skeleton != null) - { - skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; - // one pass to create all the nodes without the hierarchy - for (int i = 0; i < mesh.Skeleton.Count; i++) - { - var bone = mesh.Skeleton[i]; - var nb = new NodeBuilder(bone.Name); - if (bone.ParentIndex == -1 || bone.ParentIndex == i) - { - // this is a root bone; change the local transform to account for the coordiante system differences - nb.WithLocalTranslation(TransformRootBonePositionToGltf(bone.Position)) - .WithLocalRotation(TransformRootBoneRotationToGltf(bone.Rotation)); - containerNode.AddNode(nb); + var meshNode = new NodeBuilder(); + containerNode.AddNode(meshNode); + var rigidMesh = scene.AddRigidMesh(mb, meshNode); + rigidMesh.WithName(name); } - else - { - nb.WithLocalTranslation(TransformBonePositionToGltf(bone.Position)) - .WithLocalRotation(TransformBoneRotationToGltf(bone.Rotation)); - } - skeletonNodes[i] = nb; } - // another pass to connect the hierarchy up - for (int i = 0; i < mesh.Skeleton.Count; i++) + + // skeleton/sockets + if (mesh.Skeleton != null) { - var bone = mesh.Skeleton[i]; - var nb = skeletonNodes[i]; - if (bone.ParentIndex == -1 || bone.ParentIndex == i) + NodeBuilder[] skeletonNodes = new NodeBuilder[mesh.Skeleton.Count]; + // one pass to create all the nodes without the hierarchy + for (int i = 0; i < mesh.Skeleton.Count; i++) { - // this is a root bone; we don't need to do anything here - continue; + var bone = mesh.Skeleton[i]; + var nb = new NodeBuilder(bone.Name); + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; change the local transform to account for the coordiante system differences + nb.WithLocalTranslation(TransformRootBonePositionToGltf(bone.Position)) + .WithLocalRotation(TransformRootBoneRotationToGltf(bone.Rotation)); + containerNode.AddNode(nb); + } + else + { + nb.WithLocalTranslation(TransformBonePositionToGltf(bone.Position)) + .WithLocalRotation(TransformBoneRotationToGltf(bone.Rotation)); + } + skeletonNodes[i] = nb; } - else + // another pass to connect the hierarchy up + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var parent = skeletonNodes[bone.ParentIndex]; - parent.AddNode(nb); + var bone = mesh.Skeleton[i]; + var nb = skeletonNodes[i]; + if (bone.ParentIndex == -1 || bone.ParentIndex == i) + { + // this is a root bone; we don't need to do anything here + continue; + } + else + { + var parent = skeletonNodes[bone.ParentIndex]; + parent.AddNode(nb); + } } - } - // finish sockets by creating nodes under the bones they are attached to - for (int i = 0; i < mesh.Skeleton.Count; i++) - { - var nb = skeletonNodes[i]; - var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); - foreach (var socket in sockets) + // finish sockets by creating nodes under the bones they are attached to + for (int i = 0; i < mesh.Skeleton.Count; i++) { - var socketBuilder = new NodeBuilder(socket.Name) - .WithLocalTranslation(TransformBonePositionToGltf(socket.RelativeLocation)) - .WithLocalRotation(TransformSocketRotationToGltf(socket.RelativeRotation)) - .WithLocalScale(TransformScaleToGltf(socket.RelativeScale)); - nb.AddNode(socketBuilder); + var nb = skeletonNodes[i]; + var sockets = mesh.Sockets.FindAll(x => x.Bone == nb.Name); + foreach (var socket in sockets) + { + var socketBuilder = new NodeBuilder(socket.Name) + .WithLocalTranslation(TransformBonePositionToGltf(socket.RelativeLocation)) + .WithLocalRotation(TransformSocketRotationToGltf(socket.RelativeRotation)) + .WithLocalScale(TransformScaleToGltf(socket.RelativeScale)); + nb.AddNode(socketBuilder); + } } + skeletonMap.Add(mesh, skeletonNodes); } } var gltf = scene.ToGltf2(); gltf.Asset.Generator = $"{versionInfo ?? "Legendary Explorer Core"}"; - // collect the real nodes for the skeleton, in the exact same order - var jointNodes = skeletonNodes.Select(x => gltf.LogicalNodes.First(y => y.Name == x.Name)).ToArray(); - - if (mesh.Skeleton != null && mesh.Skeleton.Count > 0) + foreach (var (mesh, skeletonNodes) in skeletonMap) { + // find the root node of this mesh + var rootNode = gltf.LogicalNodes.First(node => node.Name == mesh.Name); + // collect the real nodes for the skeleton, in the exact same order + var jointNodes = skeletonNodes.Select(x => rootNode.FindNode(y => y.Name == x.Name)).ToArray(); // manually create the skin and then connect it up to the nodes containing the meshes var skin = gltf.CreateSkin(mesh.Name); skin.BindJoints(Matrix4x4.Identity, jointNodes); - foreach (var node in gltf.LogicalNodes) + foreach (var node in rootNode.VisualChildren) { if (node.Mesh != null) { @@ -621,14 +697,11 @@ private static Vector3 TransformBonePositionToGltf(Vector3 input) return new Vector3(input.X, -input.Y, input.Z) / ScaleFactor; } - // sqrt(2)/2 comes up repeatedly in 90 degree quaternion rotations - private static readonly float QuatHalf = (float)(Math.Sqrt(2) / 2); - private static Quaternion TransformRootBoneRotationToGltf(Quaternion input) { // add a -90 degree rotation around the x axis - var transform = new Quaternion(QuatHalf, 0, 0, -QuatHalf); + var transform = new Quaternion(Root2Over2, 0, 0, -Root2Over2); return Quaternion.Normalize(transform * input); } @@ -647,7 +720,7 @@ private static Quaternion TransformSocketRotationToGltf(Quaternion input) // fix coordinate system differences var temp = new Quaternion(input.X, -input.Z, input.Y, input.W); // add a 90 degree rotation - temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + temp = new Quaternion(Root2Over2, 0, 0, Root2Over2) * temp; return Quaternion.Normalize(temp); } @@ -656,9 +729,9 @@ private static Quaternion TransformBoneRotationToGltf(Quaternion input) // first, get it into the form glTF expects due to the swapped axes var temp = new Quaternion(input.X, input.Z, input.Y, -input.W); // next, we undo the rotation introduced by the parent - temp = new Quaternion(QuatHalf, 0, 0, QuatHalf) * temp; + temp = new Quaternion(Root2Over2, 0, 0, Root2Over2) * temp; // finally, we rotate the child in its local axes - temp = temp * new Quaternion(QuatHalf, 0, 0, -QuatHalf); + temp = temp * new Quaternion(Root2Over2, 0, 0, -Root2Over2); return Quaternion.Normalize(temp); } @@ -688,15 +761,10 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry // TODO show a warning or something return; } - // if there are both skeletal and static meshes in this, you can't have anything selected - // covered by the below - //if (skeletalMeshes.Any() && staticMeshes.Any() && existingMesh != null) - //{ - // throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh and it will import all your meshes as new meshes."); - //} if (totalMeshes > 1 && existingMesh != null) { // TODO add support for this? + // ideally it would pop a dialog to choose which one to import throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh and it will import all your meshes as new meshes."); } // if you are trying to replace a skeletal mesh but you are importing static (or vice versa) count it as nothing selected (not a compatible export selected) @@ -725,7 +793,7 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry private static IEntry GetMaterialEntry(IMEPackage package, string materialName) { // first, look for it by full memory path, which is how it exports as gltf - var entry = FindEntryByMemeroryFullPath(package, materialName, "MaterialInterface"); + var entry = package.FindEntryByMemeroryFullPath(materialName, "MaterialInterface"); // fall back to looking for it by just the last segment for compatibility with stuff exported as psk entry ??= package.Exports.FirstOrDefault(x => x.ObjectName == materialName && x.IsA("MaterialInterface")); entry ??= package.Imports.FirstOrDefault(x => x.ObjectName == materialName && x.IsA("MaterialInterface")); @@ -741,6 +809,7 @@ private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) // fill in data that was not provided on import CalculateNormalsIfNeeded(lod); CalculateTangentIfNeeded(lod); + // reorder the vertices to match the original order, if possible ReconstructVertexOrder(lod); // make the data much easier to process MergeVertexLists(lod); @@ -1392,7 +1461,7 @@ private static Vector3 TransformSocketLocationFromGltf(Vector3 input) } private static Quaternion TransformSocketRotationFromGltf(Quaternion input) { - var temp = new Quaternion(-QuatHalf, 0, 0, QuatHalf) * input; + var temp = new Quaternion(-Root2Over2, 0, 0, Root2Over2) * input; return new Quaternion(temp.X, temp.Z, -temp.Y, temp.W); } private static Vector3 TransformScaleFromGltf(Vector3 input) @@ -1407,28 +1476,24 @@ private static Vector3 TransformBonePositionFromGltf(Vector3 input) { return new Vector3(input.X, -input.Y, input.Z) * ScaleFactor; } - private static Vector3 TransformVertexPositionFromGltf(Vector3 input) { return new Vector3(input.X, input.Z, input.Y) * ScaleFactor; } - private static Quaternion TransformRootBoneRotationFromGltf(Quaternion input) { // add a 90 degree rotation around the x axis - var transform = new Quaternion(QuatHalf, 0, 0, QuatHalf); + var transform = new Quaternion(Root2Over2, 0, 0, Root2Over2); return Quaternion.Normalize(transform * input); } - private static Vector3 TransformRootBonePositionFromGltf(Vector3 input) { return new Vector3(input.X, input.Z, input.Y) * ScaleFactor; } - private static Quaternion TransformBoneRotationFromGltf(Quaternion input) { - var temp = input * new Quaternion(QuatHalf, 0, 0, QuatHalf); - temp = new Quaternion(QuatHalf, 0, 0, -QuatHalf) * temp; + var temp = input * new Quaternion(Root2Over2, 0, 0, Root2Over2); + temp = new Quaternion(Root2Over2, 0, 0, -Root2Over2) * temp; temp = new Quaternion(temp.X, temp.Z, temp.Y, -temp.W); return Quaternion.Normalize(temp); @@ -1505,7 +1570,7 @@ StaticMeshRenderData GetLod(IntermediateLOD intermediateLod) bUseFullPrecisionUVs = false, NumTexCoords = (uint)numTexCoords, NumVertices = (uint)intermediateVerts.Count, - Stride = 16, // TODO I bet this depends on the number of UVs and also maybe the precision used + Stride = 16, // TODO does this depends on the number of UVs and also maybe the precision used? VertexData = [..intermediateVerts.Select(x => { var packedNorm = (PackedNormal)Vector3.Normalize(x.Normal.Value); @@ -1549,9 +1614,20 @@ StaticMeshRenderData GetLod(IntermediateLOD intermediateLod) existingEntry ??= ExportCreator.CreateExport(package, intermediateMesh.Name, "StaticMesh"); + var existingBodySetup = existingEntry.GetProperty("BodySetup"); + if (existingBodySetup != null) + { + staticMesh.BodySetup = existingBodySetup.Value; + } + else + { + existingEntry.WriteProperty(new BoolProperty(true, "UseSimpleBoxCollision")); + } + + // TODO generate the body setup stuff so we can import collision + existingEntry.WriteBinary(staticMesh); - // TODO properties, mostly rigidBody return existingEntry; } @@ -2129,52 +2205,6 @@ private class IntermediateSocket } #endregion - - private static void FindBestDiffAndNormForMaterial(IntermediateMaterial mat, ExportEntry matEntry) - { - // TODO hardcode in what params to look for for specific known materials to avoid the stupid gold bars texture, among other things. - PackageEditorExperimentsSquid.GetMaterialTextures(matEntry, out var textures, out var baseTextures); - foreach (var (param, tex) in textures) - { - // don't look at the params, it'll pull in things like teeth diff for the scalp which are not what you want - if (/*param.Contains("Diff", StringComparison.InvariantCultureIgnoreCase) || */tex.ObjectName.ToString().Contains("Diff", StringComparison.InvariantCultureIgnoreCase)) - { - mat.DiffTexture ??= new Texture2D(tex); - } - else if (/*param.Contains("Norm", StringComparison.InvariantCultureIgnoreCase) ||*/ tex.ObjectName.ToString().Contains("Norm", StringComparison.InvariantCultureIgnoreCase)) - { - mat.NormalTexture ??= new Texture2D(tex); - } - } - foreach (var tex in baseTextures) - { - if (tex.ObjectName.ToString().Contains("Diff", StringComparison.InvariantCultureIgnoreCase)) - { - mat.DiffTexture ??= new Texture2D(tex); - } - else if (tex.ObjectName.ToString().Contains("Norm", StringComparison.InvariantCultureIgnoreCase)) - { - mat.NormalTexture ??= new Texture2D(tex); - } - } - } - - // TODO this is probably broadly useful and could live somewhere else as an extension method - public static IEntry FindEntryByMemeroryFullPath(IMEPackage pachage, string memoryFullPath, string className = null) - { - foreach (IEntry entry in pachage.Exports.Concat(pachage.Imports)) - { - if (entry.MemoryFullPath.CaseInsensitiveEquals(memoryFullPath)) - { - if (className != null && !(entry.ClassName.CaseInsensitiveEquals(className) || entry.IsA(className))) - { - continue; - } - return entry; - } - } - return null; - } } // A custom Vertex Type for SharpGltf so we can have a variable number of UVs, plus attach custom metadata about the original index of each vertex to reconstruct the order on import From adf7c0763f81825b0c0fff94ef1c54136132e10a Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Mon, 2 Feb 2026 17:09:03 -0500 Subject: [PATCH 29/34] Added support for generating new normals and tangents, if needed. Normals are exported by default from Blender and I recommend using those, but the results of this algorithm are accetable. --- .../LegendaryExplorerCore/Unreal/GLTF.cs | 101 ++++-------------- 1 file changed, 18 insertions(+), 83 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs index 0e05fc428..ae548cb6a 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs @@ -10,6 +10,7 @@ using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Materials; using SharpGLTF.Memory; +using SharpGLTF.Runtime; using SharpGLTF.Scenes; using SharpGLTF.Schema2; using SixLabors.ImageSharp; @@ -765,7 +766,7 @@ public static void ConvertGltfToMesh(ModelRoot gltf, IMEPackage pcc, ExportEntry { // TODO add support for this? // ideally it would pop a dialog to choose which one to import - throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh and it will import all your meshes as new meshes."); + throw new NotImplementedException("The file you are trying to import contains more than one mesh, but you are trying to replace a mesh. try again adding as a new mesh (select something that is not a mesh) and it will import all your meshes as new meshes."); } // if you are trying to replace a skeletal mesh but you are importing static (or vice versa) count it as nothing selected (not a compatible export selected) if (skeletalMeshes.Count == 1 && existingMesh != null && existingMesh.ClassName == "StaticMesh") @@ -806,9 +807,6 @@ private static IntermediateMesh CleanUpIntermediateMesh(IntermediateMesh mesh) // TODO make sure everything is normalized properly? foreach (var lod in mesh.LODs) { - // fill in data that was not provided on import - CalculateNormalsIfNeeded(lod); - CalculateTangentIfNeeded(lod); // reorder the vertices to match the original order, if possible ReconstructVertexOrder(lod); // make the data much easier to process @@ -853,68 +851,6 @@ private static void MergeVertexLists(IntermediateLOD lod) } } - private static void CalculateNormalsIfNeeded(IntermediateLOD lod) - { - // if we do not already have normals - if (lod.Sections[0].Vertices[0].Normal == null) - { - // TODO is this worth doing? without some welding, it is going to have seams all over the place. I could just enforce that you include them in the export, even if they are - // just autogenerated by Blender. It will do a better job and can take into account merged vertices that will get duplicated by the glTF export. - throw new NotImplementedException("You need to export your model with normals for now. I have not implemented generating them"); - } - // If the normals are not present already, calculate them here by averaging the normals of the faces containing each vertex, weighted by the angle containing that vertex, so as not to introduce artifacts due to triangulation - //if (psk.VertexNormals == null || psk.VertexNormals.Count == 0) - //{ - // // things we need per triangle: - // // normal vector - // // point index/angle pairs - // float GetAngle(Vector3 p0, Vector3 p1, Vector3 p2) - // { - // var dot = Vector3.Dot(p1 - p0, p2 - p0); - // var m1 = Vector3.Distance(p0, p1); - // var m2 = Vector3.Distance(p0, p2); - // var temp = dot / (m1 * m2); - // return (float)Math.Acos(temp); - // } - - // // need to calculate the normal per face - // // need to group faces by point index, but with dupes - // var summedNormals = new Vector3[psk.Points.Count]; - // foreach (var face in psk.Faces) - // { - // // point index of each vertex of the triangle - // var i0 = psk.Wedges[face.WedgeIdx0].PointIndex; - // var i1 = psk.Wedges[face.WedgeIdx1].PointIndex; - // var i2 = psk.Wedges[face.WedgeIdx2].PointIndex; - // // position of each vertex of the triangle - // var p0 = psk.Points[i0]; - // var p1 = psk.Points[i1]; - // var p2 = psk.Points[i2]; - - // // angle (in rad) of each angle of the triangle by point it contains - // var a0 = GetAngle(p0, p1, p2); - // var a1 = GetAngle(p1, p0, p2); - // var a2 = GetAngle(p2, p1, p0); - - // var faceNormal = Vector3.Normalize(Vector3.Cross(p2 - p0, p1 - p0)); - - // // accumulate the face normals for each point, weighted by the angle - // summedNormals[i0] += faceNormal * a0; - // summedNormals[i1] += faceNormal * a1; - // summedNormals[i2] += faceNormal * a2; - // } - // psk.VertexNormals = [.. summedNormals.Select(x => Vector3.Normalize(x))]; - //} - } - - private static void CalculateTangentIfNeeded(IntermediateLOD lod) - { - if (lod.Sections[0].Vertices[0].Tangent == null) - { - throw new NotImplementedException("You need to export your model with tangents for now. I have not implemented generating them"); - } - } - private static List GetAllVertices(IntermediateLOD lod) { var vertices = lod.Sections[0].Vertices; @@ -1156,7 +1092,6 @@ private static IntermediateMesh ToIntermediateMesh(string meshName, Node[] nodes meshName = CleanupName(meshName); SortNodes(meshName, nodes, out var lodNodes, out var collisionNodes); - // TODO sort this into groups of LODs vs collision components var skeletalMesh = lodNodes[0].Skin != null; int lodIndex = 0; int boneIndex = 0; @@ -1251,6 +1186,9 @@ Node[] FindSockets(Node joint) foreach (var node in lodNodes) { + // this API allows us to generate normals and tangents if needed + var decoder = node.Mesh.Decode(); + var LOD = new IntermediateLOD() { Index = lodIndex++ }; var sharedAccessors = DoesMeshUseSharedVertexAccessors(node.Mesh); @@ -1258,10 +1196,10 @@ Node[] FindSockets(Node joint) List sharedVerts = null; if (sharedAccessors) { - sharedVerts = GetPrimativeVertices(node.Mesh.Primitives[0]); + sharedVerts = GetPrimativeVertices(node.Mesh.Primitives[0], decoder.Primitives[0]); } - List GetPrimativeVertices(MeshPrimitive prim) + List GetPrimativeVertices(MeshPrimitive prim, IMeshPrimitiveDecoder primitiveDecoder) { List verts = []; @@ -1282,20 +1220,16 @@ List GetPrimativeVertices(MeshPrimitive prim) }; // Normals - // usually present, but not required - if (vertColumns.Normals != null) - { - vert.Normal = TranformDirectionFromGltf(vertColumns.Normals[i]); - } + // get the value it was exported with or the generated one if that is not present + vert.Normal = TranformDirectionFromGltf(vertColumns.Normals?[i] ?? primitiveDecoder.GetNormal(i)); // Tangents - // not required, but will be imported if present, calculated otherwise - if (vertColumns.Tangents != null) - { - var tanX = new Vector3(vertColumns.Tangents[i].X, vertColumns.Tangents[i].Y, vertColumns.Tangents[i].Z); - vert.Tangent = TranformDirectionFromGltf(tanX); - vert.BiTangentDirection = vertColumns.Tangents[i].W; - } + // get the value it was imported with, or the generated one if nopresent + var rawTangent = vertColumns.Tangents?[i] ?? primitiveDecoder.GetTangent(i); + var tanX = new Vector3(rawTangent.X, rawTangent.Y, rawTangent.Z); + vert.Tangent = TranformDirectionFromGltf(tanX); + vert.BiTangentDirection = rawTangent.W; + // UVs void AddUV(IList? column) @@ -1342,8 +1276,9 @@ void AddWeights(IList jointsList, IList weightsList) } // a primitive, for our uses, will roughly correspond to a material // technically it corresponds to a GPU rendering pass, which can be other things, but is most likely to be a material for us. - foreach (var prim in node.Mesh.Primitives) + for (var i = 0; i < node.Mesh.Primitives.Count; i++) { + var prim = node.Mesh.Primitives[i]; switch (prim.DrawPrimitiveType) { // we do not support points or lines outside the context of a triangle; ignore these if they come up, which is unlikely @@ -1369,7 +1304,7 @@ void AddWeights(IList jointsList, IList weightsList) { MaterialIndex = meshMatIndex, // use the shared vertices, if available - Vertices = sharedVerts ?? GetPrimativeVertices(prim) + Vertices = sharedVerts ?? GetPrimativeVertices(prim, decoder.Primitives[i]) }; // this gets us a list of int triplets; the indices of each triangle From ce1966a9dab0ac6cd28de21e67b4d201f91d3764 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Wed, 4 Feb 2026 16:12:05 -0500 Subject: [PATCH 30/34] improved error handling, fixed some bugs in material texture lookup, added special cases for some materials that fail with the default handling, picking the wrong diff. --- .../PackageEditorExperimentsSquid.cs | 16 ++-- .../Helpers/MaterialHelper.cs | 42 +++++++-- .../LegendaryExplorerCore/Unreal/GLTF.cs | 89 +++++++++++++------ 3 files changed, 106 insertions(+), 41 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 2fb8b57dd..7cbf851db 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -120,16 +120,19 @@ public static void ExportMeshToGltf(PackageEditorWindow pew, GLTF.MaterialExport if (pew.Pcc.Game == MEGame.ME1) { ShowError("This experiment does not yet support OT1; if you must do this, port it to another game first"); + return; } if (pew.Pcc.Game == MEGame.UDK) { ShowError("This experiment does not support UDK files;"); + return; } if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh", "SkeletalMeshComponent", "BioPawn"], out var export)) { if (export.ClassName == "StaticMesh" && !(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) { ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + return; } var d = new SaveFileDialog { Filter = "glTF binary|*.glb|glTF|*.glTF", FileName = $"{pew.SelectedItem.Entry.ObjectName.Instanced}.glb"}; if (d.ShowDialog() == true) @@ -3112,14 +3115,15 @@ private static bool GetSelectedItem(PackageEditorWindow pew, string[] expectedTy entry = null; if (pew.SelectedItem == null || pew.SelectedItem.Entry == null || pew.Pcc == null) { return false; } - if (!expectedTypes.Contains(pew.SelectedItem.Entry.ClassName)) + foreach (var expectedType in expectedTypes) { - return false; + if (pew.SelectedItem.Entry.IsA(expectedType)) + { + entry = (ExportEntry)pew.SelectedItem.Entry; + return entry != null; + } } - - entry = (ExportEntry)pew.SelectedItem.Entry; - - return entry != null; + return false; } } } diff --git a/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs b/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs index e3ba20210..d2f3f669c 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Helpers/MaterialHelper.cs @@ -1,4 +1,5 @@ using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Packages.CloningImportingAndRelinking; using LegendaryExplorerCore.Unreal; using LegendaryExplorerCore.Unreal.BinaryConverters; using LegendaryExplorerCore.Unreal.ObjectInfo; @@ -115,16 +116,25 @@ void ExportBaseMaterialTextures(ExportEntry baseMatEntry, bool includeUniformExp { foreach (var texIdx in matBin.SM3MaterialResource.UniformExpressionTextures) { - if (baseMatEntry.FileRef.TryGetUExport(texIdx, out var tex)) + if (baseMatEntry.FileRef.TryGetEntry(texIdx, out var texEntry)) { + ExportEntry texExport; // skip the really dumb textures - if (tex.ObjectNameString.StartsWith("GBL_ARM_ALL")) + if (texEntry.ObjectNameString.StartsWith("GBL_ARM_ALL")) { continue; } - if (tex.IsA("Texture2D")) + if (texEntry is ImportEntry importTex) { - tempBaseTextures.Add(tex); + EntryImporter.TryResolveImport(importTex, out texExport); + } + else + { + texExport = texEntry as ExportEntry; + } + if (texEntry.IsA("Texture2D")) + { + tempBaseTextures.Add(texExport); } } } @@ -160,9 +170,9 @@ public static IReadOnlyDictionary GetMaterialTextures(this public static IReadOnlyDictionary GetMaterialTextures(this ExportEntry materialExport, out IReadOnlyList baseTextures) { - var baseTextureList = new List(); + var paramTextures = GetMaterialTextures(materialExport, out var baseTextureList, true); baseTextures = baseTextureList; - return GetMaterialTextures(materialExport, out baseTextureList, true); + return paramTextures; } public static ExportEntry GetBaseMaterial(this ExportEntry materialExport, PackageCache cache = null) @@ -198,14 +208,28 @@ public static void FindBestDiffAndNormForMaterial(ExportEntry material, out Expo case "HMN_HED_NPC_Scalp_Mat_1a": paramTextures.TryGetValue("HED_Scalp_Diff", out diffuseTexture); paramTextures.TryGetValue("HED_Scalp_Norm", out normalTexture); - break; + return; case "HMM_CTH_MASTER_MAT": paramTextures.TryGetValue("HMM_ARM_ALL_Diff_Stack", out diffuseTexture); paramTextures.TryGetValue("HMM_ARM_ALL_Norm_Stack", out normalTexture); - break; + return; case "HMN_HED_LASH_Unlit_MASTER_MAT": paramTextures.TryGetValue("HED_Lash_Diff", out diffuseTexture); - break; + return; + case "ElevatorConsole02_Material01": + diffuseTexture = baseTextures[2]; + normalTexture = baseTextures[0]; + return; + case "BIOG_SOV_INGAME_MASTER_A_MAT": + case "BIOG_SOV_INGAME_MASTER_MAT": + diffuseTexture = baseTextures[6]; + normalTexture = baseTextures[0]; + return; + case "BIOG_SOV_Damage_Master_Mat": + diffuseTexture = baseTextures[8]; + normalTexture = baseTextures[0]; + return; + // consider Galaxy_Holo_Mat default: break; } diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs index ae548cb6a..11602f87b 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs @@ -66,22 +66,21 @@ public static void ExportMeshesToGltf(IEnumerable exports, string f List intermediateMeshes = []; foreach (var export in exports) { - switch (export.ClassName) - { - case "SkeletalMesh": - intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); - break; - case "StaticMesh": - intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); - break; - case "SkeletalMeshComponent": - intermediateMeshes.Add(SkeletalMeshComponentToIntermediateMesh(export, materialSetting)); - break; - case "BioPawn": - intermediateMeshes.AddRange(BioPawnToIntermediateMeshes(export, materialSetting)); - break; - default: - break; + if (export.IsA("SkeletalMesh")) + { + intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); + } + else if (export.IsA("StaticMesh")) + { + intermediateMeshes.Add(ToIntermediateMesh(export.GetBinaryData(), materialSetting)); + } + else if (export.IsA("SkeletalMeshComponent")) + { + intermediateMeshes.Add(SkeletalMeshComponentToIntermediateMesh(export, materialSetting)); + } + else if (export.IsA("BioPawn")) + { + intermediateMeshes.AddRange(BioPawnToIntermediateMeshes(export, materialSetting)); } } @@ -93,31 +92,56 @@ private static IEnumerable BioPawnToIntermediateMeshes(ExportE { cache ??= new PackageCache(); var props = export.GetProperties(); - var bodyMesh = props.GetProp("Mesh")?.ResolveToExport(export.FileRef, cache); - var headMesh = props.GetProp("m_oHeadMesh")?.ResolveToExport(export.FileRef, cache); - var hairMesh = props.GetProp("m_oHairMesh").ResolveToExport(export.FileRef, cache); - var headGearMesh = props.GetProp("m_oHeadGearMesh")?.ResolveToExport(export.FileRef, cache); - var visorMesh = props.GetProp("m_oVisorMesh")?.ResolveToExport(export.FileRef, cache); - var faceplateMesh = props.GetProp("m_oFacePlateMesh")?.ResolveToExport(export.FileRef, cache); - var otherMeshes = props.GetProp>("m_aoMeshes").Select(x => x?.ResolveToExport(export.FileRef, cache)); - IEnumerable meshes = [bodyMesh, headMesh, hairMesh, headGearMesh, visorMesh, faceplateMesh, .. otherMeshes]; + ExportEntry GetMeshComponent(string name) + { + return props.GetProp(name)?.ResolveToExport(export.FileRef, cache); + } + ExportEntry[] GetMeshComponentArray(string name) + { + return [..props.GetProp>(name)?.Select(x => x?.ResolveToExport(export.FileRef, cache)) ?? []]; + } + + IEnumerable meshes = [ + GetMeshComponent("Mesh"), + GetMeshComponent("m_oHeadMesh"), + GetMeshComponent("HeadMesh"), + GetMeshComponent("m_oHeadGearMesh"), + GetMeshComponent("HelmetMesh"), + GetMeshComponent("m_oHairMesh"), + GetMeshComponent("m_oVisorMesh"), + GetMeshComponent("m_oFacePlateMesh"), + ..GetMeshComponentArray("m_aoMeshes"), + ..GetMeshComponentArray("m_aoAccessories") + ]; foreach (var meshComponent in meshes.Where(x => x != null).Distinct()) { - yield return SkeletalMeshComponentToIntermediateMesh(meshComponent, materialSetting, cache); + var intermediate = SkeletalMeshComponentToIntermediateMesh(meshComponent, materialSetting, cache); + if (intermediate != null) + { + yield return intermediate; + } } } private static IntermediateMesh SkeletalMeshComponentToIntermediateMesh(ExportEntry export, MaterialExportLevel materialSetting, PackageCache cache = null) { cache ??= new PackageCache(); - var skelMesh = export.GetProperty("SkeletalMesh").ResolveToExport(export.FileRef, cache); + var skelMesh = export.GetProperty("SkeletalMesh")?.ResolveToExport(export.FileRef, cache); + if (skelMesh == null) + { + return null; + } var materials = export.GetProperty>("Materials")?.Select(x => x.ResolveToExport(export.FileRef, cache)) ?? []; return ToIntermediateMesh(skelMesh.GetBinaryData(), materialSetting, [.. materials]); } private static IntermediateMesh ToIntermediateMesh(StaticMesh mesh, MaterialExportLevel materialSetting) { + if (mesh.Export.Game == MEGame.ME1 || mesh.Export.Game == MEGame.ME2 || mesh.Export.Game == MEGame.UDK) + { + throw new NotImplementedException("Exporting static meshes from OT1, OT2, or UDK is not implemented."); + } var intermediateMesh = new IntermediateMesh() { Name = mesh.Export.ObjectName.Instanced @@ -295,6 +319,10 @@ private static IntermediateMaterial ToIntermediateMaterial(IEntry material, Mate private static IntermediateMesh ToIntermediateMesh(SkeletalMesh mesh, MaterialExportLevel materialSetting, ExportEntry[] overrideMaterials = null) { + if (mesh.Export.Game == MEGame.ME1 || mesh.Export.Game == MEGame.UDK) + { + throw new NotImplementedException("Exporting skeletal meshes from OT1 or UDK is not implemented."); + } overrideMaterials ??= []; var intermediateMesh = new IntermediateMesh() { @@ -462,6 +490,7 @@ private static ModelRoot ToGltf(IEnumerable meshes, string ver { var mat = new MaterialBuilder(intermediateMat.Name); mat.WithDoubleSide(intermediateMat.TwoSided); + // TODO the whole texture thing is very slow. Should look into making it faster. if (intermediateMat.DiffTexture != null) { var imageBytes = intermediateMat.DiffTexture.GetPNG(intermediateMat.DiffTexture.GetTopMip()); @@ -1451,6 +1480,10 @@ private static string CleanupName(string name) private static ExportEntry ToStaticMesh(IntermediateMesh intermediateMesh, IMEPackage package, ExportEntry existingEntry) { + if (package.Game == MEGame.ME1 || package.Game == MEGame.ME2 || package.Game == MEGame.UDK) + { + throw new NotImplementedException("Importing static meshes to OT1, OT2, or UDK is not implemented."); + } var staticMesh = new StaticMesh { Bounds = GetBounds(intermediateMesh), @@ -1596,6 +1629,10 @@ private static BoxSphereBounds GetBounds(IntermediateMesh intermediateMesh) private static ExportEntry ToSkeletalMesh(IntermediateMesh intermediateMesh, IMEPackage package, ExportEntry existingEntry) { + if (package.Game == MEGame.ME1 || package.Game == MEGame.UDK) + { + throw new NotImplementedException("Importing skeletal meshes to OT1 or UDK is not implemented."); + } var meshBin = SkeletalMesh.Create(); SetupSkeleton(intermediateMesh.Skeleton, meshBin); From 8ad7077037afb14d80f05b658fe25b285a876b3f Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Wed, 4 Feb 2026 18:07:57 -0500 Subject: [PATCH 31/34] implemented a few BioPawn adjacent classes for exporting, especially SFXStuntActor. Also changed the implementation of conversion a little bit to be more resiliant against slightly corrupt data from the game, which is present in at least one place in vanilla. --- .../PackageEditorExperimentsSquid.cs | 4 ++-- .../LegendaryExplorerCore/Unreal/GLTF.cs | 20 +++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs index 7cbf851db..9a14cd282 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs +++ b/LegendaryExplorer/LegendaryExplorer/Tools/PackageEditor/Experiments/PackageEditorExperimentsSquid.cs @@ -127,7 +127,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew, GLTF.MaterialExport ShowError("This experiment does not support UDK files;"); return; } - if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh", "SkeletalMeshComponent", "BioPawn"], out var export)) + if (GetSelectedItem(pew, ["SkeletalMesh", "StaticMesh", "SkeletalMeshComponent", "BioPawn", "SFXStuntActor", "SkeletalMeshActor"], out var export)) { if (export.ClassName == "StaticMesh" && !(pew.Pcc.Game.IsGame3() || pew.Pcc.Game.IsLEGame())) { @@ -154,7 +154,7 @@ public static void ExportMeshToGltf(PackageEditorWindow pew, GLTF.MaterialExport } else { - ShowError("You must select a skeletal mesh, static mesh, SkeletalMeshComponent, or BioPawn"); + ShowError("You must select a skeletal mesh, static mesh, SkeletalMeshComponent, SFXStuntActor, SkeletalMeshActor, or BioPawn"); } } diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs index 11602f87b..9f4493891 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs @@ -78,14 +78,14 @@ public static void ExportMeshesToGltf(IEnumerable exports, string f { intermediateMeshes.Add(SkeletalMeshComponentToIntermediateMesh(export, materialSetting)); } - else if (export.IsA("BioPawn")) + else if (export.IsA("BioPawn") || export.IsA("SFXStuntActor") || export.IsA("SkeletalMeshActor")) { intermediateMeshes.AddRange(BioPawnToIntermediateMeshes(export, materialSetting)); } } var gltf = ToGltf(intermediateMeshes, versionInfo); - gltf.Save(filePath); + gltf?.Save(filePath); } private static IEnumerable BioPawnToIntermediateMeshes(ExportEntry export, MaterialExportLevel materialSetting, PackageCache cache = null) @@ -101,13 +101,18 @@ ExportEntry[] GetMeshComponentArray(string name) return [..props.GetProp>(name)?.Select(x => x?.ResolveToExport(export.FileRef, cache)) ?? []]; } + // the properties are inconsistent across games and classes, but this should cover BioPawn from any game or SFXStuntActor IEnumerable meshes = [ + GetMeshComponent("SkeletalMeshComponent"), GetMeshComponent("Mesh"), + GetMeshComponent("BodyMesh"), GetMeshComponent("m_oHeadMesh"), GetMeshComponent("HeadMesh"), GetMeshComponent("m_oHeadGearMesh"), GetMeshComponent("HelmetMesh"), + GetMeshComponent("HeadGearMesh"), GetMeshComponent("m_oHairMesh"), + GetMeshComponent("HairMesh"), GetMeshComponent("m_oVisorMesh"), GetMeshComponent("m_oFacePlateMesh"), ..GetMeshComponentArray("m_aoMeshes"), @@ -447,8 +452,10 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, }); } - foreach (var section in lod.Sections) + for (var sectionIndex = 0; sectionIndex < lod.Sections.Length; sectionIndex++) { + var section = lod.Sections[sectionIndex]; + var endIndex = sectionIndex == lod.Sections.Length - 1 ? lod.IndexBuffer.Length : (int)(lod.Sections[sectionIndex + 1].BaseIndex); var intermediateSection = new IntermediateMeshSection { MaterialIndex = materialMapping[section.MaterialIndex], @@ -456,7 +463,7 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, Vertices = vertices }; - for (int i = (int)section.BaseIndex; i < section.BaseIndex + section.NumTriangles * 3; i += 3) + for (int i = (int)section.BaseIndex; i < endIndex; i += 3) { intermediateSection.Triangles.Add(new IntermediateTriangle() { @@ -474,6 +481,11 @@ private static IntermediateLOD ToIntermediateLod(StaticLODModel lod, int index, private static ModelRoot ToGltf(IEnumerable meshes, string versionInfo = null) { + if (!meshes.Any()) + { + // it will output an empty gltf, which will error upon trying to import into Blender. + return null; + } var scene = new SceneBuilder(); Dictionary skeletonMap = []; From 6984223b4f9c5e9183f2dd5fbbf1c9d1420e5b7f Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Wed, 4 Feb 2026 19:50:28 -0500 Subject: [PATCH 32/34] implemented better handling of multiple skeletal meshes using the same armature that are not LODs of each other, or multiple static meshes under the same parent node/at the root. --- .../LegendaryExplorerCore/Unreal/GLTF.cs | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs index 9f4493891..e2421a174 100644 --- a/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs +++ b/LegendaryExplorer/LegendaryExplorerCore/Unreal/GLTF.cs @@ -1064,9 +1064,53 @@ private static bool ApproximatelyEquals(float first, float second) private static void CollectMeshes(ModelRoot modelRoot, out List<(string, Node[])> skeletalMeshes, out List<(string, Node[])> staticMeshes) { + // sort all the nodes that have a mesh into groups by visual parent (or none; that's also a group) + // there will be a parent for each armature exported from Blender, with all meshes under that armature sharing the parent and the same skin var meshes = modelRoot.LogicalNodes.Where(node => node.Mesh != null).GroupBy(node => node.VisualParent); - skeletalMeshes = [..meshes.Where(x => x.All(node => node.Skin != null && node.Skin == x.First().Skin)).Select(group => (group.Key.Name, group.ToArray()))]; - staticMeshes = [.. meshes.Where(x => x.All(node => node.Skin == null)).Select(group => (group.Key?.Name ?? group.First().Name, group.ToArray()))]; + skeletalMeshes = []; + staticMeshes = []; + foreach (var meshGroup in meshes) + { + foreach (var node in meshGroup) + { + node.Name = CleanupName(node.Name); + } + // do I want descending? + var sorted = meshGroup.OrderBy(x => x.Name); + var currentLOD0 = sorted.First(); + List currentLODs = []; + foreach (var node in sorted) + { + // collect them in a list as long as they have the same name prefix and skin + if (node.Skin == currentLOD0.Skin && node.Name.StartsWith(currentLOD0.Name)) + { + currentLODs.Add(node); + } + // when they don't add that list to the output list and start a new one + else + { + if (currentLOD0.Skin != null) + { + skeletalMeshes.Add(currentLOD0.Name, [.. currentLODs]); + } + else + { + staticMeshes.Add(currentLOD0.Name, [.. currentLODs]); + } + currentLOD0 = node; + currentLODs = [node]; + } + } + // add the final list + if (currentLOD0.Skin != null) + { + skeletalMeshes.Add(currentLOD0.Name, [.. currentLODs]); + } + else + { + staticMeshes.Add(currentLOD0.Name, [.. currentLODs]); + } + } } private static bool DoesMeshUseSharedVertexAccessors(Mesh mesh) @@ -1103,9 +1147,8 @@ bool IsAttributeShared(string attribute) && IsAttributeShared(VertexTextureNOriginalIndex.OriginalIndexAttributeName); } - private static void SortNodes(string meshName, Node[] nodes, out Node[] lodNodes, out IEnumerable collisionNodes) + private static void SortNodes(string meshName, Node[] nodes, out Node[] lodNodes, out Node[] collisionNodes) { - if (nodes[0].Skin != null) { // all nodes with skins are LODs. Skeletal Meshes do not have separate collision components @@ -1117,9 +1160,9 @@ private static void SortNodes(string meshName, Node[] nodes, out Node[] lodNodes else { // static meshes are sorted on whether they contain "collision" in the name (case insensitive) - collisionNodes = nodes.Where(x => x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase)); + collisionNodes = [.. nodes.Where(x => x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase))]; // TODO sort these at all?> same problem as above. - lodNodes = nodes.Where(x => !x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase)).ToArray(); + lodNodes = [.. nodes.Where(x => !x.Name.Contains("collision", StringComparison.InvariantCultureIgnoreCase))]; } } From 25fb63a94537e9023847cc2c53fcd272862a22b4 Mon Sep 17 00:00:00 2001 From: DropTheSquid Date: Fri, 6 Feb 2026 18:09:31 -0500 Subject: [PATCH 33/34] Usability imprements. gltf import/export s now available in the mesh renderer alongside the uModel button and in the Meshplorer tools menu and right click menu. --- .../LegendaryExplorer/Misc/GltfHelper.cs | 220 ++++++++++++++++++ .../Tools/Meshplorer/MeshplorerWindow.xaml | 8 + .../Tools/Meshplorer/MeshplorerWindow.xaml.cs | 23 ++ .../PackageEditorExperimentsSquid.cs | 88 ++----- .../ExportLoaderControls/MeshRenderer.xaml | 4 + .../ExportLoaderControls/MeshRenderer.xaml.cs | 57 +++-- .../LegendaryExplorerCore/Unreal/GLTF.cs | 104 +++++++-- 7 files changed, 396 insertions(+), 108 deletions(-) create mode 100644 LegendaryExplorer/LegendaryExplorer/Misc/GltfHelper.cs diff --git a/LegendaryExplorer/LegendaryExplorer/Misc/GltfHelper.cs b/LegendaryExplorer/LegendaryExplorer/Misc/GltfHelper.cs new file mode 100644 index 000000000..05fec4f89 --- /dev/null +++ b/LegendaryExplorer/LegendaryExplorer/Misc/GltfHelper.cs @@ -0,0 +1,220 @@ +using LegendaryExplorer.Dialogs; +using LegendaryExplorer.SharedUI.Bases; +using LegendaryExplorer.UserControls.ExportLoaderControls; +using LegendaryExplorerCore.Helpers; +using LegendaryExplorerCore.Packages; +using LegendaryExplorerCore.Unreal; +using LegendaryExplorerCore.Unreal.ObjectInfo; +using Microsoft.Win32; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; + +namespace LegendaryExplorer.Misc +{ + /// + /// Wraps the GLTF class in LEC to make the functionality available to various parts of the user interface + /// + public static class GltfHelper + { + public static bool CanExportMeshToGltf(WPFBase owningWindow, IMEPackage package, IEntry selectedEntry) + { + // TODO is the selectedEntry a valid type? + // are experiments enabled? + return true; + } + public static void ExportMeshToGltf(WPFBase owningWindow, MeshRenderer meshRenderer, IMEPackage package, IEntry selectedEntry, GLTF.MaterialExportLevel materialExportLevel = GLTF.MaterialExportLevel.NameOnly) + { + if (package == null) + { + return; + } + if (package.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, port it to another game first"); + return; + } + if (package.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + return; + } + if (FilterSelectedItem(selectedEntry, ["SkeletalMesh", "StaticMesh", "SkeletalMeshComponent", "BioPawn", "SFXStuntActor", "SkeletalMeshActor"], out var export)) + { + if (export.ClassName == "StaticMesh" && !(package.Game.IsGame3() || package.Game.IsLEGame())) + { + ShowError("This experiment does not yet support OT1 or OT2 for static meshes."); + return; + } + var d = new SaveFileDialog { Filter = "glTF binary|*.glb|glTF|*.glTF", FileName = $"{selectedEntry.ObjectName.Instanced}.glb" }; + if (d.ShowDialog() == true) + { + Task.Run(() => + { + if (owningWindow != null) + { + owningWindow.BusyText = "Exporting to glTF..."; + owningWindow.IsBusy = true; + } + else + { + meshRenderer?.BusyText = "Exporting to glTF..."; + meshRenderer?.IsBusy = true; + } + GLTF.ExportMeshToGltf(export, d.FileName, materialExportLevel, $"Legendary Explorer {AppVersion.DisplayedVersion}"); + }).ContinueWithOnUIThread(x => + { + if (owningWindow != null) + { + owningWindow?.IsBusy = false; + } + else + { + meshRenderer?.IsBusy = false; + } + if (x.Exception != null) + { + ShowError(x.Exception.FlattenException()); + } + }); + } + } + else + { + ShowError("You must select a skeletal mesh, static mesh, SkeletalMeshComponent, SFXStuntActor, SkeletalMeshActor, or BioPawn"); + } + } + + public static void ReplaceFromGltf(WPFBase window, IEntry selectedEntry) + { + if (window.Pcc == null) + { + return; + } + if (window.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (window.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetGltfFromFile(out var gltf, out string _)) + { + FilterSelectedItem(selectedEntry, ["SkeletalMesh", "StaticMesh"], out ExportEntry selectedMeshToReplace); + GLTF.QueryMeshes(gltf, out var skeletalMeshes, out var staticMeshes); + string specificMesh = null; + if (selectedMeshToReplace.ClassName == "SkeletalMesh") + { + var meshCount = skeletalMeshes.Count(); + if (meshCount == 0) + { + ShowError("You are trying to replace a skeletal mesh but the glTF file does not contain any skeletal meshes."); + return; + } + else if (meshCount > 1) + { + var prompt = new DropdownPromptDialog("Select which mesh to use to replace your mesh.", + "Select mesh", "Mesh", skeletalMeshes, window); + prompt.ShowDialog(); + if (prompt.DialogResult == true) + { + specificMesh = prompt.Response; + } + else { return; } + } + } + else if (selectedMeshToReplace.ClassName == "StaticMesh") + { + var meshCount = staticMeshes.Count(); + if (meshCount == 0) + { + ShowError("You are trying to replace a static mesh but the glTF file does not contain any static meshes."); + return; + } + else if (meshCount > 1) + { + var prompt = new DropdownPromptDialog("Select which mesh to use to replace your mesh.", + "Select mesh", "Mesh", staticMeshes, window); + prompt.ShowDialog(); + if (prompt.DialogResult == true) + { + specificMesh = prompt.Response; + } + else { return; } + } + } + GLTF.ConvertGltfToMesh(gltf, window.Pcc, selectedMeshToReplace, specificMesh); + } + } + + public static void ImportNewFromGltf(WPFBase window) + { + if (window.Pcc == null) + { + return; + } + if (window.Pcc.Game == MEGame.ME1) + { + ShowError("This experiment does not yet support OT1; if you must do this, import it into another game and port it to OT1"); + } + if (window.Pcc.Game == MEGame.UDK) + { + ShowError("This experiment does not support UDK files;"); + } + if (GetGltfFromFile(out var gltf, out string _)) + { + GLTF.QueryMeshes(gltf, out var skeletalMeshes, out var staticMeshes); + if (!skeletalMeshes.Any() && !staticMeshes.Any()) + { + ShowError("The gltf you are trying to import does not contain any meshes."); + return; + } + GLTF.ConvertGltfToMesh(gltf, window.Pcc); + } + } + + private static bool FilterSelectedItem(IEntry selectedItem, string[] expectedTypes, out ExportEntry entry) + { + entry = null; + if (selectedItem == null) + { + return false; + } + + foreach (var expectedType in expectedTypes) + { + if (selectedItem.IsA(expectedType)) + { + entry = (ExportEntry)selectedItem; + return entry != null; + } + } + return false; + } + + private static bool GetGltfFromFile(out SharpGLTF.Schema2.ModelRoot gltf, out string filePath) + { + var d = new OpenFileDialog + { + Filter = "gLTF|*.gltf;*.glb", + Title = "Select a gLTF or glb file" + }; + if (d.ShowDialog() == true) + { + filePath = d.FileName; + gltf = SharpGLTF.Schema2.ModelRoot.Load(filePath, SharpGLTF.Validation.ValidationMode.Skip); + return true; + } + + gltf = null; + filePath = null; + return false; + } + + private static void ShowError(string errMsg) + { + MessageBox.Show(errMsg, "Warning", MessageBoxButton.OK); + } + } +} diff --git a/LegendaryExplorer/LegendaryExplorer/Tools/Meshplorer/MeshplorerWindow.xaml b/LegendaryExplorer/LegendaryExplorer/Tools/Meshplorer/MeshplorerWindow.xaml index 4d4274803..eb3893240 100644 --- a/LegendaryExplorer/LegendaryExplorer/Tools/Meshplorer/MeshplorerWindow.xaml +++ b/LegendaryExplorer/LegendaryExplorer/Tools/Meshplorer/MeshplorerWindow.xaml @@ -59,6 +59,10 @@ + + + + @@ -186,6 +190,10 @@ + + + +