diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 84628c2c..8127f348 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -13,23 +13,34 @@ namespace Penumbra.Import.Models.Export; public class MeshExporter { - public class Mesh(IEnumerable> meshes, NodeBuilder[]? joints) + public class Mesh(IEnumerable meshes, NodeBuilder[]? joints) { public void AddToScene(SceneBuilder scene) { - foreach (var mesh in meshes) + foreach (var data in meshes) { - if (joints == null) - scene.AddRigidMesh(mesh, Matrix4x4.Identity); - else - scene.AddSkinnedMesh(mesh, Matrix4x4.Identity, joints); + var instance = joints != null + ? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, joints) + : scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity); + + var extras = new Dictionary(); + foreach (var attribute in data.Attributes) + extras.Add(attribute, true); + + instance.WithExtras(JsonContent.CreateFrom(extras)); } } } - public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, GltfSkeleton? skeleton) + public struct MeshData { - var self = new MeshExporter(mdl, lod, meshIndex, skeleton?.Names); + public IMeshBuilder Mesh; + public string[] Attributes; + } + + public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton) + { + var self = new MeshExporter(mdl, lod, meshIndex, materials, skeleton?.Names); return new Mesh(self.BuildMeshes(), skeleton?.Joints); } @@ -42,18 +53,22 @@ public class MeshExporter private MdlStructs.MeshStruct XivMesh => _mdl.Meshes[_meshIndex]; + private readonly MaterialBuilder _material; + private readonly Dictionary? _boneIndexMap; private readonly Type _geometryType; private readonly Type _materialType; private readonly Type _skinningType; - private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, IReadOnlyDictionary? boneNameMap) + private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, IReadOnlyDictionary? boneNameMap) { _mdl = mdl; _lod = lod; _meshIndex = meshIndex; + _material = materials[XivMesh.MaterialIndex]; + if (boneNameMap != null) _boneIndexMap = BuildBoneIndexMap(boneNameMap); @@ -99,31 +114,33 @@ public class MeshExporter } /// Build glTF meshes for this XIV mesh. - private IMeshBuilder[] BuildMeshes() + private MeshData[] BuildMeshes() { var indices = BuildIndices(); var vertices = BuildVertices(); // NOTE: Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh, so we're specifying the index base relative to the mesh's base. if (XivMesh.SubMeshCount == 0) - return [BuildMesh($"mesh {_meshIndex}", indices, vertices, 0, (int)XivMesh.IndexCount)]; + return [BuildMesh($"mesh {_meshIndex}", indices, vertices, 0, (int)XivMesh.IndexCount, 0)]; return _mdl.SubMeshes .Skip(XivMesh.SubMeshIndex) .Take(XivMesh.SubMeshCount) .WithIndex() .Select(subMesh => BuildMesh($"mesh {_meshIndex}.{subMesh.Index}", indices, vertices, - (int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount)) + (int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount, + subMesh.Value.AttributeIndexMask)) .ToArray(); } /// Build a mesh from the provided indices and vertices. A subset of the full indices may be built by providing an index base and count. - private IMeshBuilder BuildMesh( + private MeshData BuildMesh( string name, IReadOnlyList indices, IReadOnlyList vertices, int indexBase, - int indexCount + int indexCount, + uint attributeMask ) { var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( @@ -134,13 +151,7 @@ public class MeshExporter ); var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, name)!; - // TODO: share materials &c - var materialBuilder = new MaterialBuilder() - .WithDoubleSide(true) - .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 1, 1, 1)); - - var primitiveBuilder = meshBuilder.UsePrimitive(materialBuilder); + var primitiveBuilder = meshBuilder.UsePrimitive(_material); // Store a list of the glTF indices. The list index will be equivalent to the xiv (submesh) index. var gltfIndices = new List(); @@ -192,12 +203,23 @@ public class MeshExporter } } + // Named morph targets aren't part of the specification, however `MESH.extras.targetNames` + // is a commonly-accepted means of providing the data. meshBuilder.Extras = JsonContent.CreateFrom(new Dictionary() { { "targetNames", shapeNames }, }); - return meshBuilder; + var attributes = Enumerable.Range(0, 32) + .Where(index => ((attributeMask >> index) & 1) == 1) + .Select(index => _mdl.Attributes[index]) + .ToArray(); + + return new MeshData + { + Mesh = meshBuilder, + Attributes = attributes, + }; } /// Read in the indices for this mesh. diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 8271f266..6a25af61 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -1,4 +1,5 @@ using Penumbra.GameData.Files; +using SharpGLTF.Materials; using SharpGLTF.Scenes; using SharpGLTF.Transforms; @@ -14,7 +15,7 @@ public class ModelExporter var skeletonRoot = skeleton?.Root; if (skeletonRoot != null) scene.AddNode(skeletonRoot); - + // Add all the meshes to the scene. foreach (var mesh in meshes) mesh.AddToScene(scene); @@ -25,12 +26,13 @@ public class ModelExporter public static Model Export(MdlFile mdl, IEnumerable? xivSkeleton) { var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; - var meshes = ConvertMeshes(mdl, gltfSkeleton); + var materials = ConvertMaterials(mdl); + var meshes = ConvertMeshes(mdl, materials, gltfSkeleton); return new Model(meshes, gltfSkeleton); } /// Convert a .mdl to a mesh (group) per LoD. - private static List ConvertMeshes(MdlFile mdl, GltfSkeleton? skeleton) + private static List ConvertMeshes(MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton) { var meshes = new List(); @@ -41,7 +43,7 @@ public class ModelExporter // TODO: consider other types of mesh? for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) { - var mesh = MeshExporter.Export(mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton); + var mesh = MeshExporter.Export(mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), materials, skeleton); meshes.Add(mesh); } } @@ -49,6 +51,18 @@ public class ModelExporter return meshes; } + // TODO: Compose textures for use with these materials + /// Build placeholder materials for each of the material slots in the .mdl. + private static MaterialBuilder[] ConvertMaterials(MdlFile mdl) + => mdl.Materials + .Select(name => + new MaterialBuilder(name) + .WithMetallicRoughnessShader() + .WithDoubleSide(true) + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, Vector4.One) + ) + .ToArray(); + /// Convert XIV skeleton data into a glTF-compatible node tree, with mappings. private static GltfSkeleton? ConvertSkeleton(IEnumerable skeletons) { @@ -60,7 +74,7 @@ public class ModelExporter var iterator = skeletons.SelectMany(skeleton => skeleton.Bones.Select(bone => (skeleton, bone))); foreach (var (skeleton, bone) in iterator) { - if (names.ContainsKey(bone.Name)) + if (names.ContainsKey(bone.Name)) continue; var node = new NodeBuilder(bone.Name); diff --git a/Penumbra/Import/Models/Import/MeshImporter.cs b/Penumbra/Import/Models/Import/MeshImporter.cs index 95eede2b..7da4d1d7 100644 --- a/Penumbra/Import/Models/Import/MeshImporter.cs +++ b/Penumbra/Import/Models/Import/MeshImporter.cs @@ -10,6 +10,8 @@ public class MeshImporter(IEnumerable nodes) public MdlStructs.MeshStruct MeshStruct; public List SubMeshStructs; + public string? Material; + public MdlStructs.VertexDeclarationStruct VertexDeclaration; public IEnumerable VertexBuffer; @@ -17,6 +19,8 @@ public class MeshImporter(IEnumerable nodes) public List? Bones; + public List MetaAttributes; + public List ShapeKeys; } @@ -35,6 +39,8 @@ public class MeshImporter(IEnumerable nodes) private readonly List _subMeshes = []; + private string? _material; + private MdlStructs.VertexDeclarationStruct? _vertexDeclaration; private byte[]? _strides; private ushort _vertexCount; @@ -44,6 +50,8 @@ public class MeshImporter(IEnumerable nodes) private List? _bones; + private readonly List _metaAttributes = []; + private readonly Dictionary> _shapeValues = []; private Mesh Create() @@ -74,10 +82,12 @@ public class MeshImporter(IEnumerable nodes) BoneTableIndex = 0, }, SubMeshStructs = _subMeshes, + Material = _material, VertexDeclaration = _vertexDeclaration.Value, VertexBuffer = _streams[0].Concat(_streams[1]).Concat(_streams[2]), Indices = _indices, Bones = _bones, + MetaAttributes = _metaAttributes, ShapeKeys = _shapeValues .Select(pair => new MeshShapeKey() { @@ -105,6 +115,9 @@ public class MeshImporter(IEnumerable nodes) var subMeshName = node.Name ?? node.Mesh.Name; + // TODO: Record a warning if there's a mismatch between current and incoming, as we can't support multiple materials per mesh. + _material ??= subMesh.Material; + // Check that vertex declarations match - we need to combine the buffers, so a mismatch would take a whole load of resolution. if (_vertexDeclaration == null) _vertexDeclaration = subMesh.VertexDeclaration; @@ -145,6 +158,8 @@ public class MeshImporter(IEnumerable nodes) _subMeshes.Add(subMesh.SubMeshStruct with { IndexOffset = (ushort)(subMesh.SubMeshStruct.IndexOffset + indexOffset), + AttributeIndexMask = Utility.GetMergedAttributeMask( + subMesh.SubMeshStruct.AttributeIndexMask, subMesh.MetaAttributes, _metaAttributes), }); } diff --git a/Penumbra/Import/Models/Import/ModelImporter.cs b/Penumbra/Import/Models/Import/ModelImporter.cs index 1c49d4bd..3b3d2cd0 100644 --- a/Penumbra/Import/Models/Import/ModelImporter.cs +++ b/Penumbra/Import/Models/Import/ModelImporter.cs @@ -19,6 +19,8 @@ public partial class ModelImporter(ModelRoot model) private readonly List _meshes = []; private readonly List _subMeshes = []; + private readonly List _materials = []; + private readonly List _vertexDeclarations = []; private readonly List _vertexBuffer = []; @@ -27,6 +29,8 @@ public partial class ModelImporter(ModelRoot model) private readonly List _bones = []; private readonly List _boneTables = []; + private readonly List _metaAttributes = []; + private readonly Dictionary> _shapeMeshes = []; private readonly List _shapeValues = []; @@ -37,6 +41,8 @@ public partial class ModelImporter(ModelRoot model) BuildMeshForGroup(subMeshNodes); // Now that all the meshes have been built, we can build some of the model-wide metadata. + var materials = _materials.Count > 0 ? _materials : ["/NO_MATERIAL"]; + var shapes = new List(); var shapeMeshes = new List(); foreach (var (keyName, keyMeshes) in _shapeMeshes) @@ -67,6 +73,7 @@ public partial class ModelImporter(ModelRoot model) Bones = [.. _bones], // TODO: Game doesn't seem to rely on this, but would be good to populate. SubMeshBoneMap = [], + Attributes = [.. _metaAttributes], Shapes = [.. shapes], ShapeMeshes = [.. shapeMeshes], ShapeValues = [.. _shapeValues], @@ -86,8 +93,7 @@ public partial class ModelImporter(ModelRoot model) }, ], - // TODO: Would be good to populate from gltf material names. - Materials = ["/NO_MATERIAL"], + Materials = [.. materials], // TODO: Would be good to calculate all of this up the tree. Radius = 1, @@ -130,15 +136,20 @@ public partial class ModelImporter(ModelRoot model) var mesh = MeshImporter.Import(subMeshNodes); var meshStartIndex = (uint)(mesh.MeshStruct.StartIndex + indexOffset); + var materialIndex = mesh.Material != null + ? GetMaterialIndex(mesh.Material) + : (ushort)0; + // If no bone table is used for a mesh, the index is set to 255. - var boneTableIndex = 255; - if (mesh.Bones != null) - boneTableIndex = BuildBoneTable(mesh.Bones); + var boneTableIndex = mesh.Bones != null + ? BuildBoneTable(mesh.Bones) + : (ushort)255; _meshes.Add(mesh.MeshStruct with { + MaterialIndex = materialIndex, SubMeshIndex = (ushort)(mesh.MeshStruct.SubMeshIndex + subMeshOffset), - BoneTableIndex = (ushort)boneTableIndex, + BoneTableIndex = boneTableIndex, StartIndex = meshStartIndex, VertexBufferOffset = mesh.MeshStruct.VertexBufferOffset .Select(offset => (uint)(offset + vertexOffset)) @@ -147,6 +158,8 @@ public partial class ModelImporter(ModelRoot model) _subMeshes.AddRange(mesh.SubMeshStructs.Select(m => m with { + AttributeIndexMask = Utility.GetMergedAttributeMask( + m.AttributeIndexMask, mesh.MetaAttributes, _metaAttributes), IndexOffset = (uint)(m.IndexOffset + indexOffset), })); @@ -181,6 +194,23 @@ public partial class ModelImporter(ModelRoot model) throw new Exception($"Importing this file would require more than the maximum of {ushort.MaxValue} shape values.\nTry removing or applying shape keys that do not need to be changed at runtime in-game."); } + private ushort GetMaterialIndex(string materialName) + { + // If we already have this material, grab the current index. + var index = _materials.IndexOf(materialName); + if (index >= 0) + return (ushort)index; + + // If there's already 4 materials, we can't add any more. + // TODO: permit, with a warning to reduce, and validation in MdlTab. + var count = _materials.Count; + if (count >= 4) + return 0; + + _materials.Add(materialName); + return (ushort)count; + } + private ushort BuildBoneTable(List boneNames) { var boneIndices = new List(); diff --git a/Penumbra/Import/Models/Import/SubMeshImporter.cs b/Penumbra/Import/Models/Import/SubMeshImporter.cs index 5dec4384..51443f64 100644 --- a/Penumbra/Import/Models/Import/SubMeshImporter.cs +++ b/Penumbra/Import/Models/Import/SubMeshImporter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Lumina.Data.Parsing; using OtterGui; using SharpGLTF.Schema2; @@ -10,6 +11,8 @@ public class SubMeshImporter { public MdlStructs.SubmeshStruct SubMeshStruct; + public string? Material; + public MdlStructs.VertexDeclarationStruct VertexDeclaration; public ushort VertexCount; @@ -18,6 +21,8 @@ public class SubMeshImporter public ushort[] Indices; + public string[] MetaAttributes; + public Dictionary> ShapeValues; } @@ -27,10 +32,11 @@ public class SubMeshImporter return importer.Create(); } - private readonly MeshPrimitive _primitive; - private readonly IDictionary? _nodeBoneMap; + private readonly MeshPrimitive _primitive; + private readonly IDictionary? _nodeBoneMap; + private readonly IDictionary? _nodeExtras; - private List? _attributes; + private List? _vertexAttributes; private ushort _vertexCount; private byte[] _strides = [0, 0, 0]; @@ -38,6 +44,8 @@ public class SubMeshImporter private ushort[]? _indices; + private string[]? _metaAttributes; + private readonly List? _morphNames; private Dictionary>? _shapeValues; @@ -55,6 +63,15 @@ public class SubMeshImporter _primitive = mesh.Primitives[0]; _nodeBoneMap = nodeBoneMap; + try + { + _nodeExtras = node.Extras.Deserialize>(); + } + catch + { + _nodeExtras = null; + } + try { _morphNames = mesh.Extras.GetNode("targetNames").Deserialize>(); @@ -74,12 +91,26 @@ public class SubMeshImporter { // Build all the data we'll need. BuildIndices(); - BuildAttributes(); + BuildVertexAttributes(); BuildVertices(); + BuildMetaAttributes(); ArgumentNullException.ThrowIfNull(_indices); - ArgumentNullException.ThrowIfNull(_attributes); + ArgumentNullException.ThrowIfNull(_vertexAttributes); ArgumentNullException.ThrowIfNull(_shapeValues); + ArgumentNullException.ThrowIfNull(_metaAttributes); + + var material = _primitive.Material.Name; + if (material == "") + material = null; + + // At this level, we assume that attributes are wholly controlled by this sub-mesh. + var attributeMask = _metaAttributes.Length switch + { + < 32 => (1u << _metaAttributes.Length) - 1, + 32 => uint.MaxValue, + > 32 => throw new Exception("Models may utilise a maximum of 32 attributes."), + }; return new SubMesh() { @@ -87,20 +118,22 @@ public class SubMeshImporter { IndexOffset = 0, IndexCount = (uint)_indices.Length, - AttributeIndexMask = 0, + AttributeIndexMask = attributeMask, // TODO: Flesh these out. Game doesn't seem to rely on them existing, though. BoneStartIndex = 0, BoneCount = 0, }, + Material = material, VertexDeclaration = new MdlStructs.VertexDeclarationStruct() { - VertexElements = _attributes.Select(attribute => attribute.Element).ToArray(), + VertexElements = _vertexAttributes.Select(attribute => attribute.Element).ToArray(), }, VertexCount = _vertexCount, Strides = _strides, Streams = _streams, Indices = _indices, + MetaAttributes = _metaAttributes, ShapeValues = _shapeValues, }; } @@ -110,7 +143,7 @@ public class SubMeshImporter _indices = _primitive.GetIndices().Select(idx => (ushort)idx).ToArray(); } - private void BuildAttributes() + private void BuildVertexAttributes() { var accessors = _primitive.VertexAccessors; @@ -146,14 +179,14 @@ public class SubMeshImporter offsets[attribute.Stream] += attribute.Size; } - _attributes = attributes; + _vertexAttributes = attributes; // After building the attributes, the resulting next offsets are our stream strides. _strides = offsets; } private void BuildVertices() { - ArgumentNullException.ThrowIfNull(_attributes); + ArgumentNullException.ThrowIfNull(_vertexAttributes); // Lists of vertex indices that are effected by each morph target for this primitive. var morphModifiedVertices = Enumerable.Range(0, _primitive.MorphTargetsCount) @@ -166,13 +199,13 @@ public class SubMeshImporter for (var vertexIndex = 0; vertexIndex < _vertexCount; vertexIndex++) { // Write out vertex data to streams for each attribute. - foreach (var attribute in _attributes) + foreach (var attribute in _vertexAttributes) _streams[attribute.Stream].AddRange(attribute.Build(vertexIndex)); // Record which morph targets have values for this vertex, if any. var changedMorphs = morphModifiedVertices .WithIndex() - .Where(pair => _attributes.Any(attribute => attribute.HasMorph(pair.Index, vertexIndex))) + .Where(pair => _vertexAttributes.Any(attribute => attribute.HasMorph(pair.Index, vertexIndex))) .Select(pair => pair.Value); foreach (var modifiedVertices in changedMorphs) modifiedVertices.Add(vertexIndex); @@ -184,7 +217,7 @@ public class SubMeshImporter private void BuildShapeValues(IEnumerable> morphModifiedVertices) { ArgumentNullException.ThrowIfNull(_indices); - ArgumentNullException.ThrowIfNull(_attributes); + ArgumentNullException.ThrowIfNull(_vertexAttributes); var morphShapeValues = new Dictionary>(); @@ -196,7 +229,7 @@ public class SubMeshImporter foreach (var vertexIndex in modifiedVertices) { // Write out the morphed vertex to the vertex streams. - foreach (var attribute in _attributes) + foreach (var attribute in _vertexAttributes) _streams[attribute.Stream].AddRange(attribute.BuildMorph(morphIndex, vertexIndex)); // Find any indices that target this vertex index and create a mapping. @@ -217,4 +250,13 @@ public class SubMeshImporter _shapeValues = morphShapeValues; } + + private void BuildMetaAttributes() + { + // We consider any "extras" key with a boolean value set to `true` to be an attribute. + _metaAttributes = _nodeExtras? + .Where(pair => pair.Value.ValueKind == JsonValueKind.True) + .Select(pair => pair.Key) + .ToArray() ?? []; + } } diff --git a/Penumbra/Import/Models/Import/Utility.cs b/Penumbra/Import/Models/Import/Utility.cs new file mode 100644 index 00000000..449d19e4 --- /dev/null +++ b/Penumbra/Import/Models/Import/Utility.cs @@ -0,0 +1,34 @@ +namespace Penumbra.Import.Models.Import; + +public static class Utility +{ + /// Merge attributes into an existing attribute array, providing an updated submesh mask. + /// Old submesh attribute mask. + /// Old attribute array that should be merged. + /// New attribute array. Will be mutated. + /// New submesh attribute mask, updated to match the merged attribute array. + public static uint GetMergedAttributeMask(uint oldMask, IList oldAttributes, List newAttributes) + { + var metaAttributes = Enumerable.Range(0, 32) + .Where(index => ((oldMask >> index) & 1) == 1) + .Select(index => oldAttributes[index]); + + var newMask = 0u; + + foreach (var metaAttribute in metaAttributes) + { + var attributeIndex = newAttributes.IndexOf(metaAttribute); + if (attributeIndex == -1) + { + if (newAttributes.Count >= 32) + throw new Exception("Models may utilise a maximum of 32 attributes."); + + newAttributes.Add(metaAttribute); + attributeIndex = newAttributes.Count - 1; + } + newMask |= 1u << attributeIndex; + } + + return newMask; + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index d52bf3f1..cdaf399f 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -15,6 +15,9 @@ public partial class ModEditWindow public MdlFile Mdl { get; private set; } private List[] _attributes; + public bool ImportKeepMaterials; + public bool ImportKeepAttributes; + public List? GamePaths { get; private set; } public int GamePathIndex; @@ -110,6 +113,7 @@ public partial class ModEditWindow /// Export model to an interchange format. /// Disk path to save the resulting file to. + /// .mdl game path to resolve satellite files such as skeletons relative to. public void Export(string outputPath, Utf8GamePath mdlPath) { IEnumerable skeletons; @@ -143,14 +147,70 @@ public partial class ModEditWindow { RecordIoExceptions(task.Exception); if (task is { IsCompletedSuccessfully: true, Result: not null }) - { - Initialize(task.Result); - _dirty = true; - } + FinalizeImport(task.Result); PendingIo = false; }); } + /// Finalise the import of a .mdl, applying any post-import transformations and state updates. + /// Model data to finalize. + private void FinalizeImport(MdlFile newMdl) + { + if (ImportKeepMaterials) + MergeMaterials(newMdl, Mdl); + + if (ImportKeepAttributes) + MergeAttributes(newMdl, Mdl); + + Initialize(newMdl); + _dirty = true; + } + + /// Merge material configuration from the source onto the target. + /// Model that will be updated. + /// Model to copy material configuration from. + public void MergeMaterials(MdlFile target, MdlFile source) + { + target.Materials = source.Materials; + + for (var meshIndex = 0; meshIndex < target.Meshes.Length; meshIndex++) + { + target.Meshes[meshIndex].MaterialIndex = meshIndex < source.Meshes.Length + ? source.Meshes[meshIndex].MaterialIndex + : (ushort)0; + } + } + + /// Merge attribute configuration from the source onto the target. + /// + /// Model to copy attribute configuration from. + public void MergeAttributes(MdlFile target, MdlFile source) + { + target.Attributes = source.Attributes; + + var indexEnumerator = Enumerable.Range(0, target.Meshes.Length) + .SelectMany(mi => Enumerable.Range(0, target.Meshes[mi].SubMeshCount).Select(so => (mi, so))); + foreach (var (meshIndex, subMeshOffset) in indexEnumerator) + { + var subMeshIndex = target.Meshes[meshIndex].SubMeshIndex + subMeshOffset; + + // Preemptively reset the mask in case we need to shortcut out. + target.SubMeshes[subMeshIndex].AttributeIndexMask = 0u; + + // Rather than comparing sub-meshes directly, we're grouping by parent mesh in an attempt + // to maintain semantic connection betwen mesh index and submesh attributes. + if (meshIndex >= source.Meshes.Length) + continue; + var sourceMesh = source.Meshes[meshIndex]; + + if (subMeshOffset >= sourceMesh.SubMeshCount) + continue; + var sourceSubMesh = source.SubMeshes[sourceMesh.SubMeshIndex + subMeshOffset]; + + target.SubMeshes[subMeshIndex].AttributeIndexMask = sourceSubMesh.AttributeIndexMask; + } + } + private void RecordIoExceptions(Exception? exception) { IoExceptions = exception switch { diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index d8818b21..c92e2926 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -79,6 +79,9 @@ public partial class ModEditWindow using (var frame = ImRaii.FramedGroup("Import", size, headerPreIcon: FontAwesomeIcon.FileImport)) { + ImGui.Checkbox("Keep current materials", ref tab.ImportKeepMaterials); + ImGui.Checkbox("Keep current attributes", ref tab.ImportKeepAttributes); + if (ImGuiUtil.DrawDisabledButton("Import from glTF", Vector2.Zero, "Imports a glTF file, overriding the content of this mdl.", tab.PendingIo)) _fileDialog.OpenFilePicker("Load model from glTF.", "glTF{.gltf,.glb}", (success, paths) => @@ -86,7 +89,6 @@ public partial class ModEditWindow if (success && paths.Count > 0) tab.Import(paths[0]); }, 1, _mod!.ModPath.FullName, false); - ImGui.Dummy(new Vector2(ImGui.GetFrameHeight())); } if (_dragDropManager.CreateImGuiTarget("ModelDragDrop", out var files, out _) && GetFirstModel(files, out var importFile)) @@ -477,5 +479,6 @@ public partial class ModEditWindow private static readonly string[] ValidModelExtensions = [ ".gltf", + ".glb", ]; }