diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index f372f665..219a046e 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using Lumina.Data.Parsing; using Lumina.Extensions; using OtterGui; using Penumbra.GameData.Files; @@ -309,6 +308,7 @@ public class MeshExporter { return type switch { + MdlFile.VertexType.Single2 => new Vector2(reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.Single3 => new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.UByte4 => reader.ReadBytes(4), @@ -376,6 +376,7 @@ public class MeshExporter { MdlFile.VertexType.Half2 => 1, MdlFile.VertexType.Half4 => 2, + MdlFile.VertexType.Single2 => 1, MdlFile.VertexType.Single4 => 2, _ => throw _notifier.Exception($"Unexpected UV vertex type {type}."), }; diff --git a/Penumbra/Import/Models/Export/VertexFragment.cs b/Penumbra/Import/Models/Export/VertexFragment.cs index 08b2a214..7a82e994 100644 --- a/Penumbra/Import/Models/Export/VertexFragment.cs +++ b/Penumbra/Import/Models/Export/VertexFragment.cs @@ -12,7 +12,7 @@ and there's reason to overhaul the export pipeline. public struct VertexColorFfxiv : IVertexCustom { // NOTE: We only realistically require UNSIGNED_BYTE for this, however Blender 3.6 errors on that (fixed in 4.0). - [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, false)] + [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, true)] public Vector4 FfxivColor; public int MaxColors => 0; @@ -81,7 +81,7 @@ public struct VertexTexture1ColorFfxiv : IVertexCustom [VertexAttribute("TEXCOORD_0")] public Vector2 TexCoord0; - [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, false)] + [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, true)] public Vector4 FfxivColor; public int MaxColors => 0; @@ -163,7 +163,7 @@ public struct VertexTexture2ColorFfxiv : IVertexCustom [VertexAttribute("TEXCOORD_1")] public Vector2 TexCoord1; - [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, false)] + [VertexAttribute("_FFXIV_COLOR", EncodingType.UNSIGNED_SHORT, true)] public Vector4 FfxivColor; public int MaxColors => 0; diff --git a/Penumbra/Import/Models/Import/MeshImporter.cs b/Penumbra/Import/Models/Import/MeshImporter.cs index 3a11cb04..1df97907 100644 --- a/Penumbra/Import/Models/Import/MeshImporter.cs +++ b/Penumbra/Import/Models/Import/MeshImporter.cs @@ -194,15 +194,25 @@ public class MeshImporter(IEnumerable nodes, IoNotifier notifier) foreach (var (primitive, primitiveIndex) in node.Mesh.Primitives.WithIndex()) { // Per glTF specification, an asset with a skin MUST contain skinning attributes on its meshes. - var jointsAccessor = primitive.GetVertexAccessor("JOINTS_0") - ?? throw notifier.Exception($"Primitive {primitiveIndex} is skinned but does not contain skinning vertex attributes."); + var jointsAccessor = primitive.GetVertexAccessor("JOINTS_0")?.AsVector4Array(); + var weightsAccessor = primitive.GetVertexAccessor("WEIGHTS_0")?.AsVector4Array(); + + if (jointsAccessor == null || weightsAccessor == null) + throw notifier.Exception($"Primitive {primitiveIndex} is skinned but does not contain skinning vertex attributes."); // Build a set of joints that are referenced by this mesh. - // TODO: Would be neat to omit 0-weighted joints here, but doing so will require some further work on bone mapping behavior to ensure the unweighted joints can still be resolved to valid bone indices during vertex data construction. - foreach (var joints in jointsAccessor.AsVector4Array()) + for (var i = 0; i < jointsAccessor.Count; i++) { + var joints = jointsAccessor[i]; + var weights = weightsAccessor[i]; for (var index = 0; index < 4; index++) + { + // If a joint has absolutely no weight, we omit the bone entirely. + if (weights[index] == 0) + continue; + usedJoints.Add((ushort)joints[index]); + } } } diff --git a/Penumbra/Import/Models/Import/PrimitiveImporter.cs b/Penumbra/Import/Models/Import/PrimitiveImporter.cs index 0c2968df..5df7597e 100644 --- a/Penumbra/Import/Models/Import/PrimitiveImporter.cs +++ b/Penumbra/Import/Models/Import/PrimitiveImporter.cs @@ -65,7 +65,7 @@ public class PrimitiveImporter ArgumentNullException.ThrowIfNull(_indices); ArgumentNullException.ThrowIfNull(_shapeValues); - var material = _primitive.Material.Name; + var material = _primitive.Material?.Name; if (material == "") material = null; diff --git a/Penumbra/Import/Models/Import/VertexAttribute.cs b/Penumbra/Import/Models/Import/VertexAttribute.cs index 3cfedd6f..b7f5dcf1 100644 --- a/Penumbra/Import/Models/Import/VertexAttribute.cs +++ b/Penumbra/Import/Models/Import/VertexAttribute.cs @@ -144,10 +144,10 @@ public class VertexAttribute public static VertexAttribute? BlendIndex(Accessors accessors, IDictionary? boneMap, IoNotifier notifier) { - if (!accessors.TryGetValue("JOINTS_0", out var accessor)) + if (!accessors.TryGetValue("JOINTS_0", out var jointsAccessor)) return null; - if (!accessors.ContainsKey("WEIGHTS_0")) + if (!accessors.TryGetValue("WEIGHTS_0", out var weightsAccessor)) throw notifier.Exception("Mesh contained JOINTS_0 attribute but no corresponding WEIGHTS_0 attribute."); if (boneMap == null) @@ -160,18 +160,21 @@ public class VertexAttribute Usage = (byte)MdlFile.VertexUsage.BlendIndices, }; - var values = accessor.AsVector4Array(); + var joints = jointsAccessor.AsVector4Array(); + var weights = weightsAccessor.AsVector4Array(); return new VertexAttribute( element, index => { - var gltfIndices = values[index]; + var gltfIndices = joints[index]; + var gltfWeights = weights[index]; + return BuildUByte4(new Vector4( - boneMap[(ushort)gltfIndices.X], - boneMap[(ushort)gltfIndices.Y], - boneMap[(ushort)gltfIndices.Z], - boneMap[(ushort)gltfIndices.W] + gltfWeights.X == 0 ? 0 : boneMap[(ushort)gltfIndices.X], + gltfWeights.Y == 0 ? 0 : boneMap[(ushort)gltfIndices.Y], + gltfWeights.Z == 0 ? 0 : boneMap[(ushort)gltfIndices.Z], + gltfWeights.W == 0 ? 0 : boneMap[(ushort)gltfIndices.W] )); } ); diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 1a52c4dd..485a76a7 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -213,7 +213,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect Penumbra.Log.Debug("[GLTF Export] Saving..."); var gltfModel = scene.ToGltf2(); - gltfModel.SaveGLTF(outputPath); + gltfModel.Save(outputPath); Penumbra.Log.Debug("[GLTF Export] Done."); } diff --git a/Penumbra/UI/AdvancedWindow/FileEditor.cs b/Penumbra/UI/AdvancedWindow/FileEditor.cs index 503d64b7..01bf112b 100644 --- a/Penumbra/UI/AdvancedWindow/FileEditor.cs +++ b/Penumbra/UI/AdvancedWindow/FileEditor.cs @@ -204,8 +204,9 @@ public class FileEditor( private void SaveButton() { + var canSave = _changed && _currentFile != null && _currentFile.Valid; if (ImGuiUtil.DrawDisabledButton("Save to File", Vector2.Zero, - $"Save the selected {fileType} file with all changes applied. This is not revertible.", !_changed)) + $"Save the selected {fileType} file with all changes applied. This is not revertible.", !canSave)) { compactor.WriteAllBytes(_currentPath!.File.FullName, _currentFile!.Write()); if (owner.Mod != null) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 6b9965b8..1f935eb6 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -49,7 +49,7 @@ public partial class ModEditWindow /// public bool Valid - => Mdl.Valid; + => Mdl.Valid && Mdl.Materials.All(ValidateMaterial); /// public byte[] Write() @@ -285,6 +285,20 @@ public partial class ModEditWindow : _edit._gameData.GetFile(resolvedPath.InternalName.ToString())?.Data; } + /// Validate the specified material. + /// + /// While materials can be relative (`/mt_...`) or absolute (`bg/...`), + /// they invariably must contain at least one directory seperator. + /// Missing this can lead to a crash. + /// + /// They must also be at least one character (though this is enforced + /// by containing a `/`), and end with `.mtrl`. + /// + public bool ValidateMaterial(string material) + { + return material.Contains('/') && material.EndsWith(".mtrl"); + } + /// Remove the material given by the index. /// Meshes using the removed material are redirected to material 0, and those after the index are corrected. public void RemoveMaterial(int materialIndex) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 80b1a5d5..0be95c99 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -1,5 +1,7 @@ using Dalamud.Interface; +using Dalamud.Interface.Components; using ImGuiNET; +using Lumina.Data.Parsing; using OtterGui; using OtterGui.Custom; using OtterGui.Raii; @@ -7,7 +9,6 @@ using OtterGui.Widgets; using Penumbra.GameData; using Penumbra.GameData.Files; using Penumbra.Import.Models; -using Penumbra.Mods; using Penumbra.String.Classes; using Penumbra.UI.Classes; @@ -145,8 +146,8 @@ public partial class ModEditWindow if (ImGuiUtil.DrawDisabledButton("Export to glTF", Vector2.Zero, "Exports this mdl file to glTF, for use in 3D authoring applications.", tab.PendingIo || gamePath.IsEmpty)) - _fileDialog.OpenSavePicker("Save model as glTF.", ".gltf", Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()), - ".gltf", (valid, path) => + _fileDialog.OpenSavePicker("Save model as glTF.", ".glb", Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()), + ".glb", (valid, path) => { if (!valid) return; @@ -292,10 +293,24 @@ public partial class ModEditWindow private bool DrawModelMaterialDetails(MdlTab tab, bool disabled) { - if (!ImGui.CollapsingHeader("Materials")) + var invalidMaterialCount = tab.Mdl.Materials.Count(material => !tab.ValidateMaterial(material)); + + var oldPos = ImGui.GetCursorPosY(); + var header = ImGui.CollapsingHeader("Materials"); + var newPos = ImGui.GetCursorPos(); + if (invalidMaterialCount > 0) + { + var text = $"{invalidMaterialCount} invalid material{(invalidMaterialCount > 1 ? "s" : "")}"; + var size = ImGui.CalcTextSize(text).X; + ImGui.SetCursorPos(new Vector2(ImGui.GetContentRegionAvail().X - size, oldPos + ImGui.GetStyle().FramePadding.Y)); + ImGuiUtil.TextColored(0xFF0000FF, text); + ImGui.SetCursorPos(newPos); + } + + if (!header) return false; - using var table = ImRaii.Table(string.Empty, disabled ? 2 : 3, ImGuiTableFlags.SizingFixedFit); + using var table = ImRaii.Table(string.Empty, disabled ? 2 : 4, ImGuiTableFlags.SizingFixedFit); if (!table) return false; @@ -305,7 +320,10 @@ public partial class ModEditWindow ImGui.TableSetupColumn("index", ImGuiTableColumnFlags.WidthFixed, 80 * UiHelpers.Scale); ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthStretch, 1); if (!disabled) + { ImGui.TableSetupColumn("actions", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + ImGui.TableSetupColumn("help", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + } var inputFlags = disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; for (var materialIndex = 0; materialIndex < materials.Length; materialIndex++) @@ -319,14 +337,19 @@ public partial class ModEditWindow ImGui.TableNextColumn(); ImGui.SetNextItemWidth(-1); ImGui.InputTextWithHint("##newMaterial", "Add new material...", ref _modelNewMaterial, Utf8GamePath.MaxGamePathLength, inputFlags); - var validName = _modelNewMaterial.Length > 0 && _modelNewMaterial[0] == '/'; + var validName = tab.ValidateMaterial(_modelNewMaterial); ImGui.TableNextColumn(); - if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, string.Empty, !validName, true)) - return ret; + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, string.Empty, !validName, true)) + { + ret |= true; + tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); + _modelNewMaterial = string.Empty; + } + ImGui.TableNextColumn(); + if (!validName && _modelNewMaterial.Length > 0) + DrawInvalidMaterialMarker(); - tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); - _modelNewMaterial = string.Empty; - return true; + return ret; } private bool DrawMaterialRow(MdlTab tab, bool disabled, string[] materials, int materialIndex, ImGuiInputTextFlags inputFlags) @@ -353,20 +376,38 @@ public partial class ModEditWindow return ret; ImGui.TableNextColumn(); - // Need to have at least one material. - if (materials.Length <= 1) - return ret; + if (materials.Length > 1) + { + var tt = "Delete this material.\nAny meshes targeting this material will be updated to use material #1."; + var modifierActive = _config.DeleteModModifier.IsActive(); + if (!modifierActive) + tt += $"\nHold {_config.DeleteModModifier} to delete."; - var tt = "Delete this material.\nAny meshes targeting this material will be updated to use material #1."; - var modifierActive = _config.DeleteModModifier.IsActive(); - if (!modifierActive) - tt += $"\nHold {_config.DeleteModModifier} to delete."; - if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), UiHelpers.IconButtonSize, tt, !modifierActive, true)) - return ret; + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), UiHelpers.IconButtonSize, tt, !modifierActive, true)) + { + tab.RemoveMaterial(materialIndex); + ret |= true; + } + } - tab.RemoveMaterial(materialIndex); - return true; + ImGui.TableNextColumn(); + // Add markers to invalid materials. + if (!tab.ValidateMaterial(temp)) + DrawInvalidMaterialMarker(); + + return ret; + } + + private void DrawInvalidMaterialMarker() + { + using var colorHandle = ImRaii.PushColor(ImGuiCol.TextDisabled, 0xFF0000FF, true); + + ImGuiComponents.HelpMarker( + "Materials must be either relative (e.g. \"/filename.mtrl\")\n" + + "or absolute (e.g. \"bg/full/path/to/filename.mtrl\"),\n" + + "and must end in \".mtrl\".", + FontAwesomeIcon.TimesCircle); } private bool DrawModelLodDetails(MdlTab tab, int lodIndex, bool disabled) @@ -401,6 +442,14 @@ public partial class ModEditWindow var file = tab.Mdl; var mesh = file.Meshes[meshIndex]; + // Vertex elements + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Vertex Elements"); + + ImGui.TableNextColumn(); + DrawVertexElementDetails(file.VertexDeclarations[meshIndex].VertexElements); + // Mesh material ImGui.TableNextColumn(); ImGui.AlignTextToFramePadding(); @@ -416,6 +465,40 @@ public partial class ModEditWindow return ret; } + private static void DrawVertexElementDetails(MdlStructs.VertexElement[] vertexElements) + { + using var node = ImRaii.TreeNode($"Click to expand"); + if (!node) + return; + + var flags = ImGuiTableFlags.SizingFixedFit + | ImGuiTableFlags.RowBg + | ImGuiTableFlags.Borders + | ImGuiTableFlags.NoHostExtendX; + using var table = ImRaii.Table(string.Empty, 4, flags); + if (!table) + return; + + ImGui.TableSetupColumn("Usage"); + ImGui.TableSetupColumn("Type"); + ImGui.TableSetupColumn("Stream"); + ImGui.TableSetupColumn("Offset"); + + ImGui.TableHeadersRow(); + + foreach (var element in vertexElements) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{(MdlFile.VertexUsage)element.Usage}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{(MdlFile.VertexType)element.Type}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{element.Stream}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{element.Offset}"); + } + } + private static bool DrawMaterialCombo(MdlTab tab, int meshIndex, bool disabled) { var mesh = tab.Mdl.Meshes[meshIndex];