diff --git a/Penumbra/Import/Models/Export/Config.cs b/Penumbra/Import/Models/Export/Config.cs new file mode 100644 index 00000000..58329a1d --- /dev/null +++ b/Penumbra/Import/Models/Export/Config.cs @@ -0,0 +1,6 @@ +namespace Penumbra.Import.Models.Export; + +public struct ExportConfig +{ + public bool GenerateMissingBones; +} diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 71e8f082..83a0c3cf 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -13,14 +13,14 @@ namespace Penumbra.Import.Models.Export; public class MeshExporter { - public class Mesh(IEnumerable meshes, NodeBuilder[]? joints) + public class Mesh(IEnumerable meshes, GltfSkeleton? skeleton) { public void AddToScene(SceneBuilder scene) { foreach (var data in meshes) { - var instance = joints != null - ? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, joints) + var instance = skeleton != null + ? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, [.. skeleton.Value.Joints]) : scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity); var extras = new Dictionary(data.Attributes.Length); @@ -38,15 +38,16 @@ public class MeshExporter public string[] Attributes; } - public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) + public static Mesh Export(ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) { - var self = new MeshExporter(mdl, lod, meshIndex, materials, skeleton?.Names, notifier); - return new Mesh(self.BuildMeshes(), skeleton?.Joints); + var self = new MeshExporter(config, mdl, lod, meshIndex, materials, skeleton, notifier); + return new Mesh(self.BuildMeshes(), skeleton); } private const byte MaximumMeshBufferStreams = 3; - private readonly IoNotifier _notifier; + private readonly ExportConfig _config; + private readonly IoNotifier _notifier; private readonly MdlFile _mdl; private readonly byte _lod; @@ -63,8 +64,10 @@ public class MeshExporter private readonly Type _materialType; private readonly Type _skinningType; - private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, IReadOnlyDictionary? boneNameMap, IoNotifier notifier) + // TODO: This signature is getting out of control. + private MeshExporter(ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) { + _config = config; _notifier = notifier; _mdl = mdl; _lod = lod; @@ -72,8 +75,8 @@ public class MeshExporter _material = materials[XivMesh.MaterialIndex]; - if (boneNameMap != null) - _boneIndexMap = BuildBoneIndexMap(boneNameMap); + if (skeleton != null) + _boneIndexMap = BuildBoneIndexMap(skeleton.Value); var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements .ToImmutableDictionary( @@ -94,7 +97,7 @@ public class MeshExporter } /// Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. - private Dictionary? BuildBoneIndexMap(IReadOnlyDictionary boneNameMap) + private Dictionary? BuildBoneIndexMap(GltfSkeleton skeleton) { // A BoneTableIndex of 255 means that this mesh is not skinned. if (XivMesh.BoneTableIndex == 255) @@ -107,8 +110,18 @@ public class MeshExporter foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount).WithIndex()) { var boneName = _mdl.Bones[xivBoneIndex]; - if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) - throw _notifier.Exception($"Armature does not contain bone \"{boneName}\". Ensure all dependencies are enabled in the current collection, and EST entries (if required) are configured."); + if (!skeleton.Names.TryGetValue(boneName, out var gltfBoneIndex)) + { + if (!_config.GenerateMissingBones) + throw _notifier.Exception( + $@"Armature does not contain bone ""{boneName}"". + Ensure all dependencies are enabled in the current collection, and EST entries (if required) are configured. + If this is a known issue with this model and you would like to export anyway, enable the ""Generate missing bones"" option." + ); + + (_, gltfBoneIndex) = skeleton.GenerateBone(boneName); + _notifier.Warning($"Generated missing bone \"{boneName}\". Vertices weighted to this bone will not move with the rest of the armature."); + } indexMap.Add((ushort)tableIndex, gltfBoneIndex); } diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 9bc33697..b3e9c68d 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -23,16 +23,16 @@ public class ModelExporter } /// Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. - public static Model Export(MdlFile mdl, IEnumerable? xivSkeleton, Dictionary rawMaterials, IoNotifier notifier) + public static Model Export(ExportConfig config, MdlFile mdl, IEnumerable xivSkeletons, Dictionary rawMaterials, IoNotifier notifier) { - var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; + var gltfSkeleton = ConvertSkeleton(xivSkeletons); var materials = ConvertMaterials(mdl, rawMaterials, notifier); - var meshes = ConvertMeshes(mdl, materials, gltfSkeleton, notifier); + var meshes = ConvertMeshes(config, mdl, materials, gltfSkeleton, notifier); return new Model(meshes, gltfSkeleton); } /// Convert a .mdl to a mesh (group) per LoD. - private static List ConvertMeshes(MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) + private static List ConvertMeshes(ExportConfig config, MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) { var meshes = new List(); @@ -44,7 +44,7 @@ public class ModelExporter for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) { var meshIndex = (ushort)(lod.MeshIndex + meshOffset); - var mesh = MeshExporter.Export(mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}")); + var mesh = MeshExporter.Export(config, mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}")); meshes.Add(mesh); } } @@ -105,7 +105,7 @@ public class ModelExporter return new GltfSkeleton { Root = root, - Joints = [.. joints], + Joints = joints, Names = names, }; } diff --git a/Penumbra/Import/Models/Export/Skeleton.cs b/Penumbra/Import/Models/Export/Skeleton.cs index fee107a0..ca72a1f8 100644 --- a/Penumbra/Import/Models/Export/Skeleton.cs +++ b/Penumbra/Import/Models/Export/Skeleton.cs @@ -28,8 +28,18 @@ public struct GltfSkeleton public NodeBuilder Root; /// Flattened list of skeleton nodes. - public NodeBuilder[] Joints; + public List Joints; /// Mapping of bone names to their index within the joints array. public Dictionary Names; + + public (NodeBuilder, int) GenerateBone(string name) + { + var node = new NodeBuilder(name); + var index = Joints.Count; + Names[name] = index; + Joints.Add(node); + Root.AddNode(node); + return (node, index); + } } diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index bfd55281..5340d556 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -37,9 +37,9 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect _tasks.Clear(); } - public Task ExportToGltf(MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath) + public Task ExportToGltf(ExportConfig config, MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath) => EnqueueWithResult( - new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath), + new ExportToGltfAction(this, config, mdl, sklbPaths, read, outputPath), action => action.Notifier ); @@ -182,6 +182,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect private class ExportToGltfAction( ModelManager manager, + ExportConfig config, MdlFile mdl, IEnumerable sklbPaths, Func read, @@ -204,7 +205,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect .ToDictionary(pair => pair.path, pair => pair.material!.Value); Penumbra.Log.Debug("[GLTF Export] Converting model..."); - var model = ModelExporter.Export(mdl, xivSkeletons, materials, Notifier); + var model = ModelExporter.Export(config, mdl, xivSkeletons, materials, Notifier); Penumbra.Log.Debug("[GLTF Export] Building scene..."); var scene = new SceneBuilder(); @@ -219,10 +220,13 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect /// Attempt to read out the pertinent information from the sklb file paths provided. private IEnumerable BuildSkeletons(CancellationToken cancel) { + // We're intentionally filtering failed reads here - the failure will + // be picked up, if relevant, when the model tries to create mappings + // for a bone in the failed sklb. var havokTasks = sklbPaths - .Select(path => read(path) ?? throw new Exception( - $"Resolved skeleton \"{path}\" could not be read. Ensure EST metadata is configured, and/or relevant mods are enabled in the current collection.")) - .Select(bytes => new SklbFile(bytes)) + .Select(path => read(path)) + .Where(bytes => bytes != null) + .Select(bytes => new SklbFile(bytes!)) .WithIndex() .Select(CreateHavokTask) .ToArray(); diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index cb8e662f..5b2f024c 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -3,6 +3,7 @@ using OtterGui; using Penumbra.GameData; using Penumbra.GameData.Files; using Penumbra.Import.Models; +using Penumbra.Import.Models.Export; using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; @@ -20,6 +21,8 @@ public partial class ModEditWindow public bool ImportKeepMaterials; public bool ImportKeepAttributes; + public ExportConfig ExportConfig; + public List? GamePaths { get; private set; } public int GamePathIndex; @@ -131,7 +134,7 @@ public partial class ModEditWindow } PendingIo = true; - _edit._models.ExportToGltf(Mdl, sklbPaths, ReadFile, outputPath) + _edit._models.ExportToGltf(ExportConfig, Mdl, sklbPaths, ReadFile, outputPath) .ContinueWith(task => { RecordIoExceptions(task.Exception); diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 1a200fdf..7304d3dd 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -112,6 +112,14 @@ public partial class ModEditWindow DrawGamePathCombo(tab); + // ImGui.Checkbox("##exportGeneratedMissingBones", ref tab.ExportGenerateMissingBones); + ImGui.Checkbox("##exportGeneratedMissingBones", ref tab.ExportConfig.GenerateMissingBones); + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker("Generate missing bones", + "WARNING: Enabling this option can result in unusable exported meshes.\n" + + "It is primarily intended to allow exporting models weighted to bones that do not exist.\n" + + "Before enabling, ensure dependencies are enabled in the current collection, and EST metadata is correctly configured."); + var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count ? tab.GamePaths[tab.GamePathIndex] : _customGamePath;