From df430831010f85b7970b5ec790107ef4ee726f81 Mon Sep 17 00:00:00 2001 From: ackwell Date: Wed, 27 Dec 2023 01:21:26 +1100 Subject: [PATCH 01/35] export per example --- Penumbra/Import/Models/ModelManager.cs | 45 +++++++++++++++++++ Penumbra/Penumbra.csproj | 2 + Penumbra/Services/ServiceManager.cs | 4 +- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 8 ++++ Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 4 +- Penumbra/packages.lock.json | 23 ++++++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 Penumbra/Import/Models/ModelManager.cs diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs new file mode 100644 index 00000000..33ad9249 --- /dev/null +++ b/Penumbra/Import/Models/ModelManager.cs @@ -0,0 +1,45 @@ +using Penumbra.GameData.Files; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; + +namespace Penumbra.Import.Models; + +public sealed class ModelManager +{ + public ModelManager() + { + // + } + + // TODO: Consider moving import/export onto an async queue, check ../textures/texturemanager + + public void ExportToGltf(/* MdlFile mdl, */string path) + { + var mesh = new MeshBuilder("mesh"); + + var material1 = new MaterialBuilder() + .WithDoubleSide(true) + .WithMetallicRoughnessShader() + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 0, 1)); + var primitive1 = mesh.UsePrimitive(material1); + primitive1.AddTriangle(new VertexPosition(-10, 0, 0), new VertexPosition(10, 0, 0), new VertexPosition(0, 10, 0)); + primitive1.AddTriangle(new VertexPosition(10, 0, 0), new VertexPosition(-10, 0, 0), new VertexPosition(0, -10, 0)); + + var material2 = new MaterialBuilder() + .WithDoubleSide(true) + .WithMetallicRoughnessShader() + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 1, 1)); + var primitive2 = mesh.UsePrimitive(material2); + primitive2.AddQuadrangle(new VertexPosition(-5, 0, 3), new VertexPosition(0, -5, 3), new VertexPosition(5, 0, 3), new VertexPosition(0, 5, 3)); + + var scene = new SceneBuilder(); + scene.AddRigidMesh(mesh, Matrix4x4.Identity); + + var model = scene.ToGltf2(); + model.SaveGLTF(path); + + // TODO: Draw the rest of the owl. + } +} diff --git a/Penumbra/Penumbra.csproj b/Penumbra/Penumbra.csproj index ec433113..122e17b5 100644 --- a/Penumbra/Penumbra.csproj +++ b/Penumbra/Penumbra.csproj @@ -72,6 +72,8 @@ + + diff --git a/Penumbra/Services/ServiceManager.cs b/Penumbra/Services/ServiceManager.cs index 73be8834..5a107060 100644 --- a/Penumbra/Services/ServiceManager.cs +++ b/Penumbra/Services/ServiceManager.cs @@ -8,6 +8,7 @@ using Penumbra.Collections.Cache; using Penumbra.Collections.Manager; using Penumbra.GameData; using Penumbra.GameData.Data; +using Penumbra.Import.Models; using Penumbra.Import.Textures; using Penumbra.Interop.PathResolving; using Penumbra.Interop.ResourceLoading; @@ -185,7 +186,8 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddApi(this IServiceCollection services) => services.AddSingleton() diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index e4646d07..80831dab 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -5,6 +5,7 @@ using OtterGui.Raii; using OtterGui.Widgets; using Penumbra.GameData; using Penumbra.GameData.Files; +using Penumbra.Import.Models; using Penumbra.String.Classes; namespace Penumbra.UI.AdvancedWindow; @@ -13,6 +14,8 @@ public partial class ModEditWindow { private const int MdlMaterialMaximum = 4; + private readonly ModelManager _models; + private readonly FileEditor _modelTab; private string _modelNewMaterial = string.Empty; @@ -31,6 +34,11 @@ public partial class ModEditWindow ); } + if (ImGui.Button("bingo bango")) + { + _models.ExportToGltf("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); + } + var ret = false; ret |= DrawModelMaterialDetails(tab, disabled); diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 365c4a4a..1a3d9182 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -12,6 +12,7 @@ using Penumbra.Collections.Manager; using Penumbra.Communication; using Penumbra.GameData.Enums; using Penumbra.GameData.Files; +using Penumbra.Import.Models; using Penumbra.Import.Textures; using Penumbra.Interop.ResourceTree; using Penumbra.Interop.Services; @@ -563,7 +564,7 @@ public partial class ModEditWindow : Window, IDisposable public ModEditWindow(PerformanceTracker performance, FileDialogService fileDialog, ItemSwapTab itemSwapTab, IDataManager gameData, Configuration config, ModEditor editor, ResourceTreeFactory resourceTreeFactory, MetaFileManager metaFileManager, StainService stainService, ActiveCollections activeCollections, DalamudServices dalamud, ModMergeTab modMergeTab, - CommunicatorService communicator, TextureManager textures, IDragDropManager dragDropManager, GameEventManager gameEvents, + CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager, GameEventManager gameEvents, ChangedItemDrawer changedItemDrawer) : base(WindowBaseLabel) { @@ -579,6 +580,7 @@ public partial class ModEditWindow : Window, IDisposable _communicator = communicator; _dragDropManager = dragDropManager; _textures = textures; + _models = models; _fileDialog = fileDialog; _gameEvents = gameEvents; _materialTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", diff --git a/Penumbra/packages.lock.json b/Penumbra/packages.lock.json index eed5d7c8..cef49e9c 100644 --- a/Penumbra/packages.lock.json +++ b/Penumbra/packages.lock.json @@ -26,6 +26,21 @@ "resolved": "0.33.0", "contentHash": "FlHfpTAADzaSlVCBF33iKJk9UhOr3Xj+r5LXbW2GzqYr0SrhiOf6shLX2LC2fqs7g7d+YlwKbBXqWFtb+e7icw==" }, + "SharpGLTF.Core": { + "type": "Direct", + "requested": "[1.0.0-alpha0030, )", + "resolved": "1.0.0-alpha0030", + "contentHash": "HVL6PcrM0H/uEk96nRZfhtPeYvSFGHnni3g1aIckot2IWVp0jLMH5KWgaWfsatEz4Yds3XcdSLUWmJZivDBUPA==" + }, + "SharpGLTF.Toolkit": { + "type": "Direct", + "requested": "[1.0.0-alpha0030, )", + "resolved": "1.0.0-alpha0030", + "contentHash": "nsoJWAFhXgEky9bVCY0zLeZVDx+S88u7VjvuebvMb6dJiNyFOGF6FrrMHiJe+x5pcVBxxlc3VoXliBF7r/EqYA==", + "dependencies": { + "SharpGLTF.Runtime": "1.0.0-alpha0030" + } + }, "SixLabors.ImageSharp": { "type": "Direct", "requested": "[2.1.2, )", @@ -46,6 +61,14 @@ "resolved": "5.0.0", "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, + "SharpGLTF.Runtime": { + "type": "Transitive", + "resolved": "1.0.0-alpha0030", + "contentHash": "Ysn+fyj9EVXj6mfG0BmzSTBGNi/QvcnTrMd54dBMOlI/TsMRvnOY3JjTn0MpeH2CgHXX4qogzlDt4m+rb3n4Og==", + "dependencies": { + "SharpGLTF.Core": "1.0.0-alpha0030" + } + }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", "resolved": "5.0.0", From ed283afe2caa835cdbe6994c31852ff25510481d Mon Sep 17 00:00:00 2001 From: ackwell Date: Wed, 27 Dec 2023 01:44:24 +1100 Subject: [PATCH 02/35] async is a great idea lets do more of that --- Penumbra/Import/Models/ModelManager.cs | 98 ++++++++++++++----- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 11 ++- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 33ad9249..fbccf4b7 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,3 +1,4 @@ +using OtterGui.Tasks; using Penumbra.GameData.Files; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; @@ -6,40 +7,89 @@ using SharpGLTF.Scenes; namespace Penumbra.Import.Models; -public sealed class ModelManager +public sealed class ModelManager : SingleTaskQueue, IDisposable { + private readonly ConcurrentDictionary _tasks = new(); + private bool _disposed = false; + public ModelManager() { // } - // TODO: Consider moving import/export onto an async queue, check ../textures/texturemanager - - public void ExportToGltf(/* MdlFile mdl, */string path) + public void Dispose() { - var mesh = new MeshBuilder("mesh"); + _disposed = true; + foreach (var (_, cancel) in _tasks.Values.ToArray()) + cancel.Cancel(); + _tasks.Clear(); + } - var material1 = new MaterialBuilder() - .WithDoubleSide(true) - .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 0, 1)); - var primitive1 = mesh.UsePrimitive(material1); - primitive1.AddTriangle(new VertexPosition(-10, 0, 0), new VertexPosition(10, 0, 0), new VertexPosition(0, 10, 0)); - primitive1.AddTriangle(new VertexPosition(10, 0, 0), new VertexPosition(-10, 0, 0), new VertexPosition(0, -10, 0)); - - var material2 = new MaterialBuilder() - .WithDoubleSide(true) - .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 1, 1)); - var primitive2 = mesh.UsePrimitive(material2); - primitive2.AddQuadrangle(new VertexPosition(-5, 0, 3), new VertexPosition(0, -5, 3), new VertexPosition(5, 0, 3), new VertexPosition(0, 5, 3)); + private Task Enqueue(IAction action) + { + if (_disposed) + return Task.FromException(new ObjectDisposedException(nameof(ModelManager))); - var scene = new SceneBuilder(); - scene.AddRigidMesh(mesh, Matrix4x4.Identity); + Task task; + lock (_tasks) + { + task = _tasks.GetOrAdd(action, action => + { + var token = new CancellationTokenSource(); + var task = Enqueue(action, token.Token); + task.ContinueWith(_ => _tasks.TryRemove(action, out var unused), CancellationToken.None); + return (task, token); + }).Item1; + } - var model = scene.ToGltf2(); - model.SaveGLTF(path); + return task; + } - // TODO: Draw the rest of the owl. + public Task ExportToGltf(/* MdlFile mdl, */string path) + => Enqueue(new ExportToGltfAction(path)); + + private class ExportToGltfAction : IAction + { + private readonly string _path; + + public ExportToGltfAction(string path) + { + _path = path; + } + + public void Execute(CancellationToken token) + { + var mesh = new MeshBuilder("mesh"); + + var material1 = new MaterialBuilder() + .WithDoubleSide(true) + .WithMetallicRoughnessShader() + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 0, 1)); + var primitive1 = mesh.UsePrimitive(material1); + primitive1.AddTriangle(new VertexPosition(-10, 0, 0), new VertexPosition(10, 0, 0), new VertexPosition(0, 10, 0)); + primitive1.AddTriangle(new VertexPosition(10, 0, 0), new VertexPosition(-10, 0, 0), new VertexPosition(0, -10, 0)); + + var material2 = new MaterialBuilder() + .WithDoubleSide(true) + .WithMetallicRoughnessShader() + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 1, 1)); + var primitive2 = mesh.UsePrimitive(material2); + primitive2.AddQuadrangle(new VertexPosition(-5, 0, 3), new VertexPosition(0, -5, 3), new VertexPosition(5, 0, 3), new VertexPosition(0, 5, 3)); + + var scene = new SceneBuilder(); + scene.AddRigidMesh(mesh, Matrix4x4.Identity); + + var model = scene.ToGltf2(); + model.SaveGLTF(_path); + } + + public bool Equals(IAction? other) + { + if (other is not ExportToGltfAction rhs) + return false; + + // TODO: compare configuration + return true; + } } } diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 80831dab..89497cfd 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -14,10 +14,11 @@ public partial class ModEditWindow { private const int MdlMaterialMaximum = 4; - private readonly ModelManager _models; - private readonly FileEditor _modelTab; + private readonly ModelManager _models; + private bool _pendingIo = false; + private string _modelNewMaterial = string.Empty; private readonly List _subMeshAttributeTagWidgets = []; @@ -34,9 +35,11 @@ public partial class ModEditWindow ); } - if (ImGui.Button("bingo bango")) + if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", _pendingIo)) { - _models.ExportToGltf("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); + _pendingIo = true; + var task = _models.ExportToGltf("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); + task.ContinueWith(_ => _pendingIo = false); } var ret = false; From b7472f722ee991166fb5d874419d3e25cbfb97fa Mon Sep 17 00:00:00 2001 From: ackwell Date: Wed, 27 Dec 2023 16:17:39 +1100 Subject: [PATCH 03/35] poc submesh position export --- Penumbra/Import/Models/ModelManager.cs | 71 ++++++++++++++----- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 2 +- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index fbccf4b7..bded3b0c 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,3 +1,4 @@ +using Lumina.Extensions; using OtterGui.Tasks; using Penumbra.GameData.Files; using SharpGLTF.Geometry; @@ -45,39 +46,75 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable return task; } - public Task ExportToGltf(/* MdlFile mdl, */string path) - => Enqueue(new ExportToGltfAction(path)); + public Task ExportToGltf(MdlFile mdl, string path) + => Enqueue(new ExportToGltfAction(mdl, path)); private class ExportToGltfAction : IAction { + private readonly MdlFile _mdl; private readonly string _path; - public ExportToGltfAction(string path) + public ExportToGltfAction(MdlFile mdl, string path) { + _mdl = mdl; _path = path; } public void Execute(CancellationToken token) { - var mesh = new MeshBuilder("mesh"); + var meshBuilder = new MeshBuilder("mesh"); - var material1 = new MaterialBuilder() + var material = new MaterialBuilder() .WithDoubleSide(true) .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 0, 1)); - var primitive1 = mesh.UsePrimitive(material1); - primitive1.AddTriangle(new VertexPosition(-10, 0, 0), new VertexPosition(10, 0, 0), new VertexPosition(0, 10, 0)); - primitive1.AddTriangle(new VertexPosition(10, 0, 0), new VertexPosition(-10, 0, 0), new VertexPosition(0, -10, 0)); - - var material2 = new MaterialBuilder() - .WithDoubleSide(true) - .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 0, 1, 1)); - var primitive2 = mesh.UsePrimitive(material2); - primitive2.AddQuadrangle(new VertexPosition(-5, 0, 3), new VertexPosition(0, -5, 3), new VertexPosition(5, 0, 3), new VertexPosition(0, 5, 3)); + .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 1, 1, 1)); + + // lol, lmao even + var meshIndex = 2; + var lod = 0; + + var mesh = _mdl.Meshes[meshIndex]; + var submesh = _mdl.SubMeshes[mesh.SubMeshIndex]; // just first for now + + var positionVertexElement = _mdl.VertexDeclarations[meshIndex].VertexElements + .Where(decl => decl.Usage == 0 /* POSITION */) + .First(); + + // reading in the entire indices list + var dataReader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + dataReader.Seek(_mdl.IndexOffset[lod]); + var indices = dataReader.ReadStructuresAsArray((int)_mdl.IndexBufferSize[lod] / sizeof(ushort)); + + // read in verts for this mesh + var baseOffset = _mdl.VertexOffset[lod] + mesh.VertexBufferOffset[positionVertexElement.Stream] + positionVertexElement.Offset; + var vertices = new List(); + for (var vertexIndex = 0; vertexIndex < mesh.VertexCount; vertexIndex++) + { + dataReader.Seek(baseOffset + vertexIndex * mesh.VertexBufferStride[positionVertexElement.Stream]); + // todo handle type + vertices.Add(new VertexPosition( + dataReader.ReadSingle(), + dataReader.ReadSingle(), + dataReader.ReadSingle() + )); + } + + // build a primitive for the submesh + var primitiveBuilder = meshBuilder.UsePrimitive(material); + // they're all tri list + for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) + { + var index = indexOffset + submesh.IndexOffset; + + primitiveBuilder.AddTriangle( + vertices[indices[index + 0]], + vertices[indices[index + 1]], + vertices[indices[index + 2]] + ); + } var scene = new SceneBuilder(); - scene.AddRigidMesh(mesh, Matrix4x4.Identity); + scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); var model = scene.ToGltf2(); model.SaveGLTF(_path); diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 89497cfd..b64b4f40 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -38,7 +38,7 @@ public partial class ModEditWindow if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", _pendingIo)) { _pendingIo = true; - var task = _models.ExportToGltf("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); + var task = _models.ExportToGltf(file, "C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); task.ContinueWith(_ => _pendingIo = false); } From 81425b458e043ae98c25c0cddbf1e53de3a94c35 Mon Sep 17 00:00:00 2001 From: ackwell Date: Wed, 27 Dec 2023 17:25:14 +1100 Subject: [PATCH 04/35] Use vertex element enums --- Penumbra.GameData | 2 +- Penumbra/Import/Models/ModelManager.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Penumbra.GameData b/Penumbra.GameData index ffdb966f..0dc4c892 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit ffdb966fec5a657893289e655c641ceb3af1d59f +Subproject commit 0dc4c892308aea30314d118362b3ebab7706f4e5 diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index bded3b0c..5e931b36 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -77,7 +77,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable var submesh = _mdl.SubMeshes[mesh.SubMeshIndex]; // just first for now var positionVertexElement = _mdl.VertexDeclarations[meshIndex].VertexElements - .Where(decl => decl.Usage == 0 /* POSITION */) + .Where(decl => (MdlFile.VertexUsage)decl.Usage == MdlFile.VertexUsage.Position) .First(); // reading in the entire indices list From ca46e7482f5dd2eeba323bd082a8d5d67b05d826 Mon Sep 17 00:00:00 2001 From: ackwell Date: Thu, 28 Dec 2023 00:44:19 +1100 Subject: [PATCH 05/35] Flesh out geometry handling --- Penumbra/Import/Models/ModelManager.cs | 149 ++++++++++++++++++++++--- 1 file changed, 131 insertions(+), 18 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 5e931b36..af285cbb 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Lumina.Data.Parsing; using Lumina.Extensions; using OtterGui.Tasks; using Penumbra.GameData.Files; @@ -62,17 +64,26 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public void Execute(CancellationToken token) { - var meshBuilder = new MeshBuilder("mesh"); + // lol, lmao even + var meshIndex = 2; + var lod = 0; + + var elements = _mdl.VertexDeclarations[meshIndex].VertexElements; + + var usages = elements + .Select(element => (MdlFile.VertexUsage)element.Usage) + .ToImmutableHashSet(); + var geometryType = GetGeometryType(usages); + + // TODO: probablly can do this a bit later but w/e + var meshBuilderType = typeof(MeshBuilder<,,>).MakeGenericType(geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); + var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, "mesh2")!; var material = new MaterialBuilder() .WithDoubleSide(true) .WithMetallicRoughnessShader() .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 1, 1, 1)); - // lol, lmao even - var meshIndex = 2; - var lod = 0; - var mesh = _mdl.Meshes[meshIndex]; var submesh = _mdl.SubMeshes[mesh.SubMeshIndex]; // just first for now @@ -86,18 +97,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable var indices = dataReader.ReadStructuresAsArray((int)_mdl.IndexBufferSize[lod] / sizeof(ushort)); // read in verts for this mesh - var baseOffset = _mdl.VertexOffset[lod] + mesh.VertexBufferOffset[positionVertexElement.Stream] + positionVertexElement.Offset; - var vertices = new List(); - for (var vertexIndex = 0; vertexIndex < mesh.VertexCount; vertexIndex++) - { - dataReader.Seek(baseOffset + vertexIndex * mesh.VertexBufferStride[positionVertexElement.Stream]); - // todo handle type - vertices.Add(new VertexPosition( - dataReader.ReadSingle(), - dataReader.ReadSingle(), - dataReader.ReadSingle() - )); - } + var vertices = BuildVertices(lod, mesh, _mdl.VertexDeclarations[meshIndex].VertexElements, geometryType); // build a primitive for the submesh var primitiveBuilder = meshBuilder.UsePrimitive(material); @@ -120,12 +120,125 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable model.SaveGLTF(_path); } + // todo all of this is mesh specific so probably should be a class per mesh? with the lod, too? + private IReadOnlyList BuildVertices(int lod, MdlStructs.MeshStruct mesh, IEnumerable elements, Type geometryType) + { + var vertexBuilderType = typeof(VertexBuilder<,,>).MakeGenericType(geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); + + // todo: demagic the 3 + // todo note this assumes that the buffer streams are tightly packed. that's a safe assumption - right? lumina assumes as much + var streams = new BinaryReader[3]; + for (var streamIndex = 0; streamIndex < 3; streamIndex++) + { + streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + streams[streamIndex].Seek(_mdl.VertexOffset[lod] + mesh.VertexBufferOffset[streamIndex]); + } + + var sortedElements = elements + .OrderBy(element => element.Offset) + .ToList(); + + var vertices = new List(); + + // note this is being reused + var attributes = new Dictionary(); + for (var vertexIndex = 0; vertexIndex < mesh.VertexCount; vertexIndex++) + { + attributes.Clear(); + + foreach (var element in sortedElements) + attributes[(MdlFile.VertexUsage)element.Usage] = ReadVertexAttribute(streams[element.Stream], element); + + var vertexGeometry = BuildVertexGeometry(geometryType, attributes); + + var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), new VertexEmpty())!; + vertices.Add(vertexBuilder); + } + + return vertices; + } + + // todo i fucking hate this `object` type god i hate c# gimme sum types pls + private object ReadVertexAttribute(BinaryReader reader, MdlStructs.VertexElement element) + { + return (MdlFile.VertexType)element.Type switch + { + 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.UInt => reader.ReadBytes(4), + MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f), + MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()), + MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf()), + + _ => throw new ArgumentOutOfRangeException() + }; + } + + private Type GetGeometryType(IReadOnlySet usages) + { + if (!usages.Contains(MdlFile.VertexUsage.Position)) + throw new Exception("Mesh does not contain position vertex elements."); + + if (!usages.Contains(MdlFile.VertexUsage.Normal)) + return typeof(VertexPosition); + + if (!usages.Contains(MdlFile.VertexUsage.Tangent1)) + return typeof(VertexPositionNormal); + + return typeof(VertexPositionNormalTangent); + } + + private IVertexGeometry BuildVertexGeometry(Type geometryType, IReadOnlyDictionary attributes) + { + if (geometryType == typeof(VertexPosition)) + return new VertexPosition( + ToVector3(attributes[MdlFile.VertexUsage.Position]) + ); + + if (geometryType == typeof(VertexPositionNormal)) + return new VertexPositionNormal( + ToVector3(attributes[MdlFile.VertexUsage.Position]), + ToVector3(attributes[MdlFile.VertexUsage.Normal]) + ); + + if (geometryType == typeof(VertexPositionNormalTangent)) + return new VertexPositionNormalTangent( + ToVector3(attributes[MdlFile.VertexUsage.Position]), + ToVector3(attributes[MdlFile.VertexUsage.Normal]), + ToVector4(attributes[MdlFile.VertexUsage.Tangent1]) + ); + + throw new Exception($"Unknown geometry type {geometryType}."); + } + + private Vector3 ToVector3(object data) + { + return data switch + { + Vector2 v2 => new Vector3(v2.X, v2.Y, 0), + Vector3 v3 => v3, + Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z), + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + }; + } + + private Vector4 ToVector4(object data) + { + return data switch + { + Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), + Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1), + Vector4 v4 => v4, + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + }; + } + public bool Equals(IAction? other) { if (other is not ExportToGltfAction rhs) return false; - // TODO: compare configuration + // TODO: compare configuration and such return true; } } From bc24110c9f6860ab5da2082174911a9eb992c4ae Mon Sep 17 00:00:00 2001 From: ackwell Date: Thu, 28 Dec 2023 02:15:14 +1100 Subject: [PATCH 06/35] Move mesh logic to new file, export all meshes --- Penumbra/Import/Models/MeshConverter.cs | 191 ++++++++++++++++++++++++ Penumbra/Import/Models/ModelManager.cs | 183 ++--------------------- 2 files changed, 205 insertions(+), 169 deletions(-) create mode 100644 Penumbra/Import/Models/MeshConverter.cs diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs new file mode 100644 index 00000000..2fcd2816 --- /dev/null +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -0,0 +1,191 @@ +using System.Collections.Immutable; +using Lumina.Data.Parsing; +using Lumina.Extensions; +using Penumbra.GameData.Files; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; + +namespace Penumbra.Import.Modules; + +public sealed class MeshConverter +{ + public static IMeshBuilder ToGltf(MdlFile mdl, byte lod, ushort meshIndex) + { + var self = new MeshConverter(mdl, lod, meshIndex); + return self.BuildMesh(); + } + + private const byte MaximumMeshBufferStreams = 3; + + private readonly MdlFile _mdl; + private readonly byte _lod; + private readonly ushort _meshIndex; + private MdlStructs.MeshStruct Mesh => _mdl.Meshes[_meshIndex]; + + private readonly Type _geometryType; + + private MeshConverter(MdlFile mdl, byte lod, ushort meshIndex) + { + _mdl = mdl; + _lod = lod; + _meshIndex = meshIndex; + + var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements + .Select(element => (MdlFile.VertexUsage)element.Usage) + .ToImmutableHashSet(); + + _geometryType = GetGeometryType(usages); + } + + private IMeshBuilder BuildMesh() + { + var indices = BuildIndices(); + var vertices = BuildVertices(); + + var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( + typeof(MaterialBuilder), + _geometryType, + typeof(VertexEmpty), + typeof(VertexEmpty) + ); + var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, $"mesh{_meshIndex}")!; + + // 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); + + // All XIV meshes use triangle lists. + // TODO: split by submeshes + for (var indexOffset = 0; indexOffset < Mesh.IndexCount; indexOffset += 3) + primitiveBuilder.AddTriangle( + vertices[indices[indexOffset + 0]], + vertices[indices[indexOffset + 1]], + vertices[indices[indexOffset + 2]] + ); + + return meshBuilder; + } + + private IReadOnlyList BuildIndices() + { + var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + reader.Seek(_mdl.IndexOffset[_lod] + Mesh.StartIndex * sizeof(ushort)); + return reader.ReadStructuresAsArray((int)Mesh.IndexCount); + } + + private IReadOnlyList BuildVertices() + { + var vertexBuilderType = typeof(VertexBuilder<,,>) + .MakeGenericType(_geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); + + // NOTE: This assumes that buffer streams are tightly packed, which has proven safe across tested files. If this assumption is broken, seeks will need to be moved into the vertex element loop. + var streams = new BinaryReader[MaximumMeshBufferStreams]; + for (var streamIndex = 0; streamIndex < MaximumMeshBufferStreams; streamIndex++) + { + streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + streams[streamIndex].Seek(_mdl.VertexOffset[_lod] + Mesh.VertexBufferOffset[streamIndex]); + } + + var sortedElements = _mdl.VertexDeclarations[_meshIndex].VertexElements + .OrderBy(element => element.Offset) + .Select(element => ((MdlFile.VertexUsage)element.Usage, element)) + .ToList(); + + var vertices = new List(); + + var attributes = new Dictionary(); + for (var vertexIndex = 0; vertexIndex < Mesh.VertexCount; vertexIndex++) + { + attributes.Clear(); + + foreach (var (usage, element) in sortedElements) + attributes[usage] = ReadVertexAttribute(streams[element.Stream], element); + + var vertexGeometry = BuildVertexGeometry(attributes); + + var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), new VertexEmpty())!; + vertices.Add(vertexBuilder); + } + + return vertices; + } + + private object ReadVertexAttribute(BinaryReader reader, MdlStructs.VertexElement element) + { + return (MdlFile.VertexType)element.Type switch + { + 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.UInt => reader.ReadBytes(4), + MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f), + MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()), + MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf()), + + _ => throw new ArgumentOutOfRangeException() + }; + } + + private Type GetGeometryType(IReadOnlySet usages) + { + if (!usages.Contains(MdlFile.VertexUsage.Position)) + throw new Exception("Mesh does not contain position vertex elements."); + + if (!usages.Contains(MdlFile.VertexUsage.Normal)) + return typeof(VertexPosition); + + if (!usages.Contains(MdlFile.VertexUsage.Tangent1)) + return typeof(VertexPositionNormal); + + return typeof(VertexPositionNormalTangent); + } + + private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary attributes) + { + if (_geometryType == typeof(VertexPosition)) + return new VertexPosition( + ToVector3(attributes[MdlFile.VertexUsage.Position]) + ); + + if (_geometryType == typeof(VertexPositionNormal)) + return new VertexPositionNormal( + ToVector3(attributes[MdlFile.VertexUsage.Position]), + ToVector3(attributes[MdlFile.VertexUsage.Normal]) + ); + + if (_geometryType == typeof(VertexPositionNormalTangent)) + return new VertexPositionNormalTangent( + ToVector3(attributes[MdlFile.VertexUsage.Position]), + ToVector3(attributes[MdlFile.VertexUsage.Normal]), + FixTangentVector(ToVector4(attributes[MdlFile.VertexUsage.Tangent1])) + ); + + throw new Exception($"Unknown geometry type {_geometryType}."); + } + + // Some tangent W values that should be -1 are stored as 0. + private Vector4 FixTangentVector(Vector4 tangent) + => tangent with { W = tangent.W == 1 ? 1 : -1 }; + + private Vector3 ToVector3(object data) + => data switch + { + Vector2 v2 => new Vector3(v2.X, v2.Y, 0), + Vector3 v3 => v3, + Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z), + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + }; + + private Vector4 ToVector4(object data) + => data switch + { + Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), + Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1), + Vector4 v4 => v4, + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + }; +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index af285cbb..429aad54 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,11 +1,6 @@ -using System.Collections.Immutable; -using Lumina.Data.Parsing; -using Lumina.Extensions; using OtterGui.Tasks; using Penumbra.GameData.Files; -using SharpGLTF.Geometry; -using SharpGLTF.Geometry.VertexTypes; -using SharpGLTF.Materials; +using Penumbra.Import.Modules; using SharpGLTF.Scenes; namespace Penumbra.Import.Models; @@ -64,175 +59,25 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public void Execute(CancellationToken token) { - // lol, lmao even - var meshIndex = 2; - var lod = 0; - - var elements = _mdl.VertexDeclarations[meshIndex].VertexElements; - - var usages = elements - .Select(element => (MdlFile.VertexUsage)element.Usage) - .ToImmutableHashSet(); - var geometryType = GetGeometryType(usages); - - // TODO: probablly can do this a bit later but w/e - var meshBuilderType = typeof(MeshBuilder<,,>).MakeGenericType(geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); - var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, "mesh2")!; - - var material = new MaterialBuilder() - .WithDoubleSide(true) - .WithMetallicRoughnessShader() - .WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 1, 1, 1)); - - var mesh = _mdl.Meshes[meshIndex]; - var submesh = _mdl.SubMeshes[mesh.SubMeshIndex]; // just first for now - - var positionVertexElement = _mdl.VertexDeclarations[meshIndex].VertexElements - .Where(decl => (MdlFile.VertexUsage)decl.Usage == MdlFile.VertexUsage.Position) - .First(); - - // reading in the entire indices list - var dataReader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - dataReader.Seek(_mdl.IndexOffset[lod]); - var indices = dataReader.ReadStructuresAsArray((int)_mdl.IndexBufferSize[lod] / sizeof(ushort)); - - // read in verts for this mesh - var vertices = BuildVertices(lod, mesh, _mdl.VertexDeclarations[meshIndex].VertexElements, geometryType); - - // build a primitive for the submesh - var primitiveBuilder = meshBuilder.UsePrimitive(material); - // they're all tri list - for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) - { - var index = indexOffset + submesh.IndexOffset; - - primitiveBuilder.AddTriangle( - vertices[indices[index + 0]], - vertices[indices[index + 1]], - vertices[indices[index + 2]] - ); - } - var scene = new SceneBuilder(); - scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); + + // TODO: group by LoD in output tree + for (byte lodIndex = 0; lodIndex < _mdl.LodCount; lodIndex++) + { + var lod = _mdl.Lods[lodIndex]; + + // TODO: consider other types? + for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) + { + var meshBuilder = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset)); + scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); + } + } var model = scene.ToGltf2(); model.SaveGLTF(_path); } - // todo all of this is mesh specific so probably should be a class per mesh? with the lod, too? - private IReadOnlyList BuildVertices(int lod, MdlStructs.MeshStruct mesh, IEnumerable elements, Type geometryType) - { - var vertexBuilderType = typeof(VertexBuilder<,,>).MakeGenericType(geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); - - // todo: demagic the 3 - // todo note this assumes that the buffer streams are tightly packed. that's a safe assumption - right? lumina assumes as much - var streams = new BinaryReader[3]; - for (var streamIndex = 0; streamIndex < 3; streamIndex++) - { - streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - streams[streamIndex].Seek(_mdl.VertexOffset[lod] + mesh.VertexBufferOffset[streamIndex]); - } - - var sortedElements = elements - .OrderBy(element => element.Offset) - .ToList(); - - var vertices = new List(); - - // note this is being reused - var attributes = new Dictionary(); - for (var vertexIndex = 0; vertexIndex < mesh.VertexCount; vertexIndex++) - { - attributes.Clear(); - - foreach (var element in sortedElements) - attributes[(MdlFile.VertexUsage)element.Usage] = ReadVertexAttribute(streams[element.Stream], element); - - var vertexGeometry = BuildVertexGeometry(geometryType, attributes); - - var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), new VertexEmpty())!; - vertices.Add(vertexBuilder); - } - - return vertices; - } - - // todo i fucking hate this `object` type god i hate c# gimme sum types pls - private object ReadVertexAttribute(BinaryReader reader, MdlStructs.VertexElement element) - { - return (MdlFile.VertexType)element.Type switch - { - 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.UInt => reader.ReadBytes(4), - MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f), - MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()), - MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf()), - - _ => throw new ArgumentOutOfRangeException() - }; - } - - private Type GetGeometryType(IReadOnlySet usages) - { - if (!usages.Contains(MdlFile.VertexUsage.Position)) - throw new Exception("Mesh does not contain position vertex elements."); - - if (!usages.Contains(MdlFile.VertexUsage.Normal)) - return typeof(VertexPosition); - - if (!usages.Contains(MdlFile.VertexUsage.Tangent1)) - return typeof(VertexPositionNormal); - - return typeof(VertexPositionNormalTangent); - } - - private IVertexGeometry BuildVertexGeometry(Type geometryType, IReadOnlyDictionary attributes) - { - if (geometryType == typeof(VertexPosition)) - return new VertexPosition( - ToVector3(attributes[MdlFile.VertexUsage.Position]) - ); - - if (geometryType == typeof(VertexPositionNormal)) - return new VertexPositionNormal( - ToVector3(attributes[MdlFile.VertexUsage.Position]), - ToVector3(attributes[MdlFile.VertexUsage.Normal]) - ); - - if (geometryType == typeof(VertexPositionNormalTangent)) - return new VertexPositionNormalTangent( - ToVector3(attributes[MdlFile.VertexUsage.Position]), - ToVector3(attributes[MdlFile.VertexUsage.Normal]), - ToVector4(attributes[MdlFile.VertexUsage.Tangent1]) - ); - - throw new Exception($"Unknown geometry type {geometryType}."); - } - - private Vector3 ToVector3(object data) - { - return data switch - { - Vector2 v2 => new Vector3(v2.X, v2.Y, 0), - Vector3 v3 => v3, - Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z), - _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") - }; - } - - private Vector4 ToVector4(object data) - { - return data switch - { - Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), - Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1), - Vector4 v4 => v4, - _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") - }; - } - public bool Equals(IAction? other) { if (other is not ExportToGltfAction rhs) From 635d606112979cf7661938a88afd711e92357cae Mon Sep 17 00:00:00 2001 From: ackwell Date: Thu, 28 Dec 2023 15:51:20 +1100 Subject: [PATCH 07/35] Initial skeleton tests --- Penumbra/Import/Models/HavokConverter.cs | 141 +++++++++++++ Penumbra/Import/Models/ModelManager.cs | 187 +++++++++++++++++- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 4 + 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 Penumbra/Import/Models/HavokConverter.cs diff --git a/Penumbra/Import/Models/HavokConverter.cs b/Penumbra/Import/Models/HavokConverter.cs new file mode 100644 index 00000000..515c6f97 --- /dev/null +++ b/Penumbra/Import/Models/HavokConverter.cs @@ -0,0 +1,141 @@ +using FFXIVClientStructs.Havok; + +namespace Penumbra.Import.Models; + +// TODO: where should this live? interop i guess, in penum? or game data? +public unsafe class HavokConverter +{ + /// Creates a temporary file and returns its path. + /// Path to a temporary file. + private string CreateTempFile() + { + var s = File.Create(Path.GetTempFileName()); + s.Close(); + return s.Name; + } + + /// Converts a .hkx file to a .xml file. + /// A byte array representing the .hkx file. + /// A string representing the .xml file. + /// Thrown if parsing the .hkx file fails. + /// Thrown if writing the .xml file fails. + public string HkxToXml(byte[] hkx) + { + var tempHkx = CreateTempFile(); + File.WriteAllBytes(tempHkx, hkx); + + var resource = Read(tempHkx); + File.Delete(tempHkx); + + if (resource == null) throw new Exception("HavokReadException"); + + var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.TextFormat + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllText(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// Converts a .xml file to a .hkx file. + /// A string representing the .xml file. + /// A byte array representing the .hkx file. + /// Thrown if parsing the .xml file fails. + /// Thrown if writing the .hkx file fails. + public byte[] XmlToHkx(string xml) + { + var tempXml = CreateTempFile(); + File.WriteAllText(tempXml, xml); + + var resource = Read(tempXml); + File.Delete(tempXml); + + if (resource == null) throw new Exception("HavokReadException"); + + var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllBytes(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// + /// Parses a serialized file into an hkResource*. + /// The type is guessed automatically by Havok. + /// This pointer might be null - you should check for that. + /// + /// Path to a file on the filesystem. + /// A (potentially null) pointer to an hkResource. + private hkResource* Read(string filePath) + { + var path = Marshal.StringToHGlobalAnsi(filePath); + + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + + var loadOptions = stackalloc hkSerializeUtil.LoadOptions[1]; + loadOptions->Flags = new() { Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; + loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + // TODO: probably can loadfrombuffer this + var resource = hkSerializeUtil.LoadFromFile((byte*)path, null, loadOptions); + return resource; + } + + /// Serializes an hkResource* to a temporary file. + /// A pointer to the hkResource, opened through Read(). + /// Flags representing how to serialize the file. + /// An opened FileStream of a temporary file. You are expected to read the file and delete it. + /// Thrown if accessing the root level container fails. + /// Thrown if an unknown failure in writing occurs. + private FileStream Write( + hkResource* resource, + hkSerializeUtil.SaveOptionBits optionBits + ) + { + var tempFile = CreateTempFile(); + var path = Marshal.StringToHGlobalAnsi(tempFile); + var oStream = new hkOstream(); + oStream.Ctor((byte*)path); + + var result = stackalloc hkResult[1]; + + var saveOptions = new hkSerializeUtil.SaveOptions() + { + Flags = new() { Storage = (int)optionBits } + }; + + + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + try + { + var name = "hkRootLevelContainer"; + + var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry); + if (resourcePtr == null) throw new Exception("HavokWriteException"); + + var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name); + if (hkRootLevelContainerClass == null) throw new Exception("HavokWriteException"); + + hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions); + } + finally { oStream.Dtor(); } + + if (result->Result == hkResult.hkResultEnum.Failure) throw new Exception("HavokFailureException"); + + return new FileStream(tempFile, FileMode.Open); + } +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 429aad54..c4c46353 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,18 +1,26 @@ +using System.Xml; +using Dalamud.Plugin.Services; +using Lumina.Data; +using Lumina.Extensions; +using OtterGui; using OtterGui.Tasks; using Penumbra.GameData.Files; using Penumbra.Import.Modules; using SharpGLTF.Scenes; +using SharpGLTF.Transforms; namespace Penumbra.Import.Models; public sealed class ModelManager : SingleTaskQueue, IDisposable { + private readonly IDataManager _gameData; + private readonly ConcurrentDictionary _tasks = new(); private bool _disposed = false; - public ModelManager() + public ModelManager(IDataManager gameData) { - // + _gameData = gameData; } public void Dispose() @@ -46,6 +54,181 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public Task ExportToGltf(MdlFile mdl, string path) => Enqueue(new ExportToGltfAction(mdl, path)); + public void SkeletonTest() + { + var sklbPath = "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb"; + + var something = _gameData.GetFile(sklbPath); + + var fuck = new HavokConverter(); + var killme = fuck.HkxToXml(something.Skeleton); + + var doc = new XmlDocument(); + doc.LoadXml(killme); + + var skels = doc.SelectNodes("/hktagfile/object[@type='hkaSkeleton']") + .Cast() + .Select(element => new Skel(element)) + .ToArray(); + + // todo: look into how this is selecting the skel - only first? + var animSkel = doc.SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']") + .SelectNodes("array[@name='skeletons']") + .Cast() + .First(); + var mainSkelId = animSkel.ChildNodes[0].InnerText; + + var mainSkel = skels.First(skel => skel.Id == mainSkelId); + + // this is atrocious + NodeBuilder? root = null; + var boneMap = new Dictionary(); + for (var boneIndex = 0; boneIndex < mainSkel.BoneNames.Length; boneIndex++) + { + var name = mainSkel.BoneNames[boneIndex]; + if (boneMap.ContainsKey(name)) continue; + + var node = new NodeBuilder(name); + + var rp = mainSkel.ReferencePose[boneIndex]; + var transform = new AffineTransform( + new Vector3(rp[8], rp[9], rp[10]), + new Quaternion(rp[4], rp[5], rp[6], rp[7]), + new Vector3([rp[0], rp[1], rp[2]]) + ); + node.SetLocalTransform(transform, false); + + boneMap[name] = node; + + var parentId = mainSkel.ParentIndices[boneIndex]; + if (parentId == -1) + { + root = node; + continue; + } + + var parent = boneMap[mainSkel.BoneNames[parentId]]; + parent.AddNode(node); + } + + var scene = new SceneBuilder(); + scene.AddNode(root); + var model = scene.ToGltf2(); + model.SaveGLTF(@"C:\Users\ackwell\blender\gltf-tests\zoingo.gltf"); + + Penumbra.Log.Information($"zoingo {string.Join(',', mainSkel.ParentIndices)}"); + } + + // this is garbage that should be in gamedata + + private sealed class Garbage : FileResource + { + public byte[] Skeleton; + + public override void LoadFile() + { + var magic = Reader.ReadUInt32(); + if (magic != 0x736B6C62) + throw new InvalidDataException("Invalid sklb magic"); + + // todo do this all properly jfc + var version = Reader.ReadUInt32(); + + var oldHeader = version switch { + 0x31313030 or 0x31313130 or 0x31323030 => true, + 0x31333030 => false, + _ => throw new InvalidDataException($"Unknown version {version}") + }; + + // Skeleton offset directly follows the layer offset. + uint skeletonOffset; + if (oldHeader) + { + Reader.ReadInt16(); + skeletonOffset = Reader.ReadUInt16(); + } + else + { + Reader.ReadUInt32(); + skeletonOffset = Reader.ReadUInt32(); + } + + Reader.Seek(skeletonOffset); + Skeleton = Reader.ReadBytes((int)(Reader.BaseStream.Length - skeletonOffset)); + } + } + + private class Skel + { + public readonly string Id; + + public readonly float[][] ReferencePose; + public readonly int[] ParentIndices; + public readonly string[] BoneNames; + + // TODO: this shouldn't have any reference to the skel xml - i should just make it a bare class that can be repr'd in gamedata or whatever + public Skel(XmlElement el) + { + Id = el.GetAttribute("id"); + + ReferencePose = ReadReferencePose(el); + ParentIndices = ReadParentIndices(el); + BoneNames = ReadBoneNames(el); + } + + private float[][] ReadReferencePose(XmlElement el) + { + return ReadArray( + (XmlElement)el.SelectSingleNode("array[@name='referencePose']"), + ReadVec12 + ); + } + + private float[] ReadVec12(XmlElement el) + { + return el.ChildNodes + .Cast() + .Where(node => node.NodeType != XmlNodeType.Comment) + .Select(node => { + var t = node.InnerText.Trim()[1..]; + // todo: surely there's a less shit way to do this i mean seriously + return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(t, NumberStyles.HexNumber))); + }) + .ToArray(); + } + + private int[] ReadParentIndices(XmlElement el) + { + // todo: would be neat to genericise array between bare and children + return el.SelectSingleNode("array[@name='parentIndices']") + .InnerText + .Split(new char[] {' ', '\n'}, StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); + } + + private string[] ReadBoneNames(XmlElement el) + { + return ReadArray( + (XmlElement)el.SelectSingleNode("array[@name='bones']"), + el => el.SelectSingleNode("string[@name='name']").InnerText + ); + } + + private T[] ReadArray(XmlElement el, Func convert) + { + var size = int.Parse(el.GetAttribute("size")); + + var array = new T[size]; + foreach (var (node, index) in el.ChildNodes.Cast().WithIndex()) + { + array[index] = convert(node); + } + + return array; + } + } + private class ExportToGltfAction : IAction { private readonly MdlFile _mdl; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index b64b4f40..f0cc34a6 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -41,6 +41,10 @@ public partial class ModEditWindow var task = _models.ExportToGltf(file, "C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); task.ContinueWith(_ => _pendingIo = false); } + if (ImGui.Button("zoingo boingo")) + { + _models.SkeletonTest(); + } var ret = false; From d646c5e4b5ffc256c768258ff4c72765877311af Mon Sep 17 00:00:00 2001 From: ackwell Date: Thu, 28 Dec 2023 16:49:44 +1100 Subject: [PATCH 08/35] Resolve skeleton path --- Penumbra/Import/Models/ModelManager.cs | 43 ++++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index c4c46353..9074b67a 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,11 +1,12 @@ using System.Xml; using Dalamud.Plugin.Services; -using Lumina.Data; using Lumina.Extensions; using OtterGui; using OtterGui.Tasks; +using Penumbra.Collections.Manager; using Penumbra.GameData.Files; using Penumbra.Import.Modules; +using Penumbra.String.Classes; using SharpGLTF.Scenes; using SharpGLTF.Transforms; @@ -14,13 +15,15 @@ namespace Penumbra.Import.Models; public sealed class ModelManager : SingleTaskQueue, IDisposable { private readonly IDataManager _gameData; + private readonly ActiveCollectionData _activeCollectionData; private readonly ConcurrentDictionary _tasks = new(); private bool _disposed = false; - public ModelManager(IDataManager gameData) + public ModelManager(IDataManager gameData, ActiveCollectionData activeCollectionData) { _gameData = gameData; + _activeCollectionData = activeCollectionData; } public void Dispose() @@ -58,7 +61,18 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable { var sklbPath = "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb"; - var something = _gameData.GetFile(sklbPath); + var succeeded = Utf8GamePath.FromString(sklbPath, out var utf8Path, true); + var testResolve = _activeCollectionData.Current.ResolvePath(utf8Path); + Penumbra.Log.Information($"resolved: {(testResolve == null ? "NULL" : testResolve.ToString())}"); + + // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... + var bytes = testResolve switch + { + null => _gameData.GetFile(sklbPath).Data, + FullPath path => File.ReadAllBytes(path.ToPath()) + }; + + var something = new Garbage(bytes); var fuck = new HavokConverter(); var killme = fuck.HkxToXml(something.Skeleton); @@ -121,18 +135,21 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable // this is garbage that should be in gamedata - private sealed class Garbage : FileResource + private sealed class Garbage { public byte[] Skeleton; - public override void LoadFile() + public Garbage(byte[] data) { - var magic = Reader.ReadUInt32(); + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + var magic = reader.ReadUInt32(); if (magic != 0x736B6C62) throw new InvalidDataException("Invalid sklb magic"); // todo do this all properly jfc - var version = Reader.ReadUInt32(); + var version = reader.ReadUInt32(); var oldHeader = version switch { 0x31313030 or 0x31313130 or 0x31323030 => true, @@ -144,17 +161,17 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable uint skeletonOffset; if (oldHeader) { - Reader.ReadInt16(); - skeletonOffset = Reader.ReadUInt16(); + reader.ReadInt16(); + skeletonOffset = reader.ReadUInt16(); } else { - Reader.ReadUInt32(); - skeletonOffset = Reader.ReadUInt32(); + reader.ReadUInt32(); + skeletonOffset = reader.ReadUInt32(); } - Reader.Seek(skeletonOffset); - Skeleton = Reader.ReadBytes((int)(Reader.BaseStream.Length - skeletonOffset)); + reader.Seek(skeletonOffset); + Skeleton = reader.ReadBytes((int)(reader.BaseStream.Length - skeletonOffset)); } } From d7cac3e09a9f531261cf60e8bf5020149bc4073e Mon Sep 17 00:00:00 2001 From: ackwell Date: Fri, 29 Dec 2023 02:31:02 +1100 Subject: [PATCH 09/35] Clean up and refactor skeleton logic --- Penumbra/Import/Models/ModelManager.cs | 171 +++----------------- Penumbra/Import/Models/Skeleton.cs | 25 +++ Penumbra/Import/Models/SkeletonConverter.cs | 132 +++++++++++++++ Penumbra/Import/Models/SklbFile.cs | 44 +++++ 4 files changed, 223 insertions(+), 149 deletions(-) create mode 100644 Penumbra/Import/Models/Skeleton.cs create mode 100644 Penumbra/Import/Models/SkeletonConverter.cs create mode 100644 Penumbra/Import/Models/SklbFile.cs diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 9074b67a..6a9ef334 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -61,6 +61,8 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable { var sklbPath = "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb"; + // NOTE: to resolve game path from _mod_, will need to wire the mod class via the modeditwindow to the model editor, through to here. + // NOTE: to get the game path for a model we'll probably need to use a reverse resolve - there's no guarantee for a modded model that they're named per game path, nor that there's only one name. var succeeded = Utf8GamePath.FromString(sklbPath, out var utf8Path, true); var testResolve = _activeCollectionData.Current.ResolvePath(utf8Path); Penumbra.Log.Information($"resolved: {(testResolve == null ? "NULL" : testResolve.ToString())}"); @@ -72,56 +74,40 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable FullPath path => File.ReadAllBytes(path.ToPath()) }; - var something = new Garbage(bytes); + var sklb = new SklbFile(bytes); - var fuck = new HavokConverter(); - var killme = fuck.HkxToXml(something.Skeleton); + // TODO: Consider making these static methods. + var havokConverter = new HavokConverter(); + var xml = havokConverter.HkxToXml(sklb.Skeleton); - var doc = new XmlDocument(); - doc.LoadXml(killme); + var skeletonConverter = new SkeletonConverter(); + var skeleton = skeletonConverter.FromXml(xml); - var skels = doc.SelectNodes("/hktagfile/object[@type='hkaSkeleton']") - .Cast() - .Select(element => new Skel(element)) - .ToArray(); - - // todo: look into how this is selecting the skel - only first? - var animSkel = doc.SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']") - .SelectNodes("array[@name='skeletons']") - .Cast() - .First(); - var mainSkelId = animSkel.ChildNodes[0].InnerText; - - var mainSkel = skels.First(skel => skel.Id == mainSkelId); - - // this is atrocious + // this is (less) atrocious NodeBuilder? root = null; var boneMap = new Dictionary(); - for (var boneIndex = 0; boneIndex < mainSkel.BoneNames.Length; boneIndex++) + for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) { - var name = mainSkel.BoneNames[boneIndex]; - if (boneMap.ContainsKey(name)) continue; + var bone = skeleton.Bones[boneIndex]; - var node = new NodeBuilder(name); + if (boneMap.ContainsKey(bone.Name)) continue; - var rp = mainSkel.ReferencePose[boneIndex]; - var transform = new AffineTransform( - new Vector3(rp[8], rp[9], rp[10]), - new Quaternion(rp[4], rp[5], rp[6], rp[7]), - new Vector3([rp[0], rp[1], rp[2]]) - ); - node.SetLocalTransform(transform, false); + var node = new NodeBuilder(bone.Name); + boneMap[bone.Name] = node; - boneMap[name] = node; + node.SetLocalTransform(new AffineTransform( + bone.Transform.Scale, + bone.Transform.Rotation, + bone.Transform.Translation + ), false); - var parentId = mainSkel.ParentIndices[boneIndex]; - if (parentId == -1) + if (bone.ParentIndex == -1) { root = node; continue; } - var parent = boneMap[mainSkel.BoneNames[parentId]]; + var parent = boneMap[skeleton.Bones[bone.ParentIndex].Name]; parent.AddNode(node); } @@ -130,120 +116,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable var model = scene.ToGltf2(); model.SaveGLTF(@"C:\Users\ackwell\blender\gltf-tests\zoingo.gltf"); - Penumbra.Log.Information($"zoingo {string.Join(',', mainSkel.ParentIndices)}"); - } - - // this is garbage that should be in gamedata - - private sealed class Garbage - { - public byte[] Skeleton; - - public Garbage(byte[] data) - { - using var stream = new MemoryStream(data); - using var reader = new BinaryReader(stream); - - var magic = reader.ReadUInt32(); - if (magic != 0x736B6C62) - throw new InvalidDataException("Invalid sklb magic"); - - // todo do this all properly jfc - var version = reader.ReadUInt32(); - - var oldHeader = version switch { - 0x31313030 or 0x31313130 or 0x31323030 => true, - 0x31333030 => false, - _ => throw new InvalidDataException($"Unknown version {version}") - }; - - // Skeleton offset directly follows the layer offset. - uint skeletonOffset; - if (oldHeader) - { - reader.ReadInt16(); - skeletonOffset = reader.ReadUInt16(); - } - else - { - reader.ReadUInt32(); - skeletonOffset = reader.ReadUInt32(); - } - - reader.Seek(skeletonOffset); - Skeleton = reader.ReadBytes((int)(reader.BaseStream.Length - skeletonOffset)); - } - } - - private class Skel - { - public readonly string Id; - - public readonly float[][] ReferencePose; - public readonly int[] ParentIndices; - public readonly string[] BoneNames; - - // TODO: this shouldn't have any reference to the skel xml - i should just make it a bare class that can be repr'd in gamedata or whatever - public Skel(XmlElement el) - { - Id = el.GetAttribute("id"); - - ReferencePose = ReadReferencePose(el); - ParentIndices = ReadParentIndices(el); - BoneNames = ReadBoneNames(el); - } - - private float[][] ReadReferencePose(XmlElement el) - { - return ReadArray( - (XmlElement)el.SelectSingleNode("array[@name='referencePose']"), - ReadVec12 - ); - } - - private float[] ReadVec12(XmlElement el) - { - return el.ChildNodes - .Cast() - .Where(node => node.NodeType != XmlNodeType.Comment) - .Select(node => { - var t = node.InnerText.Trim()[1..]; - // todo: surely there's a less shit way to do this i mean seriously - return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(t, NumberStyles.HexNumber))); - }) - .ToArray(); - } - - private int[] ReadParentIndices(XmlElement el) - { - // todo: would be neat to genericise array between bare and children - return el.SelectSingleNode("array[@name='parentIndices']") - .InnerText - .Split(new char[] {' ', '\n'}, StringSplitOptions.RemoveEmptyEntries) - .Select(int.Parse) - .ToArray(); - } - - private string[] ReadBoneNames(XmlElement el) - { - return ReadArray( - (XmlElement)el.SelectSingleNode("array[@name='bones']"), - el => el.SelectSingleNode("string[@name='name']").InnerText - ); - } - - private T[] ReadArray(XmlElement el, Func convert) - { - var size = int.Parse(el.GetAttribute("size")); - - var array = new T[size]; - foreach (var (node, index) in el.ChildNodes.Cast().WithIndex()) - { - array[index] = convert(node); - } - - return array; - } + Penumbra.Log.Information($"zoingo!"); } private class ExportToGltfAction : IAction diff --git a/Penumbra/Import/Models/Skeleton.cs b/Penumbra/Import/Models/Skeleton.cs new file mode 100644 index 00000000..fb5c8284 --- /dev/null +++ b/Penumbra/Import/Models/Skeleton.cs @@ -0,0 +1,25 @@ +namespace Penumbra.Import.Models; + +// TODO: this should almost certainly live in gamedata. if not, it should at _least_ be adjacent to the model handling. +public class Skeleton +{ + public Bone[] Bones; + + public Skeleton(Bone[] bones) + { + Bones = bones; + } + + public struct Bone + { + public string Name; + public int ParentIndex; + public Transform Transform; + } + + public struct Transform { + public Vector3 Scale; + public Quaternion Rotation; + public Vector3 Translation; + } +} diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs new file mode 100644 index 00000000..d54b0294 --- /dev/null +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -0,0 +1,132 @@ +using System.Xml; +using OtterGui; + +namespace Penumbra.Import.Models; + +// TODO: tempted to say that this living here is more okay? that or next to havok converter, wherever that ends up. +public class SkeletonConverter +{ + public Skeleton FromXml(string xml) + { + var document = new XmlDocument(); + document.LoadXml(xml); + + var mainSkeletonId = GetMainSkeletonId(document); + + var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']"); + if (skeletonNode == null) + throw new InvalidDataException(); + + var referencePose = ReadReferencePose(skeletonNode); + var parentIndices = ReadParentIndices(skeletonNode); + var boneNames = ReadBoneNames(skeletonNode); + + if (boneNames.Length != parentIndices.Length || boneNames.Length != referencePose.Length) + throw new InvalidDataException(); + + var bones = referencePose + .Zip(parentIndices, boneNames) + .Select(values => + { + var (transform, parentIndex, name) = values; + return new Skeleton.Bone() + { + Transform = transform, + ParentIndex = parentIndex, + Name = name, + }; + }) + .ToArray(); + + return new Skeleton(bones); + } + + /// Get the main skeleton ID for a given skeleton document. + /// XML skeleton document. + private string GetMainSkeletonId(XmlNode node) + { + var animationSkeletons = node + .SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']/array[@name='skeletons']")? + .ChildNodes; + + if (animationSkeletons?.Count != 1) + throw new Exception($"Assumption broken: Expected 1 hkaAnimationContainer skeleton, got {animationSkeletons?.Count ?? 0}"); + + return animationSkeletons[0]!.InnerText; + } + + /// Read the reference pose transforms for a skeleton. + /// XML node for the skeleton. + private Skeleton.Transform[] ReadReferencePose(XmlNode node) + { + return ReadArray( + CheckExists(node.SelectSingleNode("array[@name='referencePose']")), + node => + { + var raw = ReadVec12(node); + return new Skeleton.Transform() + { + Translation = new(raw[0], raw[1], raw[2]), + Rotation = new(raw[4], raw[5], raw[6], raw[7]), + Scale = new(raw[8], raw[9], raw[10]), + }; + } + ); + } + + private float[] ReadVec12(XmlNode node) + { + var array = node.ChildNodes + .Cast() + .Where(node => node.NodeType != XmlNodeType.Comment) + .Select(node => + { + var text = node.InnerText.Trim()[1..]; + // TODO: surely there's a less shit way to do this i mean seriously + return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(text, NumberStyles.HexNumber))); + }) + .ToArray(); + + if (array.Length != 12) + throw new InvalidDataException(); + + return array; + } + + private int[] ReadParentIndices(XmlNode node) + { + // todo: would be neat to genericise array between bare and children + return CheckExists(node.SelectSingleNode("array[@name='parentIndices']")) + .InnerText + .Split(new char[] { ' ', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); + } + + private string[] ReadBoneNames(XmlNode node) + { + return ReadArray( + CheckExists(node.SelectSingleNode("array[@name='bones']")), + node => CheckExists(node.SelectSingleNode("string[@name='name']")).InnerText + ); + } + + private T[] ReadArray(XmlNode node, Func convert) + { + var element = (XmlElement)node; + + var size = int.Parse(element.GetAttribute("size")); + + var array = new T[size]; + foreach (var (childNode, index) in element.ChildNodes.Cast().WithIndex()) + array[index] = convert(childNode); + + return array; + } + + private static T CheckExists(T? value) + { + ArgumentNullException.ThrowIfNull(value); + return value; + } +} diff --git a/Penumbra/Import/Models/SklbFile.cs b/Penumbra/Import/Models/SklbFile.cs new file mode 100644 index 00000000..9ae6f7db --- /dev/null +++ b/Penumbra/Import/Models/SklbFile.cs @@ -0,0 +1,44 @@ +using Lumina.Extensions; + +namespace Penumbra.Import.Models; + +// TODO: yeah this goes in gamedata. +public class SklbFile +{ + public byte[] Skeleton; + + public SklbFile(byte[] data) + { + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + var magic = reader.ReadUInt32(); + if (magic != 0x736B6C62) + throw new InvalidDataException("Invalid sklb magic"); + + // todo do this all properly jfc + var version = reader.ReadUInt32(); + + var oldHeader = version switch { + 0x31313030 or 0x31313130 or 0x31323030 => true, + 0x31333030 => false, + _ => throw new InvalidDataException($"Unknown version {version}") + }; + + // Skeleton offset directly follows the layer offset. + uint skeletonOffset; + if (oldHeader) + { + reader.ReadInt16(); + skeletonOffset = reader.ReadUInt16(); + } + else + { + reader.ReadUInt32(); + skeletonOffset = reader.ReadUInt32(); + } + + reader.Seek(skeletonOffset); + Skeleton = reader.ReadBytes((int)(reader.BaseStream.Length - skeletonOffset)); + } +} From 71fc901798ec9f90feab880c891667efab3f8893 Mon Sep 17 00:00:00 2001 From: ackwell Date: Fri, 29 Dec 2023 19:16:42 +1100 Subject: [PATCH 10/35] Resolve mdl game paths --- .../ModEditWindow.Models.MdlTab.cs | 18 +++++++++++++++++- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 3 +++ Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 4986963f..254db841 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -1,6 +1,8 @@ using OtterGui; using Penumbra.GameData; using Penumbra.GameData.Files; +using Penumbra.Mods; +using Penumbra.String.Classes; namespace Penumbra.UI.AdvancedWindow; @@ -9,12 +11,14 @@ public partial class ModEditWindow private class MdlTab : IWritable { public readonly MdlFile Mdl; + public readonly List GamePaths; private readonly List[] _attributes; - public MdlTab(byte[] bytes) + public MdlTab(byte[] bytes, string path, Mod? mod) { Mdl = new MdlFile(bytes); + GamePaths = mod == null ? new() : FindGamePaths(path, mod); _attributes = CreateAttributes(Mdl); } @@ -26,6 +30,18 @@ public partial class ModEditWindow public byte[] Write() => Mdl.Write(); + // TODO: this _needs_ to be done asynchronously, kart mods hang for a good second or so + private List FindGamePaths(string path, Mod mod) + { + // todo: might be worth ordering based on prio + selection for disambiguating between multiple matches? not sure. same for the multi group case + return mod.AllSubMods + .SelectMany(submod => submod.Files.Concat(submod.FileSwaps)) + // todo: using ordinal ignore case because the option group paths in mods being lowerecased somewhere, but the mod editor using fs paths, which may be uppercase. i'd say this will blow up on linux, but it's already the case so can't be too much worse than present right + .Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .ToList(); + } + /// 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 f0cc34a6..d5ca6fa5 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -45,6 +45,9 @@ public partial class ModEditWindow { _models.SkeletonTest(); } + ImGui.TextUnformatted("blippity blap"); + foreach (var gamePath in tab.GamePaths) + ImGui.TextUnformatted(gamePath.ToString()); var ret = false; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 1a3d9182..181538ec 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -587,7 +587,7 @@ public partial class ModEditWindow : Window, IDisposable () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", - () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new MdlTab(bytes)); + () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, _) => new MdlTab(bytes, path, _mod)); _shaderPackageTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new ShpkTab(_fileDialog, bytes)); From 18fd36d2d7123d3519b29c12bb580943b0f67e1c Mon Sep 17 00:00:00 2001 From: ackwell Date: Fri, 29 Dec 2023 23:49:55 +1100 Subject: [PATCH 11/35] Bit of cleanup --- Penumbra/Import/Models/ModelManager.cs | 12 +++++------ .../ModEditWindow.Models.MdlTab.cs | 21 +++++++++++++++++-- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 7 ++----- Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 2 +- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 6a9ef334..42134037 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -54,8 +54,8 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable return task; } - public Task ExportToGltf(MdlFile mdl, string path) - => Enqueue(new ExportToGltfAction(mdl, path)); + public Task ExportToGltf(MdlFile mdl, string outputPath) + => Enqueue(new ExportToGltfAction(mdl, outputPath)); public void SkeletonTest() { @@ -122,12 +122,12 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable private class ExportToGltfAction : IAction { private readonly MdlFile _mdl; - private readonly string _path; + private readonly string _outputPath; - public ExportToGltfAction(MdlFile mdl, string path) + public ExportToGltfAction(MdlFile mdl, string outputPath) { _mdl = mdl; - _path = path; + _outputPath = outputPath; } public void Execute(CancellationToken token) @@ -148,7 +148,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable } var model = scene.ToGltf2(); - model.SaveGLTF(_path); + model.SaveGLTF(_outputPath); } public bool Equals(IAction? other) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 254db841..4552078a 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -10,13 +10,18 @@ public partial class ModEditWindow { private class MdlTab : IWritable { + private ModEditWindow _edit; + public readonly MdlFile Mdl; public readonly List GamePaths; - private readonly List[] _attributes; - public MdlTab(byte[] bytes, string path, Mod? mod) + public bool PendingIo { get; private set; } = false; + + public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) { + _edit = edit; + Mdl = new MdlFile(bytes); GamePaths = mod == null ? new() : FindGamePaths(path, mod); _attributes = CreateAttributes(Mdl); @@ -31,6 +36,9 @@ public partial class ModEditWindow => Mdl.Write(); // TODO: this _needs_ to be done asynchronously, kart mods hang for a good second or so + /// Find the list of game paths that may correspond to this model. + /// Resolved path to a .mdl. + /// Mod within which the .mdl is resolved. private List FindGamePaths(string path, Mod mod) { // todo: might be worth ordering based on prio + selection for disambiguating between multiple matches? not sure. same for the multi group case @@ -42,6 +50,15 @@ public partial class ModEditWindow .ToList(); } + /// Export model to an interchange format. + /// Disk path to save the resulting file to. + public void Export(string outputPath) + { + PendingIo = true; + _edit._models.ExportToGltf(Mdl, outputPath) + .ContinueWith(_ => PendingIo = false); + } + /// 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 d5ca6fa5..0b145b89 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -17,7 +17,6 @@ public partial class ModEditWindow private readonly FileEditor _modelTab; private readonly ModelManager _models; - private bool _pendingIo = false; private string _modelNewMaterial = string.Empty; private readonly List _subMeshAttributeTagWidgets = []; @@ -35,11 +34,9 @@ public partial class ModEditWindow ); } - if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", _pendingIo)) + if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", tab.PendingIo)) { - _pendingIo = true; - var task = _models.ExportToGltf(file, "C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); - task.ContinueWith(_ => _pendingIo = false); + tab.Export("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); } if (ImGui.Button("zoingo boingo")) { diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 181538ec..20ad92c3 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -587,7 +587,7 @@ public partial class ModEditWindow : Window, IDisposable () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", - () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, _) => new MdlTab(bytes, path, _mod)); + () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, _) => new MdlTab(this, bytes, path, _mod)); _shaderPackageTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new ShpkTab(_fileDialog, bytes)); From 695c18439db5dc353def4cd1edd72e761cb0a456 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sat, 30 Dec 2023 02:41:19 +1100 Subject: [PATCH 12/35] Hook up rudimentary skeleton resolution for equipment models --- Penumbra.GameData | 2 +- Penumbra/Import/Models/ModelManager.cs | 134 ++++++++---------- Penumbra/Import/Models/SklbFile.cs | 44 ------ .../ModEditWindow.Models.MdlTab.cs | 60 +++++++- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 4 - 5 files changed, 121 insertions(+), 123 deletions(-) delete mode 100644 Penumbra/Import/Models/SklbFile.cs diff --git a/Penumbra.GameData b/Penumbra.GameData index 0dc4c892..b6a68ab6 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 0dc4c892308aea30314d118362b3ebab7706f4e5 +Subproject commit b6a68ab60be6a46f8ede63425cd0716dedf693a3 diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 42134037..9f56588a 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,12 +1,8 @@ -using System.Xml; using Dalamud.Plugin.Services; -using Lumina.Extensions; -using OtterGui; using OtterGui.Tasks; using Penumbra.Collections.Manager; using Penumbra.GameData.Files; using Penumbra.Import.Modules; -using Penumbra.String.Classes; using SharpGLTF.Scenes; using SharpGLTF.Transforms; @@ -14,14 +10,16 @@ namespace Penumbra.Import.Models; public sealed class ModelManager : SingleTaskQueue, IDisposable { + private readonly IFramework _framework; private readonly IDataManager _gameData; private readonly ActiveCollectionData _activeCollectionData; private readonly ConcurrentDictionary _tasks = new(); private bool _disposed = false; - public ModelManager(IDataManager gameData, ActiveCollectionData activeCollectionData) + public ModelManager(IFramework framework, IDataManager gameData, ActiveCollectionData activeCollectionData) { + _framework = framework; _gameData = gameData; _activeCollectionData = activeCollectionData; } @@ -54,86 +52,33 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable return task; } - public Task ExportToGltf(MdlFile mdl, string outputPath) - => Enqueue(new ExportToGltfAction(mdl, outputPath)); - - public void SkeletonTest() - { - var sklbPath = "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb"; - - // NOTE: to resolve game path from _mod_, will need to wire the mod class via the modeditwindow to the model editor, through to here. - // NOTE: to get the game path for a model we'll probably need to use a reverse resolve - there's no guarantee for a modded model that they're named per game path, nor that there's only one name. - var succeeded = Utf8GamePath.FromString(sklbPath, out var utf8Path, true); - var testResolve = _activeCollectionData.Current.ResolvePath(utf8Path); - Penumbra.Log.Information($"resolved: {(testResolve == null ? "NULL" : testResolve.ToString())}"); - - // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... - var bytes = testResolve switch - { - null => _gameData.GetFile(sklbPath).Data, - FullPath path => File.ReadAllBytes(path.ToPath()) - }; - - var sklb = new SklbFile(bytes); - - // TODO: Consider making these static methods. - var havokConverter = new HavokConverter(); - var xml = havokConverter.HkxToXml(sklb.Skeleton); - - var skeletonConverter = new SkeletonConverter(); - var skeleton = skeletonConverter.FromXml(xml); - - // this is (less) atrocious - NodeBuilder? root = null; - var boneMap = new Dictionary(); - for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) - { - var bone = skeleton.Bones[boneIndex]; - - if (boneMap.ContainsKey(bone.Name)) continue; - - var node = new NodeBuilder(bone.Name); - boneMap[bone.Name] = node; - - node.SetLocalTransform(new AffineTransform( - bone.Transform.Scale, - bone.Transform.Rotation, - bone.Transform.Translation - ), false); - - if (bone.ParentIndex == -1) - { - root = node; - continue; - } - - var parent = boneMap[skeleton.Bones[bone.ParentIndex].Name]; - parent.AddNode(node); - } - - var scene = new SceneBuilder(); - scene.AddNode(root); - var model = scene.ToGltf2(); - model.SaveGLTF(@"C:\Users\ackwell\blender\gltf-tests\zoingo.gltf"); - - Penumbra.Log.Information($"zoingo!"); - } + public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) + => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); private class ExportToGltfAction : IAction { + private readonly ModelManager _manager; + private readonly MdlFile _mdl; + private readonly SklbFile? _sklb; private readonly string _outputPath; - public ExportToGltfAction(MdlFile mdl, string outputPath) + public ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) { + _manager = manager; _mdl = mdl; + _sklb = sklb; _outputPath = outputPath; } - public void Execute(CancellationToken token) + public void Execute(CancellationToken cancel) { var scene = new SceneBuilder(); + var skeletonRoot = BuildSkeleton(cancel); + if (skeletonRoot != null) + scene.AddNode(skeletonRoot); + // TODO: group by LoD in output tree for (byte lodIndex = 0; lodIndex < _mdl.LodCount; lodIndex++) { @@ -151,6 +96,53 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable model.SaveGLTF(_outputPath); } + // TODO: this should be moved to a seperate model converter or something + private NodeBuilder? BuildSkeleton(CancellationToken cancel) + { + if (_sklb == null) + return null; + + // TODO: Consider making these static methods. + // TODO: work out how i handle this havok deal. running it outside the framework causes an immediate ctd. + var havokConverter = new HavokConverter(); + var xmlTask = _manager._framework.RunOnFrameworkThread(() => havokConverter.HkxToXml(_sklb.Skeleton)); + xmlTask.Wait(cancel); + var xml = xmlTask.Result; + + var skeletonConverter = new SkeletonConverter(); + var skeleton = skeletonConverter.FromXml(xml); + + // this is (less) atrocious + NodeBuilder? root = null; + var boneMap = new Dictionary(); + for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) + { + var bone = skeleton.Bones[boneIndex]; + + if (boneMap.ContainsKey(bone.Name)) continue; + + var node = new NodeBuilder(bone.Name); + boneMap[bone.Name] = node; + + node.SetLocalTransform(new AffineTransform( + bone.Transform.Scale, + bone.Transform.Rotation, + bone.Transform.Translation + ), false); + + if (bone.ParentIndex == -1) + { + root = node; + continue; + } + + var parent = boneMap[skeleton.Bones[bone.ParentIndex].Name]; + parent.AddNode(node); + } + + return root; + } + public bool Equals(IAction? other) { if (other is not ExportToGltfAction rhs) diff --git a/Penumbra/Import/Models/SklbFile.cs b/Penumbra/Import/Models/SklbFile.cs deleted file mode 100644 index 9ae6f7db..00000000 --- a/Penumbra/Import/Models/SklbFile.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Lumina.Extensions; - -namespace Penumbra.Import.Models; - -// TODO: yeah this goes in gamedata. -public class SklbFile -{ - public byte[] Skeleton; - - public SklbFile(byte[] data) - { - using var stream = new MemoryStream(data); - using var reader = new BinaryReader(stream); - - var magic = reader.ReadUInt32(); - if (magic != 0x736B6C62) - throw new InvalidDataException("Invalid sklb magic"); - - // todo do this all properly jfc - var version = reader.ReadUInt32(); - - var oldHeader = version switch { - 0x31313030 or 0x31313130 or 0x31323030 => true, - 0x31333030 => false, - _ => throw new InvalidDataException($"Unknown version {version}") - }; - - // Skeleton offset directly follows the layer offset. - uint skeletonOffset; - if (oldHeader) - { - reader.ReadInt16(); - skeletonOffset = reader.ReadUInt16(); - } - else - { - reader.ReadUInt32(); - skeletonOffset = reader.ReadUInt32(); - } - - reader.Seek(skeletonOffset); - Skeleton = reader.ReadBytes((int)(reader.BaseStream.Length - skeletonOffset)); - } -} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 4552078a..99c32761 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -8,16 +8,20 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { - private class MdlTab : IWritable + private partial class MdlTab : IWritable { private ModEditWindow _edit; public readonly MdlFile Mdl; public readonly List GamePaths; private readonly List[] _attributes; - + public bool PendingIo { get; private set; } = false; + // TODO: this can probably be genericised across all of chara + [GeneratedRegex(@"chara/equipment/e(?'Set'\d{4})/model/c(?'Race'\d{4})e\k'Set'_.+\.mdl", RegexOptions.Compiled)] + private static partial Regex CharaEquipmentRegex(); + public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) { _edit = edit; @@ -54,11 +58,61 @@ public partial class ModEditWindow /// Disk path to save the resulting file to. public void Export(string outputPath) { + // NOTES ON EST (i don't think it's worth supporting yet...) + // for collection wide lookup; + // Collections.Cache.EstCache::GetEstEntry + // Collections.Cache.MetaCache::GetEstEntry + // Collections.ModCollection.MetaCache? + // for default lookup, probably; + // EstFile.GetDefault(...) + + // TODO: allow user to pick the gamepath in the ui + // TODO: what if there's no gamepaths? + var mdlPath = GamePaths.First(); + var sklbPath = GetSklbPath(mdlPath.ToString()); + var sklb = sklbPath != null ? ReadSklb(sklbPath) : null; + PendingIo = true; - _edit._models.ExportToGltf(Mdl, outputPath) + _edit._models.ExportToGltf(Mdl, sklb, outputPath) .ContinueWith(_ => PendingIo = false); } + /// Try to find the .sklb path for a .mdl file. + /// .mdl file to look up the skeleton for. + private string? GetSklbPath(string mdlPath) + { + // TODO: This needs to be drastically expanded, it's dodgy af rn + + var match = CharaEquipmentRegex().Match(mdlPath); + if (!match.Success) + return null; + + var race = match.Groups["Race"].Value; + + return $"chara/human/c{race}/skeleton/base/b0001/skl_c{race}b0001.sklb"; + } + + /// Read a .sklb from the active collection or game. + /// Game path to the .sklb to load. + private SklbFile ReadSklb(string sklbPath) + { + // TODO: if cross-collection lookups are turned off, this conversion can be skipped + if (!Utf8GamePath.FromString(sklbPath, out var utf8SklbPath, true)) + throw new Exception("TODO: handle - should it throw, or try to fail gracefully?"); + + var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath); + // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... + var bytes = resolvedPath switch + { + null => _edit._dalamud.GameData.GetFile(sklbPath)?.Data, + FullPath path => File.ReadAllBytes(path.ToPath()), + }; + if (bytes == null) + throw new Exception("TODO: handle - this effectively means that the resolved path doesn't exist. graceful?"); + + return new SklbFile(bytes); + } + /// 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 0b145b89..ff2c1ae5 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -38,10 +38,6 @@ public partial class ModEditWindow { tab.Export("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); } - if (ImGui.Button("zoingo boingo")) - { - _models.SkeletonTest(); - } ImGui.TextUnformatted("blippity blap"); foreach (var gamePath in tab.GamePaths) ImGui.TextUnformatted(gamePath.ToString()); From 727fa3c18352472927899f2826e70fbb15048d1f Mon Sep 17 00:00:00 2001 From: ackwell Date: Sat, 30 Dec 2023 17:07:34 +1100 Subject: [PATCH 13/35] Initial pass on skinned mesh output --- Penumbra/Import/Models/MeshConverter.cs | 84 ++++++++++++++++++++++--- Penumbra/Import/Models/ModelManager.cs | 33 ++++++---- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs index 2fcd2816..30d24c17 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -10,9 +10,9 @@ namespace Penumbra.Import.Modules; public sealed class MeshConverter { - public static IMeshBuilder ToGltf(MdlFile mdl, byte lod, ushort meshIndex) + public static IMeshBuilder ToGltf(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) { - var self = new MeshConverter(mdl, lod, meshIndex); + var self = new MeshConverter(mdl, lod, meshIndex, boneNameMap); return self.BuildMesh(); } @@ -23,21 +23,49 @@ public sealed class MeshConverter private readonly ushort _meshIndex; private MdlStructs.MeshStruct Mesh => _mdl.Meshes[_meshIndex]; - private readonly Type _geometryType; + private readonly Dictionary? _boneIndexMap; - private MeshConverter(MdlFile mdl, byte lod, ushort meshIndex) + private readonly Type _geometryType; + private readonly Type _skinningType; + + private MeshConverter(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) { _mdl = mdl; _lod = lod; _meshIndex = meshIndex; + if (boneNameMap != null) + _boneIndexMap = BuildBoneIndexMap(boneNameMap); + var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements .Select(element => (MdlFile.VertexUsage)element.Usage) .ToImmutableHashSet(); _geometryType = GetGeometryType(usages); + _skinningType = GetSkinningType(usages); } + private Dictionary BuildBoneIndexMap(Dictionary boneNameMap) + { + // todo: BoneTableIndex of 255 means null? if so, it should probably feed into the attributes we assign... + var xivBoneTable = _mdl.BoneTables[Mesh.BoneTableIndex]; + + var indexMap = new Dictionary(); + + foreach (var xivBoneIndex in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount)) + { + var boneName = _mdl.Bones[xivBoneIndex]; + if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) + // TODO: handle - i think this is a hard failure, it means that a bone name in the model doesn't exist in the armature. + throw new Exception($"looking for {boneName} in {string.Join(", ", boneNameMap.Keys)}"); + + indexMap.Add(xivBoneIndex, gltfBoneIndex); + } + + return indexMap; + } + + // TODO: consider a struct return type private IMeshBuilder BuildMesh() { var indices = BuildIndices(); @@ -47,7 +75,7 @@ public sealed class MeshConverter typeof(MaterialBuilder), _geometryType, typeof(VertexEmpty), - typeof(VertexEmpty) + _skinningType ); var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, $"mesh{_meshIndex}")!; @@ -81,7 +109,7 @@ public sealed class MeshConverter private IReadOnlyList BuildVertices() { var vertexBuilderType = typeof(VertexBuilder<,,>) - .MakeGenericType(_geometryType, typeof(VertexEmpty), typeof(VertexEmpty)); + .MakeGenericType(_geometryType, typeof(VertexEmpty), _skinningType); // NOTE: This assumes that buffer streams are tightly packed, which has proven safe across tested files. If this assumption is broken, seeks will need to be moved into the vertex element loop. var streams = new BinaryReader[MaximumMeshBufferStreams]; @@ -107,8 +135,9 @@ public sealed class MeshConverter attributes[usage] = ReadVertexAttribute(streams[element.Stream], element); var vertexGeometry = BuildVertexGeometry(attributes); + var vertexSkinning = BuildVertexSkinning(attributes); - var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), new VertexEmpty())!; + var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), vertexSkinning)!; vertices.Add(vertexBuilder); } @@ -167,6 +196,40 @@ public sealed class MeshConverter throw new Exception($"Unknown geometry type {_geometryType}."); } + private Type GetSkinningType(IReadOnlySet usages) + { + // TODO: possibly need to check only index - weight might be missing? + if (usages.Contains(MdlFile.VertexUsage.BlendWeights) && usages.Contains(MdlFile.VertexUsage.BlendIndices)) + return typeof(VertexJoints4); + + return typeof(VertexEmpty); + } + + private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary attributes) + { + if (_skinningType == typeof(VertexEmpty)) + return new VertexEmpty(); + + if (_skinningType == typeof(VertexJoints4)) + { + // todo: this shouldn't happen... right? better approach? + if (_boneIndexMap == null) + throw new Exception("cannot build skinned vertex without index mapping"); + + var indices = ToByteArray(attributes[MdlFile.VertexUsage.BlendIndices]); + var weights = ToVector4(attributes[MdlFile.VertexUsage.BlendWeights]); + + // todo: if this throws on the bone index map, the mod is broken, as it contains weights for bones that do not exist. + // i've not seen any of these that even tt can understand + var bindings = Enumerable.Range(0, 4) + .Select(index => (_boneIndexMap[indices[index]], weights[index])) + .ToArray(); + return new VertexJoints4(bindings); + } + + throw new Exception($"Unknown skinning type {_skinningType}"); + } + // Some tangent W values that should be -1 are stored as 0. private Vector4 FixTangentVector(Vector4 tangent) => tangent with { W = tangent.W == 1 ? 1 : -1 }; @@ -188,4 +251,11 @@ public sealed class MeshConverter Vector4 v4 => v4, _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") }; + + private byte[] ToByteArray(object data) + => data switch + { + byte[] value => value, + _ => throw new ArgumentOutOfRangeException($"Invalid byte[] input {data}") + }; } diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 9f56588a..027ac841 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -75,20 +75,24 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable { var scene = new SceneBuilder(); - var skeletonRoot = BuildSkeleton(cancel); - if (skeletonRoot != null) - scene.AddNode(skeletonRoot); + var skeleton = BuildSkeleton(cancel); + if (skeleton != null) + scene.AddNode(skeleton.Value.Root); // TODO: group by LoD in output tree for (byte lodIndex = 0; lodIndex < _mdl.LodCount; lodIndex++) { var lod = _mdl.Lods[lodIndex]; - // TODO: consider other types? + // TODO: consider other types of mesh? for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) { - var meshBuilder = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset)); - scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); + var meshBuilder = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton?.Names); + // TODO: use a value from the mesh converter for this check, rather than assuming that it has joints + if (skeleton == null) + scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); + else + scene.AddSkinnedMesh(meshBuilder, Matrix4x4.Identity, skeleton?.Joints); } } @@ -97,7 +101,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable } // TODO: this should be moved to a seperate model converter or something - private NodeBuilder? BuildSkeleton(CancellationToken cancel) + private (NodeBuilder Root, NodeBuilder[] Joints, Dictionary Names)? BuildSkeleton(CancellationToken cancel) { if (_sklb == null) return null; @@ -114,15 +118,17 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable // this is (less) atrocious NodeBuilder? root = null; - var boneMap = new Dictionary(); + var names = new Dictionary(); + var joints = new List(); for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) { var bone = skeleton.Bones[boneIndex]; - if (boneMap.ContainsKey(bone.Name)) continue; + if (names.ContainsKey(bone.Name)) continue; var node = new NodeBuilder(bone.Name); - boneMap[bone.Name] = node; + names[bone.Name] = joints.Count; + joints.Add(node); node.SetLocalTransform(new AffineTransform( bone.Transform.Scale, @@ -136,11 +142,14 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable continue; } - var parent = boneMap[skeleton.Bones[bone.ParentIndex].Name]; + var parent = joints[names[skeleton.Bones[bone.ParentIndex].Name]]; parent.AddNode(node); } - return root; + if (root == null) + return null; + + return (root, joints.ToArray(), names); } public bool Equals(IAction? other) From f7a2c174152c6899618e437adb0566f3924073a9 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sat, 30 Dec 2023 18:31:15 +1100 Subject: [PATCH 14/35] Quick submesh implementation --- Penumbra/Import/Models/MeshConverter.cs | 27 +++++++++++++++++-------- Penumbra/Import/Models/ModelManager.cs | 11 +++++----- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs index 30d24c17..9f55383e 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -10,7 +10,7 @@ namespace Penumbra.Import.Modules; public sealed class MeshConverter { - public static IMeshBuilder ToGltf(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) + public static IMeshBuilder[] ToGltf(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) { var self = new MeshConverter(mdl, lod, meshIndex, boneNameMap); return self.BuildMesh(); @@ -65,12 +65,23 @@ public sealed class MeshConverter return indexMap; } - // TODO: consider a struct return type - private IMeshBuilder BuildMesh() + private IMeshBuilder[] BuildMesh() { - var indices = BuildIndices(); var vertices = BuildVertices(); + // TODO: handle submeshCount = 0 + + return _mdl.SubMeshes + .Skip(Mesh.SubMeshIndex) + .Take(Mesh.SubMeshCount) + .Select(submesh => BuildSubMesh(submesh, vertices)) + .ToArray(); + } + + private IMeshBuilder BuildSubMesh(MdlStructs.SubmeshStruct submesh, IReadOnlyList vertices) + { + var indices = BuildIndices(submesh); + var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( typeof(MaterialBuilder), _geometryType, @@ -89,7 +100,7 @@ public sealed class MeshConverter // All XIV meshes use triangle lists. // TODO: split by submeshes - for (var indexOffset = 0; indexOffset < Mesh.IndexCount; indexOffset += 3) + for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) primitiveBuilder.AddTriangle( vertices[indices[indexOffset + 0]], vertices[indices[indexOffset + 1]], @@ -99,11 +110,11 @@ public sealed class MeshConverter return meshBuilder; } - private IReadOnlyList BuildIndices() + private IReadOnlyList BuildIndices(MdlStructs.SubmeshStruct submesh) { var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - reader.Seek(_mdl.IndexOffset[_lod] + Mesh.StartIndex * sizeof(ushort)); - return reader.ReadStructuresAsArray((int)Mesh.IndexCount); + reader.Seek(_mdl.IndexOffset[_lod] + submesh.IndexOffset * sizeof(ushort)); + return reader.ReadStructuresAsArray((int)submesh.IndexCount); } private IReadOnlyList BuildVertices() diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 027ac841..2dd64235 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -87,12 +87,13 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable // TODO: consider other types of mesh? for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) { - var meshBuilder = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton?.Names); + var meshBuilders = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton?.Names); // TODO: use a value from the mesh converter for this check, rather than assuming that it has joints - if (skeleton == null) - scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); - else - scene.AddSkinnedMesh(meshBuilder, Matrix4x4.Identity, skeleton?.Joints); + foreach (var meshBuilder in meshBuilders) + if (skeleton == null) + scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); + else + scene.AddSkinnedMesh(meshBuilder, Matrix4x4.Identity, skeleton?.Joints); } } From 309f0351fa2f5d95e831eb52ae78374a3d1ac772 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sun, 31 Dec 2023 15:33:37 +1100 Subject: [PATCH 15/35] Build indices for entire mesh --- Penumbra/Import/Models/MeshConverter.cs | 26 +++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs index 9f55383e..4aa60331 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -40,7 +40,7 @@ public sealed class MeshConverter var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements .Select(element => (MdlFile.VertexUsage)element.Usage) .ToImmutableHashSet(); - + _geometryType = GetGeometryType(usages); _skinningType = GetSkinningType(usages); } @@ -58,7 +58,7 @@ public sealed class MeshConverter if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) // TODO: handle - i think this is a hard failure, it means that a bone name in the model doesn't exist in the armature. throw new Exception($"looking for {boneName} in {string.Join(", ", boneNameMap.Keys)}"); - + indexMap.Add(xivBoneIndex, gltfBoneIndex); } @@ -67,6 +67,7 @@ public sealed class MeshConverter private IMeshBuilder[] BuildMesh() { + var indices = BuildIndices(); var vertices = BuildVertices(); // TODO: handle submeshCount = 0 @@ -74,13 +75,14 @@ public sealed class MeshConverter return _mdl.SubMeshes .Skip(Mesh.SubMeshIndex) .Take(Mesh.SubMeshCount) - .Select(submesh => BuildSubMesh(submesh, vertices)) + .Select(submesh => BuildSubMesh(submesh, indices, vertices)) .ToArray(); } - private IMeshBuilder BuildSubMesh(MdlStructs.SubmeshStruct submesh, IReadOnlyList vertices) + private IMeshBuilder BuildSubMesh(MdlStructs.SubmeshStruct submesh, IReadOnlyList indices, IReadOnlyList vertices) { - var indices = BuildIndices(submesh); + // Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh. + var startIndex = (int)(submesh.IndexOffset - Mesh.StartIndex); var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( typeof(MaterialBuilder), @@ -102,19 +104,19 @@ public sealed class MeshConverter // TODO: split by submeshes for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) primitiveBuilder.AddTriangle( - vertices[indices[indexOffset + 0]], - vertices[indices[indexOffset + 1]], - vertices[indices[indexOffset + 2]] + vertices[indices[indexOffset + startIndex + 0]], + vertices[indices[indexOffset + startIndex + 1]], + vertices[indices[indexOffset + startIndex + 2]] ); return meshBuilder; } - private IReadOnlyList BuildIndices(MdlStructs.SubmeshStruct submesh) + private IReadOnlyList BuildIndices() { var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - reader.Seek(_mdl.IndexOffset[_lod] + submesh.IndexOffset * sizeof(ushort)); - return reader.ReadStructuresAsArray((int)submesh.IndexCount); + reader.Seek(_mdl.IndexOffset[_lod] + Mesh.StartIndex * sizeof(ushort)); + return reader.ReadStructuresAsArray((int)Mesh.IndexCount); } private IReadOnlyList BuildVertices() @@ -244,7 +246,7 @@ public sealed class MeshConverter // Some tangent W values that should be -1 are stored as 0. private Vector4 FixTangentVector(Vector4 tangent) => tangent with { W = tangent.W == 1 ? 1 : -1 }; - + private Vector3 ToVector3(object data) => data switch { From 989915ddbe36e6d5bedea429d3e132c97897a584 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sun, 31 Dec 2023 16:10:20 +1100 Subject: [PATCH 16/35] Add initial shape key support --- Penumbra/Import/Models/MeshConverter.cs | 42 +++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs index 4aa60331..ff52b967 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -100,14 +100,52 @@ public sealed class MeshConverter var primitiveBuilder = meshBuilder.UsePrimitive(materialBuilder); + // Store a list of the glTF indices. The list index will be equivalent to the xiv (submesh) index. + var gltfIndices = new List(); + // All XIV meshes use triangle lists. - // TODO: split by submeshes for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) - primitiveBuilder.AddTriangle( + { + var (a, b, c) = primitiveBuilder.AddTriangle( vertices[indices[indexOffset + startIndex + 0]], vertices[indices[indexOffset + startIndex + 1]], vertices[indices[indexOffset + startIndex + 2]] ); + gltfIndices.AddRange([a, b, c]); + } + + var primitiveVertices = meshBuilder.Primitives.First().Vertices; + var shapeNames = new List(); + + foreach (var shape in _mdl.Shapes) + { + // Filter down to shape values for the current mesh that sit within the bounds of the current submesh. + var shapeValues = _mdl.ShapeMeshes + .Skip(shape.ShapeMeshStartIndex[_lod]) + .Take(shape.ShapeMeshCount[_lod]) + .Where(shapeMesh => shapeMesh.MeshIndexOffset == Mesh.StartIndex) + .SelectMany(shapeMesh => + _mdl.ShapeValues + .Skip((int)shapeMesh.ShapeValueOffset) + .Take((int)shapeMesh.ShapeValueCount) + ) + .Where(shapeValue => + shapeValue.BaseIndicesIndex >= startIndex + && shapeValue.BaseIndicesIndex < startIndex + submesh.IndexCount + ) + .ToList(); + + if (shapeValues.Count == 0) continue; + + var morphBuilder = meshBuilder.UseMorphTarget(shapeNames.Count); + shapeNames.Add(shape.ShapeName); + + foreach (var shapeValue in shapeValues) + morphBuilder.SetVertex( + primitiveVertices[gltfIndices[shapeValue.BaseIndicesIndex - startIndex]].GetGeometry(), + vertices[shapeValue.ReplacingVertexIndex].GetGeometry() + ); + } return meshBuilder; } From 6a2b802196953d78033cdc6824c0c22bd87e6192 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sun, 31 Dec 2023 17:11:08 +1100 Subject: [PATCH 17/35] Add shape key names --- Penumbra/Import/Models/MeshConverter.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/MeshConverter.cs index ff52b967..97121798 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/MeshConverter.cs @@ -4,6 +4,7 @@ using Lumina.Extensions; using Penumbra.GameData.Files; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.IO; using SharpGLTF.Materials; namespace Penumbra.Import.Modules; @@ -124,7 +125,7 @@ public sealed class MeshConverter .Skip(shape.ShapeMeshStartIndex[_lod]) .Take(shape.ShapeMeshCount[_lod]) .Where(shapeMesh => shapeMesh.MeshIndexOffset == Mesh.StartIndex) - .SelectMany(shapeMesh => + .SelectMany(shapeMesh => _mdl.ShapeValues .Skip((int)shapeMesh.ShapeValueOffset) .Take((int)shapeMesh.ShapeValueCount) @@ -147,6 +148,10 @@ public sealed class MeshConverter ); } + meshBuilder.Extras = JsonContent.CreateFrom(new Dictionary() { + {"targetNames", shapeNames} + }); + return meshBuilder; } From 551c25a64cf93a58fe2e7ca253a79542da345474 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 00:18:03 +1100 Subject: [PATCH 18/35] Move a few things to export subdir --- .../MeshExporter.cs} | 60 +++++++---- .../Import/Models/Export/ModelExporter.cs | 100 ++++++++++++++++++ .../Import/Models/{ => Export}/Skeleton.cs | 16 ++- Penumbra/Import/Models/ModelManager.cs | 69 ++---------- Penumbra/Import/Models/SkeletonConverter.cs | 11 +- 5 files changed, 169 insertions(+), 87 deletions(-) rename Penumbra/Import/Models/{MeshConverter.cs => Export/MeshExporter.cs} (84%) create mode 100644 Penumbra/Import/Models/Export/ModelExporter.cs rename Penumbra/Import/Models/{ => Export}/Skeleton.cs (53%) diff --git a/Penumbra/Import/Models/MeshConverter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs similarity index 84% rename from Penumbra/Import/Models/MeshConverter.cs rename to Penumbra/Import/Models/Export/MeshExporter.cs index 97121798..fdfa59e4 100644 --- a/Penumbra/Import/Models/MeshConverter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -6,15 +6,39 @@ using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.IO; using SharpGLTF.Materials; +using SharpGLTF.Scenes; -namespace Penumbra.Import.Modules; +namespace Penumbra.Import.Models.Export; -public sealed class MeshConverter +public class MeshExporter { - public static IMeshBuilder[] ToGltf(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) + public class Mesh { - var self = new MeshConverter(mdl, lod, meshIndex, boneNameMap); - return self.BuildMesh(); + private IMeshBuilder[] _meshes; + private NodeBuilder[]? _joints; + + public Mesh(IMeshBuilder[] meshes, NodeBuilder[]? joints) + { + _meshes = meshes; + _joints = joints; + } + + public void AddToScene(SceneBuilder scene) + { + // TODO: throw if mesh has skinned vertices but no joints are available? + foreach (var mesh in _meshes) + if (_joints == null) + scene.AddRigidMesh(mesh, Matrix4x4.Identity); + else + scene.AddSkinnedMesh(mesh, Matrix4x4.Identity, _joints); + } + } + + // TODO: replace bonenamemap with a gltfskeleton + public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, GltfSkeleton? skeleton) + { + var self = new MeshExporter(mdl, lod, meshIndex, skeleton?.Names); + return new Mesh(self.BuildMeshes(), skeleton?.Joints); } private const byte MaximumMeshBufferStreams = 3; @@ -22,14 +46,14 @@ public sealed class MeshConverter private readonly MdlFile _mdl; private readonly byte _lod; private readonly ushort _meshIndex; - private MdlStructs.MeshStruct Mesh => _mdl.Meshes[_meshIndex]; + private MdlStructs.MeshStruct XivMesh => _mdl.Meshes[_meshIndex]; private readonly Dictionary? _boneIndexMap; private readonly Type _geometryType; private readonly Type _skinningType; - private MeshConverter(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) + private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) { _mdl = mdl; _lod = lod; @@ -49,7 +73,7 @@ public sealed class MeshConverter private Dictionary BuildBoneIndexMap(Dictionary boneNameMap) { // todo: BoneTableIndex of 255 means null? if so, it should probably feed into the attributes we assign... - var xivBoneTable = _mdl.BoneTables[Mesh.BoneTableIndex]; + var xivBoneTable = _mdl.BoneTables[XivMesh.BoneTableIndex]; var indexMap = new Dictionary(); @@ -66,16 +90,16 @@ public sealed class MeshConverter return indexMap; } - private IMeshBuilder[] BuildMesh() + private IMeshBuilder[] BuildMeshes() { var indices = BuildIndices(); var vertices = BuildVertices(); - // TODO: handle submeshCount = 0 + // TODO: handle SubMeshCount = 0 return _mdl.SubMeshes - .Skip(Mesh.SubMeshIndex) - .Take(Mesh.SubMeshCount) + .Skip(XivMesh.SubMeshIndex) + .Take(XivMesh.SubMeshCount) .Select(submesh => BuildSubMesh(submesh, indices, vertices)) .ToArray(); } @@ -83,7 +107,7 @@ public sealed class MeshConverter private IMeshBuilder BuildSubMesh(MdlStructs.SubmeshStruct submesh, IReadOnlyList indices, IReadOnlyList vertices) { // Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh. - var startIndex = (int)(submesh.IndexOffset - Mesh.StartIndex); + var startIndex = (int)(submesh.IndexOffset - XivMesh.StartIndex); var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( typeof(MaterialBuilder), @@ -124,7 +148,7 @@ public sealed class MeshConverter var shapeValues = _mdl.ShapeMeshes .Skip(shape.ShapeMeshStartIndex[_lod]) .Take(shape.ShapeMeshCount[_lod]) - .Where(shapeMesh => shapeMesh.MeshIndexOffset == Mesh.StartIndex) + .Where(shapeMesh => shapeMesh.MeshIndexOffset == XivMesh.StartIndex) .SelectMany(shapeMesh => _mdl.ShapeValues .Skip((int)shapeMesh.ShapeValueOffset) @@ -158,8 +182,8 @@ public sealed class MeshConverter private IReadOnlyList BuildIndices() { var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - reader.Seek(_mdl.IndexOffset[_lod] + Mesh.StartIndex * sizeof(ushort)); - return reader.ReadStructuresAsArray((int)Mesh.IndexCount); + reader.Seek(_mdl.IndexOffset[_lod] + XivMesh.StartIndex * sizeof(ushort)); + return reader.ReadStructuresAsArray((int)XivMesh.IndexCount); } private IReadOnlyList BuildVertices() @@ -172,7 +196,7 @@ public sealed class MeshConverter for (var streamIndex = 0; streamIndex < MaximumMeshBufferStreams; streamIndex++) { streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData)); - streams[streamIndex].Seek(_mdl.VertexOffset[_lod] + Mesh.VertexBufferOffset[streamIndex]); + streams[streamIndex].Seek(_mdl.VertexOffset[_lod] + XivMesh.VertexBufferOffset[streamIndex]); } var sortedElements = _mdl.VertexDeclarations[_meshIndex].VertexElements @@ -183,7 +207,7 @@ public sealed class MeshConverter var vertices = new List(); var attributes = new Dictionary(); - for (var vertexIndex = 0; vertexIndex < Mesh.VertexCount; vertexIndex++) + for (var vertexIndex = 0; vertexIndex < XivMesh.VertexCount; vertexIndex++) { attributes.Clear(); diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs new file mode 100644 index 00000000..c8716cf3 --- /dev/null +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -0,0 +1,100 @@ +using Penumbra.GameData.Files; +using SharpGLTF.Scenes; +using SharpGLTF.Transforms; + +namespace Penumbra.Import.Models.Export; + +public class ModelExporter +{ + public class Model + { + private List _meshes; + private GltfSkeleton? _skeleton; + + public Model(List meshes, GltfSkeleton? skeleton) + { + _meshes = meshes; + _skeleton = skeleton; + } + + public void AddToScene(SceneBuilder scene) + { + // If there's a skeleton, the root node should be added before we add any potentially skinned meshes. + 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); + } + } + + public static Model Export(MdlFile mdl, XivSkeleton? xivSkeleton) + { + var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; + var meshes = ConvertMeshes(mdl, gltfSkeleton); + return new Model(meshes, gltfSkeleton); + } + + private static List ConvertMeshes(MdlFile mdl, GltfSkeleton? skeleton) + { + var meshes = new List(); + + for (byte lodIndex = 0; lodIndex < mdl.LodCount; lodIndex++) + { + var lod = mdl.Lods[lodIndex]; + + // 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); + meshes.Add(mesh); + } + } + + return meshes; + } + + private static GltfSkeleton? ConvertSkeleton(XivSkeleton skeleton) + { + NodeBuilder? root = null; + var names = new Dictionary(); + var joints = new List(); + for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) + { + var bone = skeleton.Bones[boneIndex]; + + if (names.ContainsKey(bone.Name)) continue; + + var node = new NodeBuilder(bone.Name); + names[bone.Name] = joints.Count; + joints.Add(node); + + node.SetLocalTransform(new AffineTransform( + bone.Transform.Scale, + bone.Transform.Rotation, + bone.Transform.Translation + ), false); + + if (bone.ParentIndex == -1) + { + root = node; + continue; + } + + var parent = joints[names[skeleton.Bones[bone.ParentIndex].Name]]; + parent.AddNode(node); + } + + if (root == null) + return null; + + return new() + { + Root = root, + Joints = joints.ToArray(), + Names = names, + }; + } +} diff --git a/Penumbra/Import/Models/Skeleton.cs b/Penumbra/Import/Models/Export/Skeleton.cs similarity index 53% rename from Penumbra/Import/Models/Skeleton.cs rename to Penumbra/Import/Models/Export/Skeleton.cs index fb5c8284..13379dc4 100644 --- a/Penumbra/Import/Models/Skeleton.cs +++ b/Penumbra/Import/Models/Export/Skeleton.cs @@ -1,11 +1,12 @@ -namespace Penumbra.Import.Models; +using SharpGLTF.Scenes; -// TODO: this should almost certainly live in gamedata. if not, it should at _least_ be adjacent to the model handling. -public class Skeleton +namespace Penumbra.Import.Models.Export; + +public class XivSkeleton { public Bone[] Bones; - public Skeleton(Bone[] bones) + public XivSkeleton(Bone[] bones) { Bones = bones; } @@ -23,3 +24,10 @@ public class Skeleton public Vector3 Translation; } } + +public struct GltfSkeleton +{ + public NodeBuilder Root; + public NodeBuilder[] Joints; + public Dictionary Names; +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 2dd64235..9f72619f 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -2,7 +2,7 @@ using Dalamud.Plugin.Services; using OtterGui.Tasks; using Penumbra.Collections.Manager; using Penumbra.GameData.Files; -using Penumbra.Import.Modules; +using Penumbra.Import.Models.Export; using SharpGLTF.Scenes; using SharpGLTF.Transforms; @@ -73,36 +73,18 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public void Execute(CancellationToken cancel) { + var xivSkeleton = BuildSkeleton(cancel); + var model = ModelExporter.Export(_mdl, xivSkeleton); + var scene = new SceneBuilder(); + model.AddToScene(scene); - var skeleton = BuildSkeleton(cancel); - if (skeleton != null) - scene.AddNode(skeleton.Value.Root); - - // TODO: group by LoD in output tree - for (byte lodIndex = 0; lodIndex < _mdl.LodCount; lodIndex++) - { - var lod = _mdl.Lods[lodIndex]; - - // TODO: consider other types of mesh? - for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) - { - var meshBuilders = MeshConverter.ToGltf(_mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton?.Names); - // TODO: use a value from the mesh converter for this check, rather than assuming that it has joints - foreach (var meshBuilder in meshBuilders) - if (skeleton == null) - scene.AddRigidMesh(meshBuilder, Matrix4x4.Identity); - else - scene.AddSkinnedMesh(meshBuilder, Matrix4x4.Identity, skeleton?.Joints); - } - } - - var model = scene.ToGltf2(); - model.SaveGLTF(_outputPath); + var gltfModel = scene.ToGltf2(); + gltfModel.SaveGLTF(_outputPath); } // TODO: this should be moved to a seperate model converter or something - private (NodeBuilder Root, NodeBuilder[] Joints, Dictionary Names)? BuildSkeleton(CancellationToken cancel) + private XivSkeleton? BuildSkeleton(CancellationToken cancel) { if (_sklb == null) return null; @@ -117,40 +99,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable var skeletonConverter = new SkeletonConverter(); var skeleton = skeletonConverter.FromXml(xml); - // this is (less) atrocious - NodeBuilder? root = null; - var names = new Dictionary(); - var joints = new List(); - for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) - { - var bone = skeleton.Bones[boneIndex]; - - if (names.ContainsKey(bone.Name)) continue; - - var node = new NodeBuilder(bone.Name); - names[bone.Name] = joints.Count; - joints.Add(node); - - node.SetLocalTransform(new AffineTransform( - bone.Transform.Scale, - bone.Transform.Rotation, - bone.Transform.Translation - ), false); - - if (bone.ParentIndex == -1) - { - root = node; - continue; - } - - var parent = joints[names[skeleton.Bones[bone.ParentIndex].Name]]; - parent.AddNode(node); - } - - if (root == null) - return null; - - return (root, joints.ToArray(), names); + return skeleton; } public bool Equals(IAction? other) diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs index d54b0294..e265e5c3 100644 --- a/Penumbra/Import/Models/SkeletonConverter.cs +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -1,12 +1,13 @@ using System.Xml; using OtterGui; +using Penumbra.Import.Models.Export; namespace Penumbra.Import.Models; // TODO: tempted to say that this living here is more okay? that or next to havok converter, wherever that ends up. public class SkeletonConverter { - public Skeleton FromXml(string xml) + public XivSkeleton FromXml(string xml) { var document = new XmlDocument(); document.LoadXml(xml); @@ -29,7 +30,7 @@ public class SkeletonConverter .Select(values => { var (transform, parentIndex, name) = values; - return new Skeleton.Bone() + return new XivSkeleton.Bone() { Transform = transform, ParentIndex = parentIndex, @@ -38,7 +39,7 @@ public class SkeletonConverter }) .ToArray(); - return new Skeleton(bones); + return new XivSkeleton(bones); } /// Get the main skeleton ID for a given skeleton document. @@ -57,14 +58,14 @@ public class SkeletonConverter /// Read the reference pose transforms for a skeleton. /// XML node for the skeleton. - private Skeleton.Transform[] ReadReferencePose(XmlNode node) + private XivSkeleton.Transform[] ReadReferencePose(XmlNode node) { return ReadArray( CheckExists(node.SelectSingleNode("array[@name='referencePose']")), node => { var raw = ReadVec12(node); - return new Skeleton.Transform() + return new XivSkeleton.Transform() { Translation = new(raw[0], raw[1], raw[2]), Rotation = new(raw[4], raw[5], raw[6], raw[7]), From f1379af92cbf292622c79cc1b1ff4e82a441ec8b Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 00:38:24 +1100 Subject: [PATCH 19/35] Add UV export --- Penumbra/Import/Models/Export/MeshExporter.cs | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index fdfa59e4..06ca747b 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -51,6 +51,7 @@ public class MeshExporter private readonly Dictionary? _boneIndexMap; private readonly Type _geometryType; + private readonly Type _materialType; private readonly Type _skinningType; private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) @@ -67,6 +68,7 @@ public class MeshExporter .ToImmutableHashSet(); _geometryType = GetGeometryType(usages); + _materialType = GetMaterialType(usages); _skinningType = GetSkinningType(usages); } @@ -112,7 +114,7 @@ public class MeshExporter var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( typeof(MaterialBuilder), _geometryType, - typeof(VertexEmpty), + _materialType, _skinningType ); var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, $"mesh{_meshIndex}")!; @@ -189,7 +191,7 @@ public class MeshExporter private IReadOnlyList BuildVertices() { var vertexBuilderType = typeof(VertexBuilder<,,>) - .MakeGenericType(_geometryType, typeof(VertexEmpty), _skinningType); + .MakeGenericType(_geometryType, _materialType, _skinningType); // NOTE: This assumes that buffer streams are tightly packed, which has proven safe across tested files. If this assumption is broken, seeks will need to be moved into the vertex element loop. var streams = new BinaryReader[MaximumMeshBufferStreams]; @@ -215,9 +217,10 @@ public class MeshExporter attributes[usage] = ReadVertexAttribute(streams[element.Stream], element); var vertexGeometry = BuildVertexGeometry(attributes); + var vertexMaterial = BuildVertexMaterial(attributes); var vertexSkinning = BuildVertexSkinning(attributes); - var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, new VertexEmpty(), vertexSkinning)!; + var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, vertexMaterial, vertexSkinning)!; vertices.Add(vertexBuilder); } @@ -276,6 +279,43 @@ public class MeshExporter throw new Exception($"Unknown geometry type {_geometryType}."); } + private Type GetMaterialType(IReadOnlySet usages) + { + // TODO: IIUC, xiv's uv2 is usually represented as the second two components of a vec4 uv attribute - add support. + var materialUsages = ( + usages.Contains(MdlFile.VertexUsage.UV), + usages.Contains(MdlFile.VertexUsage.Color) + ); + + return materialUsages switch + { + (true, true) => typeof(VertexColor1Texture1), + (true, false) => typeof(VertexTexture1), + (false, true) => typeof(VertexColor1), + (false, false) => typeof(VertexEmpty), + }; + } + + private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary attributes) + { + if (_materialType == typeof(VertexEmpty)) + return new VertexEmpty(); + + if (_materialType == typeof(VertexColor1)) + return new VertexColor1(ToVector4(attributes[MdlFile.VertexUsage.Color])); + + if (_materialType == typeof(VertexTexture1)) + return new VertexTexture1(ToVector2(attributes[MdlFile.VertexUsage.UV])); + + if (_materialType == typeof(VertexColor1Texture1)) + return new VertexColor1Texture1( + ToVector4(attributes[MdlFile.VertexUsage.Color]), + ToVector2(attributes[MdlFile.VertexUsage.UV]) + ); + + throw new Exception($"Unknown material type {_skinningType}"); + } + private Type GetSkinningType(IReadOnlySet usages) { // TODO: possibly need to check only index - weight might be missing? @@ -314,6 +354,15 @@ public class MeshExporter private Vector4 FixTangentVector(Vector4 tangent) => tangent with { W = tangent.W == 1 ? 1 : -1 }; + private Vector2 ToVector2(object data) + => data switch + { + Vector2 v2 => v2, + Vector3 v3 => new Vector2(v3.X, v3.Y), + Vector4 v4 => new Vector2(v4.X, v4.Y), + _ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}") + }; + private Vector3 ToVector3(object data) => data switch { From dc845b766e654a1cfef2ff1c792091c3999eabea Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 00:57:27 +1100 Subject: [PATCH 20/35] Clean up top-level conversion utilities. --- Penumbra/Import/Models/HavokConverter.cs | 60 +++++++++------------ Penumbra/Import/Models/ModelManager.cs | 9 +--- Penumbra/Import/Models/SkeletonConverter.cs | 47 +++++++++------- 3 files changed, 55 insertions(+), 61 deletions(-) diff --git a/Penumbra/Import/Models/HavokConverter.cs b/Penumbra/Import/Models/HavokConverter.cs index 515c6f97..7f87d50a 100644 --- a/Penumbra/Import/Models/HavokConverter.cs +++ b/Penumbra/Import/Models/HavokConverter.cs @@ -2,24 +2,19 @@ using FFXIVClientStructs.Havok; namespace Penumbra.Import.Models; -// TODO: where should this live? interop i guess, in penum? or game data? -public unsafe class HavokConverter +public static unsafe class HavokConverter { - /// Creates a temporary file and returns its path. - /// Path to a temporary file. - private string CreateTempFile() + /// Creates a temporary file and returns its path. + private static string CreateTempFile() { - var s = File.Create(Path.GetTempFileName()); - s.Close(); - return s.Name; + var stream = File.Create(Path.GetTempFileName()); + stream.Close(); + return stream.Name; } - /// Converts a .hkx file to a .xml file. - /// A byte array representing the .hkx file. - /// A string representing the .xml file. - /// Thrown if parsing the .hkx file fails. - /// Thrown if writing the .xml file fails. - public string HkxToXml(byte[] hkx) + /// Converts a .hkx file to a .xml file. + /// A byte array representing the .hkx file. + public static string HkxToXml(byte[] hkx) { var tempHkx = CreateTempFile(); File.WriteAllBytes(tempHkx, hkx); @@ -27,7 +22,7 @@ public unsafe class HavokConverter var resource = Read(tempHkx); File.Delete(tempHkx); - if (resource == null) throw new Exception("HavokReadException"); + if (resource == null) throw new Exception("Failed to read havok file."); var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers | hkSerializeUtil.SaveOptionBits.TextFormat @@ -42,12 +37,9 @@ public unsafe class HavokConverter return bytes; } - /// Converts a .xml file to a .hkx file. - /// A string representing the .xml file. - /// A byte array representing the .hkx file. - /// Thrown if parsing the .xml file fails. - /// Thrown if writing the .hkx file fails. - public byte[] XmlToHkx(string xml) + /// Converts an .xml file to a .hkx file. + /// A string representing the .xml file. + public static byte[] XmlToHkx(string xml) { var tempXml = CreateTempFile(); File.WriteAllText(tempXml, xml); @@ -55,7 +47,7 @@ public unsafe class HavokConverter var resource = Read(tempXml); File.Delete(tempXml); - if (resource == null) throw new Exception("HavokReadException"); + if (resource == null) throw new Exception("Failed to read havok file."); var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers | hkSerializeUtil.SaveOptionBits.WriteAttributes; @@ -74,9 +66,8 @@ public unsafe class HavokConverter /// The type is guessed automatically by Havok. /// This pointer might be null - you should check for that. /// - /// Path to a file on the filesystem. - /// A (potentially null) pointer to an hkResource. - private hkResource* Read(string filePath) + /// Path to a file on the filesystem. + private static hkResource* Read(string filePath) { var path = Marshal.StringToHGlobalAnsi(filePath); @@ -87,18 +78,15 @@ public unsafe class HavokConverter loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); - // TODO: probably can loadfrombuffer this + // TODO: probably can use LoadFromBuffer for this. var resource = hkSerializeUtil.LoadFromFile((byte*)path, null, loadOptions); return resource; } - /// Serializes an hkResource* to a temporary file. - /// A pointer to the hkResource, opened through Read(). - /// Flags representing how to serialize the file. - /// An opened FileStream of a temporary file. You are expected to read the file and delete it. - /// Thrown if accessing the root level container fails. - /// Thrown if an unknown failure in writing occurs. - private FileStream Write( + /// Serializes an hkResource* to a temporary file. + /// A pointer to the hkResource, opened through Read(). + /// Flags representing how to serialize the file. + private static FileStream Write( hkResource* resource, hkSerializeUtil.SaveOptionBits optionBits ) @@ -125,16 +113,16 @@ public unsafe class HavokConverter var name = "hkRootLevelContainer"; var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry); - if (resourcePtr == null) throw new Exception("HavokWriteException"); + if (resourcePtr == null) throw new Exception("Failed to retrieve havok root level container resource."); var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name); - if (hkRootLevelContainerClass == null) throw new Exception("HavokWriteException"); + if (hkRootLevelContainerClass == null) throw new Exception("Failed to retrieve havok root level container type."); hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions); } finally { oStream.Dtor(); } - if (result->Result == hkResult.hkResultEnum.Failure) throw new Exception("HavokFailureException"); + if (result->Result == hkResult.hkResultEnum.Failure) throw new Exception("Failed to serialize havok file."); return new FileStream(tempFile, FileMode.Open); } diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 9f72619f..a56d7168 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -89,17 +89,12 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable if (_sklb == null) return null; - // TODO: Consider making these static methods. // TODO: work out how i handle this havok deal. running it outside the framework causes an immediate ctd. - var havokConverter = new HavokConverter(); - var xmlTask = _manager._framework.RunOnFrameworkThread(() => havokConverter.HkxToXml(_sklb.Skeleton)); + var xmlTask = _manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(_sklb.Skeleton)); xmlTask.Wait(cancel); var xml = xmlTask.Result; - var skeletonConverter = new SkeletonConverter(); - var skeleton = skeletonConverter.FromXml(xml); - - return skeleton; + return SkeletonConverter.FromXml(xml); } public bool Equals(IAction? other) diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs index e265e5c3..24bcf3e0 100644 --- a/Penumbra/Import/Models/SkeletonConverter.cs +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -4,10 +4,11 @@ using Penumbra.Import.Models.Export; namespace Penumbra.Import.Models; -// TODO: tempted to say that this living here is more okay? that or next to havok converter, wherever that ends up. -public class SkeletonConverter +public static class SkeletonConverter { - public XivSkeleton FromXml(string xml) + /// Parse XIV skeleton data from a havok XML tagfile. + /// Havok XML tagfile containing skeleton data. + public static XivSkeleton FromXml(string xml) { var document = new XmlDocument(); document.LoadXml(xml); @@ -16,14 +17,14 @@ public class SkeletonConverter var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']"); if (skeletonNode == null) - throw new InvalidDataException(); + throw new InvalidDataException($"Failed to find skeleton with id {mainSkeletonId}."); var referencePose = ReadReferencePose(skeletonNode); var parentIndices = ReadParentIndices(skeletonNode); var boneNames = ReadBoneNames(skeletonNode); if (boneNames.Length != parentIndices.Length || boneNames.Length != referencePose.Length) - throw new InvalidDataException(); + throw new InvalidDataException($"Mismatch in bone value array lengths: names({boneNames.Length}) parents({parentIndices.Length}) pose({referencePose.Length})"); var bones = referencePose .Zip(parentIndices, boneNames) @@ -38,27 +39,27 @@ public class SkeletonConverter }; }) .ToArray(); - + return new XivSkeleton(bones); } - /// Get the main skeleton ID for a given skeleton document. - /// XML skeleton document. - private string GetMainSkeletonId(XmlNode node) + /// Get the main skeleton ID for a given skeleton document. + /// XML skeleton document. + private static string GetMainSkeletonId(XmlNode node) { var animationSkeletons = node .SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']/array[@name='skeletons']")? .ChildNodes; if (animationSkeletons?.Count != 1) - throw new Exception($"Assumption broken: Expected 1 hkaAnimationContainer skeleton, got {animationSkeletons?.Count ?? 0}"); + throw new Exception($"Assumption broken: Expected 1 hkaAnimationContainer skeleton, got {animationSkeletons?.Count ?? 0}."); return animationSkeletons[0]!.InnerText; } - /// Read the reference pose transforms for a skeleton. - /// XML node for the skeleton. - private XivSkeleton.Transform[] ReadReferencePose(XmlNode node) + /// Read the reference pose transforms for a skeleton. + /// XML node for the skeleton. + private static XivSkeleton.Transform[] ReadReferencePose(XmlNode node) { return ReadArray( CheckExists(node.SelectSingleNode("array[@name='referencePose']")), @@ -75,7 +76,9 @@ public class SkeletonConverter ); } - private float[] ReadVec12(XmlNode node) + /// Read a 12-item vector from a tagfile. + /// Havok Vec12 XML node. + private static float[] ReadVec12(XmlNode node) { var array = node.ChildNodes .Cast() @@ -89,12 +92,14 @@ public class SkeletonConverter .ToArray(); if (array.Length != 12) - throw new InvalidDataException(); + throw new InvalidDataException($"Unexpected Vector12 length ({array.Length})."); return array; } - private int[] ReadParentIndices(XmlNode node) + /// Read the bone parent relations for a skeleton. + /// XML node for the skeleton. + private static int[] ReadParentIndices(XmlNode node) { // todo: would be neat to genericise array between bare and children return CheckExists(node.SelectSingleNode("array[@name='parentIndices']")) @@ -104,7 +109,9 @@ public class SkeletonConverter .ToArray(); } - private string[] ReadBoneNames(XmlNode node) + /// Read the names of bones in a skeleton. + /// XML node for the skeleton. + private static string[] ReadBoneNames(XmlNode node) { return ReadArray( CheckExists(node.SelectSingleNode("array[@name='bones']")), @@ -112,7 +119,10 @@ public class SkeletonConverter ); } - private T[] ReadArray(XmlNode node, Func convert) + /// Read an XML tagfile array, converting it via the provided conversion function. + /// Tagfile XML array node. + /// Function to convert array item nodes to required data types. + private static T[] ReadArray(XmlNode node, Func convert) { var element = (XmlElement)node; @@ -125,6 +135,7 @@ public class SkeletonConverter return array; } + /// Check if the argument is null, returning a non-nullable value if it exists, and throwing if not. private static T CheckExists(T? value) { ArgumentNullException.ThrowIfNull(value); From 518117b25a5a31c5b1347541c78c5c0f57aeb98d Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 11:01:31 +1100 Subject: [PATCH 21/35] Add submeshless support --- Penumbra/Import/Models/Export/MeshExporter.cs | 34 +++++++++++-------- Penumbra/Import/Models/ModelManager.cs | 6 +++- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 06ca747b..7b51ca31 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -34,7 +34,6 @@ public class MeshExporter } } - // TODO: replace bonenamemap with a gltfskeleton public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, GltfSkeleton? skeleton) { var self = new MeshExporter(mdl, lod, meshIndex, skeleton?.Names); @@ -74,7 +73,7 @@ public class MeshExporter private Dictionary BuildBoneIndexMap(Dictionary boneNameMap) { - // todo: BoneTableIndex of 255 means null? if so, it should probably feed into the attributes we assign... + // TODO: BoneTableIndex of 255 means null? if so, it should probably feed into the attributes we assign... var xivBoneTable = _mdl.BoneTables[XivMesh.BoneTableIndex]; var indexMap = new Dictionary(); @@ -97,20 +96,25 @@ public class MeshExporter var indices = BuildIndices(); var vertices = BuildVertices(); - // TODO: handle SubMeshCount = 0 + // 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(indices, vertices, 0, (int)XivMesh.IndexCount)]; return _mdl.SubMeshes .Skip(XivMesh.SubMeshIndex) .Take(XivMesh.SubMeshCount) - .Select(submesh => BuildSubMesh(submesh, indices, vertices)) + .Select(submesh => BuildMesh(indices, vertices, (int)(submesh.IndexOffset - XivMesh.StartIndex), (int)submesh.IndexCount)) .ToArray(); } - private IMeshBuilder BuildSubMesh(MdlStructs.SubmeshStruct submesh, IReadOnlyList indices, IReadOnlyList vertices) + private IMeshBuilder BuildMesh( + IReadOnlyList indices, + IReadOnlyList vertices, + int indexBase, + int indexCount + ) { - // Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh. - var startIndex = (int)(submesh.IndexOffset - XivMesh.StartIndex); - var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( typeof(MaterialBuilder), _geometryType, @@ -131,12 +135,12 @@ public class MeshExporter var gltfIndices = new List(); // All XIV meshes use triangle lists. - for (var indexOffset = 0; indexOffset < submesh.IndexCount; indexOffset += 3) + for (var indexOffset = 0; indexOffset < indexCount; indexOffset += 3) { var (a, b, c) = primitiveBuilder.AddTriangle( - vertices[indices[indexOffset + startIndex + 0]], - vertices[indices[indexOffset + startIndex + 1]], - vertices[indices[indexOffset + startIndex + 2]] + vertices[indices[indexBase + indexOffset + 0]], + vertices[indices[indexBase + indexOffset + 1]], + vertices[indices[indexBase + indexOffset + 2]] ); gltfIndices.AddRange([a, b, c]); } @@ -157,8 +161,8 @@ public class MeshExporter .Take((int)shapeMesh.ShapeValueCount) ) .Where(shapeValue => - shapeValue.BaseIndicesIndex >= startIndex - && shapeValue.BaseIndicesIndex < startIndex + submesh.IndexCount + shapeValue.BaseIndicesIndex >= indexBase + && shapeValue.BaseIndicesIndex < indexBase + indexCount ) .ToList(); @@ -169,7 +173,7 @@ public class MeshExporter foreach (var shapeValue in shapeValues) morphBuilder.SetVertex( - primitiveVertices[gltfIndices[shapeValue.BaseIndicesIndex - startIndex]].GetGeometry(), + primitiveVertices[gltfIndices[shapeValue.BaseIndicesIndex - indexBase]].GetGeometry(), vertices[shapeValue.ReplacingVertexIndex].GetGeometry() ); } diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index a56d7168..4f761549 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -73,17 +73,21 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public void Execute(CancellationToken cancel) { + Penumbra.Log.Debug("Reading skeleton."); var xivSkeleton = BuildSkeleton(cancel); + + Penumbra.Log.Debug("Converting model."); var model = ModelExporter.Export(_mdl, xivSkeleton); + Penumbra.Log.Debug("Building scene."); var scene = new SceneBuilder(); model.AddToScene(scene); + Penumbra.Log.Debug("Saving."); var gltfModel = scene.ToGltf2(); gltfModel.SaveGLTF(_outputPath); } - // TODO: this should be moved to a seperate model converter or something private XivSkeleton? BuildSkeleton(CancellationToken cancel) { if (_sklb == null) From 08ed3ca447149d7cc3c70ca99a74f6c6dfe1d108 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 11:31:38 +1100 Subject: [PATCH 22/35] Handle mesh skeleton edge cases --- Penumbra/Import/Models/Export/MeshExporter.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 7b51ca31..e835fe62 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -69,11 +69,18 @@ public class MeshExporter _geometryType = GetGeometryType(usages); _materialType = GetMaterialType(usages); _skinningType = GetSkinningType(usages); + + // If there's skinning usages but no bone mapping, there's probably something wrong with the data. + if (_skinningType != typeof(VertexEmpty) && _boneIndexMap == null) + Penumbra.Log.Warning($"Mesh {meshIndex} has skinned vertex usages but no bone information was provided."); } - private Dictionary BuildBoneIndexMap(Dictionary boneNameMap) + private Dictionary? BuildBoneIndexMap(Dictionary boneNameMap) { - // TODO: BoneTableIndex of 255 means null? if so, it should probably feed into the attributes we assign... + // A BoneTableIndex of 255 means that this mesh is not skinned. + if (XivMesh.BoneTableIndex == 255) + return null; + var xivBoneTable = _mdl.BoneTables[XivMesh.BoneTableIndex]; var indexMap = new Dictionary(); @@ -82,8 +89,7 @@ public class MeshExporter { var boneName = _mdl.Bones[xivBoneIndex]; if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) - // TODO: handle - i think this is a hard failure, it means that a bone name in the model doesn't exist in the armature. - throw new Exception($"looking for {boneName} in {string.Join(", ", boneNameMap.Keys)}"); + throw new Exception($"Armature does not contain bone \"{boneName}\" requested by mesh {_meshIndex}."); indexMap.Add(xivBoneIndex, gltfBoneIndex); } From a059942bb2c4d4ae6efbbef19a92b36a512695a8 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 12:57:56 +1100 Subject: [PATCH 23/35] Clean up + docs --- Penumbra/Import/Models/Export/MeshExporter.cs | 52 ++++++++++++++----- .../Import/Models/Export/ModelExporter.cs | 3 ++ Penumbra/Import/Models/Export/Skeleton.cs | 7 +++ Penumbra/Import/Models/ModelManager.cs | 1 + 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index e835fe62..cf7cc975 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Lumina.Data.Parsing; using Lumina.Extensions; +using OtterGui; using Penumbra.GameData.Files; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; @@ -25,7 +26,6 @@ public class MeshExporter public void AddToScene(SceneBuilder scene) { - // TODO: throw if mesh has skinned vertices but no joints are available? foreach (var mesh in _meshes) if (_joints == null) scene.AddRigidMesh(mesh, Matrix4x4.Identity); @@ -73,8 +73,11 @@ public class MeshExporter // If there's skinning usages but no bone mapping, there's probably something wrong with the data. if (_skinningType != typeof(VertexEmpty) && _boneIndexMap == null) Penumbra.Log.Warning($"Mesh {meshIndex} has skinned vertex usages but no bone information was provided."); + + Penumbra.Log.Debug($"Mesh {meshIndex} using vertex types geometry: {_geometryType.Name}, material: {_materialType.Name}, skinning: {_skinningType.Name}"); } + /// Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provdied. private Dictionary? BuildBoneIndexMap(Dictionary boneNameMap) { // A BoneTableIndex of 255 means that this mesh is not skinned. @@ -97,6 +100,7 @@ public class MeshExporter return indexMap; } + /// Build glTF meshes for this XIV mesh. private IMeshBuilder[] BuildMeshes() { var indices = BuildIndices(); @@ -105,16 +109,19 @@ public class MeshExporter // 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(indices, vertices, 0, (int)XivMesh.IndexCount)]; + return [BuildMesh($"mesh {_meshIndex}", indices, vertices, 0, (int)XivMesh.IndexCount)]; return _mdl.SubMeshes .Skip(XivMesh.SubMeshIndex) .Take(XivMesh.SubMeshCount) - .Select(submesh => BuildMesh(indices, vertices, (int)(submesh.IndexOffset - XivMesh.StartIndex), (int)submesh.IndexCount)) + .WithIndex() + .Select(submesh => BuildMesh($"mesh {_meshIndex}.{submesh.Index}", indices, vertices, (int)(submesh.Value.IndexOffset - XivMesh.StartIndex), (int)submesh.Value.IndexCount)) .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( + string name, IReadOnlyList indices, IReadOnlyList vertices, int indexBase, @@ -127,7 +134,7 @@ public class MeshExporter _materialType, _skinningType ); - var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, $"mesh{_meshIndex}")!; + var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, name)!; // TODO: share materials &c var materialBuilder = new MaterialBuilder() @@ -191,6 +198,7 @@ public class MeshExporter return meshBuilder; } + /// Read in the indices for this mesh. private IReadOnlyList BuildIndices() { var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); @@ -198,6 +206,7 @@ public class MeshExporter return reader.ReadStructuresAsArray((int)XivMesh.IndexCount); } + /// Build glTF-compatible vertex data for all vertices in this mesh. private IReadOnlyList BuildVertices() { var vertexBuilderType = typeof(VertexBuilder<,,>) @@ -224,7 +233,7 @@ public class MeshExporter attributes.Clear(); foreach (var (usage, element) in sortedElements) - attributes[usage] = ReadVertexAttribute(streams[element.Stream], element); + attributes[usage] = ReadVertexAttribute((MdlFile.VertexType)element.Type, streams[element.Stream]); var vertexGeometry = BuildVertexGeometry(attributes); var vertexMaterial = BuildVertexMaterial(attributes); @@ -237,9 +246,10 @@ public class MeshExporter return vertices; } - private object ReadVertexAttribute(BinaryReader reader, MdlStructs.VertexElement element) + /// Read a vertex attribute of the specified type from a vertex buffer stream. + private object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader) { - return (MdlFile.VertexType)element.Type switch + return type switch { MdlFile.VertexType.Single3 => new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), @@ -252,6 +262,7 @@ public class MeshExporter }; } + /// Get the vertex geometry type for this mesh's vertex usages. private Type GetGeometryType(IReadOnlySet usages) { if (!usages.Contains(MdlFile.VertexUsage.Position)) @@ -266,6 +277,7 @@ public class MeshExporter return typeof(VertexPositionNormalTangent); } + /// Build a geometry vertex from a vertex's attributes. private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary attributes) { if (_geometryType == typeof(VertexPosition)) @@ -289,6 +301,7 @@ public class MeshExporter throw new Exception($"Unknown geometry type {_geometryType}."); } + /// Get the vertex material type for this mesh's vertex usages. private Type GetMaterialType(IReadOnlySet usages) { // TODO: IIUC, xiv's uv2 is usually represented as the second two components of a vec4 uv attribute - add support. @@ -306,6 +319,7 @@ public class MeshExporter }; } + /// Build a material vertex from a vertex's attributes. private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary attributes) { if (_materialType == typeof(VertexEmpty)) @@ -326,15 +340,16 @@ public class MeshExporter throw new Exception($"Unknown material type {_skinningType}"); } + /// Get the vertex skinning type for this mesh's vertex usages. private Type GetSkinningType(IReadOnlySet usages) { - // TODO: possibly need to check only index - weight might be missing? if (usages.Contains(MdlFile.VertexUsage.BlendWeights) && usages.Contains(MdlFile.VertexUsage.BlendIndices)) return typeof(VertexJoints4); return typeof(VertexEmpty); } + /// Build a skinning vertex from a vertex's attributes. private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary attributes) { if (_skinningType == typeof(VertexEmpty)) @@ -342,17 +357,21 @@ public class MeshExporter if (_skinningType == typeof(VertexJoints4)) { - // todo: this shouldn't happen... right? better approach? if (_boneIndexMap == null) - throw new Exception("cannot build skinned vertex without index mapping"); + throw new Exception("Tried to build skinned vertex but no bone mappings are available."); var indices = ToByteArray(attributes[MdlFile.VertexUsage.BlendIndices]); var weights = ToVector4(attributes[MdlFile.VertexUsage.BlendWeights]); - // todo: if this throws on the bone index map, the mod is broken, as it contains weights for bones that do not exist. - // i've not seen any of these that even tt can understand var bindings = Enumerable.Range(0, 4) - .Select(index => (_boneIndexMap[indices[index]], weights[index])) + .Select(bindingIndex => { + // NOTE: I've not seen any files that throw this error that aren't completely broken. + var xivBoneIndex = indices[bindingIndex]; + if (!_boneIndexMap.TryGetValue(xivBoneIndex, out var jointIndex)) + throw new Exception($"Vertex contains weight for unknown bone index {xivBoneIndex}."); + + return (jointIndex, weights[bindingIndex]); + }) .ToArray(); return new VertexJoints4(bindings); } @@ -360,10 +379,12 @@ public class MeshExporter throw new Exception($"Unknown skinning type {_skinningType}"); } - // Some tangent W values that should be -1 are stored as 0. + /// Clamps any tangent W value other than 1 to -1. + /// Some XIV models seemingly store -1 as 0, this patches over that. private Vector4 FixTangentVector(Vector4 tangent) => tangent with { W = tangent.W == 1 ? 1 : -1 }; + /// Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. private Vector2 ToVector2(object data) => data switch { @@ -373,6 +394,7 @@ public class MeshExporter _ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}") }; + /// Convert a vertex attribute value to a Vector3. Supported inputs are Vector2, Vector3, and Vector4. private Vector3 ToVector3(object data) => data switch { @@ -382,6 +404,7 @@ public class MeshExporter _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") }; + /// Convert a vertex attribute value to a Vector4. Supported inputs are Vector2, Vector3, and Vector4. private Vector4 ToVector4(object data) => data switch { @@ -391,6 +414,7 @@ public class MeshExporter _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") }; + /// Convert a vertex attribute value to a byte array. private byte[] ToByteArray(object data) => data switch { diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index c8716cf3..35819e7a 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -30,6 +30,7 @@ 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, XivSkeleton? xivSkeleton) { var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; @@ -37,6 +38,7 @@ public class ModelExporter return new Model(meshes, gltfSkeleton); } + /// Convert a .mdl to a mesh (group) per LoD. private static List ConvertMeshes(MdlFile mdl, GltfSkeleton? skeleton) { var meshes = new List(); @@ -56,6 +58,7 @@ public class ModelExporter return meshes; } + /// Convert XIV skeleton data into a glTF-compatible node tree, with mappings. private static GltfSkeleton? ConvertSkeleton(XivSkeleton skeleton) { NodeBuilder? root = null; diff --git a/Penumbra/Import/Models/Export/Skeleton.cs b/Penumbra/Import/Models/Export/Skeleton.cs index 13379dc4..09cdcc32 100644 --- a/Penumbra/Import/Models/Export/Skeleton.cs +++ b/Penumbra/Import/Models/Export/Skeleton.cs @@ -2,6 +2,7 @@ using SharpGLTF.Scenes; namespace Penumbra.Import.Models.Export; +/// Representation of a skeleton within XIV. public class XivSkeleton { public Bone[] Bones; @@ -25,9 +26,15 @@ public class XivSkeleton } } +/// Representation of a glTF-compatible skeleton. public struct GltfSkeleton { + /// Root node of the skeleton. public NodeBuilder Root; + + /// Flattened list of skeleton nodes. public NodeBuilder[] Joints; + + /// Mapping of bone names to their index within the joints array. public Dictionary Names; } diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 4f761549..e71b8baf 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -88,6 +88,7 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable gltfModel.SaveGLTF(_outputPath); } + /// Attempt to read out the pertinent information from a .sklb. private XivSkeleton? BuildSkeleton(CancellationToken cancel) { if (_sklb == null) From 9f981a3e52268c6b1c98959c9eb4c0d398ef8837 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 13:10:50 +1100 Subject: [PATCH 24/35] Render export errors --- Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs | 6 +++++- Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 99c32761..31f2f7e0 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -17,6 +17,7 @@ public partial class ModEditWindow private readonly List[] _attributes; public bool PendingIo { get; private set; } = false; + public string? IoException { get; private set; } = null; // TODO: this can probably be genericised across all of chara [GeneratedRegex(@"chara/equipment/e(?'Set'\d{4})/model/c(?'Race'\d{4})e\k'Set'_.+\.mdl", RegexOptions.Compiled)] @@ -74,7 +75,10 @@ public partial class ModEditWindow PendingIo = true; _edit._models.ExportToGltf(Mdl, sklb, outputPath) - .ContinueWith(_ => PendingIo = false); + .ContinueWith(task => { + IoException = task.Exception?.ToString(); + PendingIo = false; + }); } /// Try to find the .sklb path for a .mdl file. diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index ff2c1ae5..57c47b8f 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -42,6 +42,9 @@ public partial class ModEditWindow foreach (var gamePath in tab.GamePaths) ImGui.TextUnformatted(gamePath.ToString()); + if (tab.IoException != null) + ImGui.TextUnformatted(tab.IoException); + var ret = false; ret |= DrawModelMaterialDetails(tab, disabled); From 73ff3642fc7ae2e41234dc7eca6c33a3e6ae50a6 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 13:30:04 +1100 Subject: [PATCH 25/35] Async game path resolution --- Penumbra/Import/Models/ModelManager.cs | 1 - .../ModEditWindow.Models.MdlTab.cs | 41 +++++++++++-------- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 12 +++--- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index e71b8baf..217450dd 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -4,7 +4,6 @@ using Penumbra.Collections.Manager; using Penumbra.GameData.Files; using Penumbra.Import.Models.Export; using SharpGLTF.Scenes; -using SharpGLTF.Transforms; namespace Penumbra.Import.Models; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 31f2f7e0..ba6435c7 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -13,7 +13,7 @@ public partial class ModEditWindow private ModEditWindow _edit; public readonly MdlFile Mdl; - public readonly List GamePaths; + public List? GamePaths { get; private set ;} private readonly List[] _attributes; public bool PendingIo { get; private set; } = false; @@ -28,8 +28,10 @@ public partial class ModEditWindow _edit = edit; Mdl = new MdlFile(bytes); - GamePaths = mod == null ? new() : FindGamePaths(path, mod); _attributes = CreateAttributes(Mdl); + + if (mod != null) + FindGamePaths(path, mod); } /// @@ -40,36 +42,41 @@ public partial class ModEditWindow public byte[] Write() => Mdl.Write(); - // TODO: this _needs_ to be done asynchronously, kart mods hang for a good second or so /// Find the list of game paths that may correspond to this model. /// Resolved path to a .mdl. /// Mod within which the .mdl is resolved. - private List FindGamePaths(string path, Mod mod) + private void FindGamePaths(string path, Mod mod) { - // todo: might be worth ordering based on prio + selection for disambiguating between multiple matches? not sure. same for the multi group case - return mod.AllSubMods - .SelectMany(submod => submod.Files.Concat(submod.FileSwaps)) - // todo: using ordinal ignore case because the option group paths in mods being lowerecased somewhere, but the mod editor using fs paths, which may be uppercase. i'd say this will blow up on linux, but it's already the case so can't be too much worse than present right - .Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) - .Select(kv => kv.Key) - .ToList(); + PendingIo = true; + var task = Task.Run(() => { + // TODO: Is it worth trying to order results based on option priorities for cases where more than one match is found? + // NOTE: We're using case insensitive comparisons, as option group paths in mods are stored in lower case, but the mod editor uses paths directly from the file system, which may be mixed case. + return mod.AllSubMods + .SelectMany(submod => submod.Files.Concat(submod.FileSwaps)) + .Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .ToList(); + }); + + task.ContinueWith(task => { + IoException = task.Exception?.ToString(); + PendingIo = false; + GamePaths = task.Result; + }); } /// Export model to an interchange format. /// Disk path to save the resulting file to. - public void Export(string outputPath) + public void Export(string outputPath, Utf8GamePath mdlPath) { - // NOTES ON EST (i don't think it's worth supporting yet...) + // NOTES ON EST // for collection wide lookup; // Collections.Cache.EstCache::GetEstEntry // Collections.Cache.MetaCache::GetEstEntry // Collections.ModCollection.MetaCache? // for default lookup, probably; // EstFile.GetDefault(...) - - // TODO: allow user to pick the gamepath in the ui - // TODO: what if there's no gamepaths? - var mdlPath = GamePaths.First(); + var sklbPath = GetSklbPath(mdlPath.ToString()); var sklb = sklbPath != null ? ReadSklb(sklbPath) : null; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 57c47b8f..9af2dd91 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -34,13 +34,13 @@ public partial class ModEditWindow ); } - if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", tab.PendingIo)) - { - tab.Export("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); - } + if (tab.GamePaths != null) + if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", tab.PendingIo)) + tab.Export("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf", tab.GamePaths.First()); ImGui.TextUnformatted("blippity blap"); - foreach (var gamePath in tab.GamePaths) - ImGui.TextUnformatted(gamePath.ToString()); + if (tab.GamePaths != null) + foreach (var gamePath in tab.GamePaths) + ImGui.TextUnformatted(gamePath.ToString()); if (tab.IoException != null) ImGui.TextUnformatted(tab.IoException); From bb9e7cac074e86987b939c115948cd85f2372e9a Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 14:21:38 +1100 Subject: [PATCH 26/35] Clean up UI --- Penumbra/Import/Models/ModelManager.cs | 1 - .../ModEditWindow.Models.MdlTab.cs | 4 +- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 73 ++++++++++++++++--- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 217450dd..35a5e53e 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -93,7 +93,6 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable if (_sklb == null) return null; - // TODO: work out how i handle this havok deal. running it outside the framework causes an immediate ctd. var xmlTask = _manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(_sklb.Skeleton)); xmlTask.Wait(cancel); var xml = xmlTask.Result; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index ba6435c7..38c1c6bd 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -13,8 +13,10 @@ public partial class ModEditWindow private ModEditWindow _edit; public readonly MdlFile Mdl; - public List? GamePaths { get; private set ;} private readonly List[] _attributes; + + public List? GamePaths { get; private set ;} + public int GamePathIndex; public bool PendingIo { get; private set; } = false; public string? IoException { get; private set; } = null; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 9af2dd91..aa69953b 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -34,16 +34,7 @@ public partial class ModEditWindow ); } - if (tab.GamePaths != null) - if (ImGuiUtil.DrawDisabledButton("bingo bango", Vector2.Zero, "description", tab.PendingIo)) - tab.Export("C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf", tab.GamePaths.First()); - ImGui.TextUnformatted("blippity blap"); - if (tab.GamePaths != null) - foreach (var gamePath in tab.GamePaths) - ImGui.TextUnformatted(gamePath.ToString()); - - if (tab.IoException != null) - ImGui.TextUnformatted(tab.IoException); + DrawExport(tab, disabled); var ret = false; @@ -58,6 +49,68 @@ public partial class ModEditWindow return !disabled && ret; } + private void DrawExport(MdlTab tab, bool disabled) + { + // IO on a disabled panel doesn't really make sense. + if (disabled) + return; + + if (!ImGui.CollapsingHeader("Export")) + return; + + if (tab.GamePaths == null) + { + if (tab.IoException == null) + ImGui.TextUnformatted("Resolving model game paths."); + else + ImGuiUtil.TextWrapped(tab.IoException); + + return; + } + + DrawGamePathCombo(tab); + + if (ImGuiUtil.DrawDisabledButton("Export to glTF", Vector2.Zero, "Exports this mdl file to glTF, for use in 3D authoring applications.", tab.PendingIo)) + { + var gamePath = tab.GamePaths[tab.GamePathIndex]; + + _fileDialog.OpenSavePicker( + "Save model as glTF.", + ".gltf", + Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()), + ".gltf", + (valid, path) => { + if (!valid) + return; + + tab.Export(path, gamePath); + }, + _mod!.ModPath.FullName, + false + ); + } + + if (tab.IoException != null) + ImGuiUtil.TextWrapped(tab.IoException); + + return; + } + + private void DrawGamePathCombo(MdlTab tab) + { + using var combo = ImRaii.Combo("Game Path", tab.GamePaths![tab.GamePathIndex].ToString()); + if (!combo) + return; + + foreach (var (path, index) in tab.GamePaths.WithIndex()) + { + if (!ImGui.Selectable(path.ToString(), index == tab.GamePathIndex)) + continue; + + tab.GamePathIndex = index; + } + } + private bool DrawModelMaterialDetails(MdlTab tab, bool disabled) { if (!ImGui.CollapsingHeader("Materials")) From 215f8074833ed5c7e9550853fba58a08871b1659 Mon Sep 17 00:00:00 2001 From: ackwell Date: Mon, 1 Jan 2024 23:52:37 +1100 Subject: [PATCH 27/35] Fix oversight in bone index mapping generation --- Penumbra/Import/Models/Export/MeshExporter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index cf7cc975..75283732 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -88,13 +88,13 @@ public class MeshExporter var indexMap = new Dictionary(); - foreach (var xivBoneIndex in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount)) + foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount).WithIndex()) { var boneName = _mdl.Bones[xivBoneIndex]; if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) throw new Exception($"Armature does not contain bone \"{boneName}\" requested by mesh {_meshIndex}."); - indexMap.Add(xivBoneIndex, gltfBoneIndex); + indexMap.Add((ushort)tableIndex, gltfBoneIndex); } return indexMap; From d85cbd805124ad877a7cc45f418812c0fa43284c Mon Sep 17 00:00:00 2001 From: ackwell Date: Tue, 2 Jan 2024 12:41:14 +1100 Subject: [PATCH 28/35] Add UV2 support --- Penumbra/Import/Models/Export/MeshExporter.cs | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 75283732..5f67114d 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -63,8 +63,10 @@ public class MeshExporter _boneIndexMap = BuildBoneIndexMap(boneNameMap); var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements - .Select(element => (MdlFile.VertexUsage)element.Usage) - .ToImmutableHashSet(); + .ToImmutableDictionary( + element => (MdlFile.VertexUsage)element.Usage, + element => (MdlFile.VertexType)element.Type + ); _geometryType = GetGeometryType(usages); _materialType = GetMaterialType(usages); @@ -263,15 +265,15 @@ public class MeshExporter } /// Get the vertex geometry type for this mesh's vertex usages. - private Type GetGeometryType(IReadOnlySet usages) + private Type GetGeometryType(IReadOnlyDictionary usages) { - if (!usages.Contains(MdlFile.VertexUsage.Position)) + if (!usages.ContainsKey(MdlFile.VertexUsage.Position)) throw new Exception("Mesh does not contain position vertex elements."); - if (!usages.Contains(MdlFile.VertexUsage.Normal)) + if (!usages.ContainsKey(MdlFile.VertexUsage.Normal)) return typeof(VertexPosition); - if (!usages.Contains(MdlFile.VertexUsage.Tangent1)) + if (!usages.ContainsKey(MdlFile.VertexUsage.Tangent1)) return typeof(VertexPositionNormal); return typeof(VertexPositionNormalTangent); @@ -302,20 +304,32 @@ public class MeshExporter } /// Get the vertex material type for this mesh's vertex usages. - private Type GetMaterialType(IReadOnlySet usages) + private Type GetMaterialType(IReadOnlyDictionary usages) { - // TODO: IIUC, xiv's uv2 is usually represented as the second two components of a vec4 uv attribute - add support. + var uvCount = 0; + if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var type)) + uvCount = type switch + { + MdlFile.VertexType.Half2 => 1, + MdlFile.VertexType.Half4 => 2, + _ => throw new Exception($"Unexpected UV vertex type {type}.") + }; + var materialUsages = ( - usages.Contains(MdlFile.VertexUsage.UV), - usages.Contains(MdlFile.VertexUsage.Color) + uvCount, + usages.ContainsKey(MdlFile.VertexUsage.Color) ); return materialUsages switch { - (true, true) => typeof(VertexColor1Texture1), - (true, false) => typeof(VertexTexture1), - (false, true) => typeof(VertexColor1), - (false, false) => typeof(VertexEmpty), + (2, true) => typeof(VertexColor1Texture2), + (2, false) => typeof(VertexTexture2), + (1, true) => typeof(VertexColor1Texture1), + (1, false) => typeof(VertexTexture1), + (0, true) => typeof(VertexColor1), + (0, false) => typeof(VertexEmpty), + + _ => throw new Exception("Unreachable."), }; } @@ -337,13 +351,34 @@ public class MeshExporter ToVector2(attributes[MdlFile.VertexUsage.UV]) ); + // XIV packs two UVs into a single vec4 attribute. + + if (_materialType == typeof(VertexTexture2)) + { + var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]); + return new VertexTexture2( + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W) + ); + } + + if (_materialType == typeof(VertexColor1Texture2)) + { + var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]); + return new VertexColor1Texture2( + ToVector4(attributes[MdlFile.VertexUsage.Color]), + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W) + ); + } + throw new Exception($"Unknown material type {_skinningType}"); } /// Get the vertex skinning type for this mesh's vertex usages. - private Type GetSkinningType(IReadOnlySet usages) + private Type GetSkinningType(IReadOnlyDictionary usages) { - if (usages.Contains(MdlFile.VertexUsage.BlendWeights) && usages.Contains(MdlFile.VertexUsage.BlendIndices)) + if (usages.ContainsKey(MdlFile.VertexUsage.BlendWeights) && usages.ContainsKey(MdlFile.VertexUsage.BlendIndices)) return typeof(VertexJoints4); return typeof(VertexEmpty); From 655e2fd2cae574b3536d86ea9b4649fe9561812b Mon Sep 17 00:00:00 2001 From: ackwell Date: Tue, 2 Jan 2024 14:36:18 +1100 Subject: [PATCH 29/35] Flesh out skeleton path resolution a bit --- .../ModEditWindow.Models.MdlTab.cs | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index c4fb10f8..20a4129d 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -21,10 +21,15 @@ public partial class ModEditWindow public bool PendingIo { get; private set; } = false; public string? IoException { get; private set; } = null; - // TODO: this can probably be genericised across all of chara - [GeneratedRegex(@"chara/equipment/e(?'Set'\d{4})/model/c(?'Race'\d{4})e\k'Set'_.+\.mdl", RegexOptions.Compiled)] + [GeneratedRegex(@"chara/(?:equipment|accessory)/(?'Set'[a-z]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] private static partial Regex CharaEquipmentRegex(); + [GeneratedRegex(@"chara/human/(?'Race'c\d{4})/obj/(?'Type'[^/]+)/(?'Set'[^/]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] + private static partial Regex CharaHumanRegex(); + + [GeneratedRegex(@"chara/(?'SubCategory'demihuman|monster|weapon)/(?'Set'w\d{4})/obj/body/(?'Body'b\d{4})/model/\k'Set'\k'Body'.mdl", RegexOptions.Compiled)] + private static partial Regex CharaBodyRegex(); + public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) { _edit = edit; @@ -71,16 +76,14 @@ public partial class ModEditWindow /// Disk path to save the resulting file to. public void Export(string outputPath, Utf8GamePath mdlPath) { - // NOTES ON EST - // for collection wide lookup; - // Collections.Cache.EstCache::GetEstEntry - // Collections.Cache.MetaCache::GetEstEntry - // Collections.ModCollection.MetaCache? - // for default lookup, probably; - // EstFile.GetDefault(...) - - var sklbPath = GetSklbPath(mdlPath.ToString()); - var sklb = sklbPath != null ? ReadSklb(sklbPath) : null; + SklbFile? sklb = null; + try { + var sklbPath = GetSklbPath(mdlPath.ToString()); + sklb = sklbPath != null ? ReadSklb(sklbPath) : null; + } catch (Exception exception) { + IoException = exception?.ToString(); + return; + } PendingIo = true; _edit._models.ExportToGltf(Mdl, sklb, outputPath) @@ -94,15 +97,37 @@ public partial class ModEditWindow /// .mdl file to look up the skeleton for. private string? GetSklbPath(string mdlPath) { - // TODO: This needs to be drastically expanded, it's dodgy af rn - + // Equipment is skinned to the base body skeleton of the race they target. var match = CharaEquipmentRegex().Match(mdlPath); - if (!match.Success) - return null; + if (match.Success) + { + var race = match.Groups["Race"].Value; + return $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb"; + } - var race = match.Groups["Race"].Value; + // Some parts of human have their own skeletons. + match = CharaHumanRegex().Match(mdlPath); + if (match.Success) + { + var type = match.Groups["Type"].Value; + var race = match.Groups["Race"].Value; + return type switch + { + "body" or "tail" => $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb", + _ => throw new Exception($"Currently unsupported human model type \"{type}\"."), + }; + } - return $"chara/human/c{race}/skeleton/base/b0001/skl_c{race}b0001.sklb"; + // A few subcategories - such as weapons, demihumans, and monsters - have dedicated per-"body" skeletons. + match = CharaBodyRegex().Match(mdlPath); + if (match.Success) + { + var subCategory = match.Groups["SubCategory"].Value; + var set = match.Groups["Set"].Value; + return $"chara/{subCategory}/{set}/skeleton/base/b0001/skl_{set}b0001.sklb"; + } + + return null; } /// Read a .sklb from the active collection or game. @@ -111,7 +136,7 @@ public partial class ModEditWindow { // TODO: if cross-collection lookups are turned off, this conversion can be skipped if (!Utf8GamePath.FromString(sklbPath, out var utf8SklbPath, true)) - throw new Exception("TODO: handle - should it throw, or try to fail gracefully?"); + throw new Exception($"Resolved skeleton path {sklbPath} could not be converted to a game path."); var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath); // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... @@ -121,7 +146,7 @@ public partial class ModEditWindow FullPath path => File.ReadAllBytes(path.ToPath()), }; if (bytes == null) - throw new Exception("TODO: handle - this effectively means that the resolved path doesn't exist. graceful?"); + throw new Exception($"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?"); return new SklbFile(bytes); } From e8e87cc6cbadec1616cac63dca7b7f2f2642d173 Mon Sep 17 00:00:00 2001 From: ackwell Date: Tue, 2 Jan 2024 14:36:24 +1100 Subject: [PATCH 30/35] Whoops --- Penumbra/Import/Models/Export/MeshExporter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 5f67114d..1b53df8a 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -312,6 +312,7 @@ public class MeshExporter { MdlFile.VertexType.Half2 => 1, MdlFile.VertexType.Half4 => 2, + MdlFile.VertexType.Single4 => 2, _ => throw new Exception($"Unexpected UV vertex type {type}.") }; From 306a9c217a2436ae58e2eb454eab0a9639fdd789 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 5 Jan 2024 14:51:41 +0100 Subject: [PATCH 31/35] Fix FileDialog being drawn multiple times. --- Penumbra/UI/AdvancedWindow/FileEditor.cs | 2 -- Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs | 3 --- 2 files changed, 5 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/FileEditor.cs b/Penumbra/UI/AdvancedWindow/FileEditor.cs index 336be1a4..4783e76b 100644 --- a/Penumbra/UI/AdvancedWindow/FileEditor.cs +++ b/Penumbra/UI/AdvancedWindow/FileEditor.cs @@ -158,8 +158,6 @@ public class FileEditor : IDisposable where T : class, IWritable _quickImport = null; } - - _fileDialog.Draw(); } public void Reset() diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs index 82fc78c0..8d1c8cb7 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs @@ -6,7 +6,6 @@ using OtterGui.Raii; using OtterGui; using OtterGui.Classes; using Penumbra.GameData; -using Penumbra.GameData.Data; using Penumbra.GameData.Files; using Penumbra.GameData.Interop; using Penumbra.String; @@ -43,8 +42,6 @@ public partial class ModEditWindow ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); DrawOtherShaderPackageDetails(file); - file.FileDialog.Draw(); - ret |= file.Shpk.IsChanged(); return !disabled && ret; From 55f38865e3d9885b3643b7d7e588d5c069d966e9 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 5 Jan 2024 18:13:03 +0100 Subject: [PATCH 32/35] Memorize last selected mod and state of advanced editing window. --- Penumbra/Communication/ModPathChanged.cs | 3 ++ Penumbra/EphemeralConfig.cs | 32 +++++++++++-- Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 49 ++++++++++++-------- Penumbra/UI/ModsTab/ModFileSystemSelector.cs | 28 ++++++++--- 4 files changed, 82 insertions(+), 30 deletions(-) diff --git a/Penumbra/Communication/ModPathChanged.cs b/Penumbra/Communication/ModPathChanged.cs index 3ec64f7e..e6291781 100644 --- a/Penumbra/Communication/ModPathChanged.cs +++ b/Penumbra/Communication/ModPathChanged.cs @@ -19,6 +19,9 @@ public sealed class ModPathChanged() { public enum Priority { + /// + EphemeralConfig = -500, + /// CollectionCacheManagerAddition = -100, diff --git a/Penumbra/EphemeralConfig.cs b/Penumbra/EphemeralConfig.cs index 6c87d331..8cf23de6 100644 --- a/Penumbra/EphemeralConfig.cs +++ b/Penumbra/EphemeralConfig.cs @@ -2,7 +2,10 @@ using Dalamud.Interface.Internal.Notifications; using Newtonsoft.Json; using OtterGui.Classes; using Penumbra.Api.Enums; +using Penumbra.Communication; using Penumbra.Enums; +using Penumbra.Mods; +using Penumbra.Mods.Manager; using Penumbra.Services; using Penumbra.UI; using Penumbra.UI.ResourceWatcher; @@ -11,11 +14,14 @@ using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; namespace Penumbra; -public class EphemeralConfig : ISavable +public class EphemeralConfig : ISavable, IDisposable { [JsonIgnore] private readonly SaveService _saveService; + [JsonIgnore] + private readonly ModPathChanged _modPathChanged; + public int Version { get; set; } = Configuration.Constants.CurrentVersion; public int LastSeenVersion { get; set; } = PenumbraChangelog.LastChangelogVersion; public bool DebugSeparateWindow { get; set; } = false; @@ -31,17 +37,24 @@ public class EphemeralConfig : ISavable public TabType SelectedTab { get; set; } = TabType.Settings; public ChangedItemDrawer.ChangedItemIcon ChangedItemFilter { get; set; } = ChangedItemDrawer.DefaultFlags; public bool FixMainWindow { get; set; } = false; + public string LastModPath { get; set; } = string.Empty; + public bool AdvancedEditingOpen { get; set; } = false; /// /// Load the current configuration. /// Includes adding new colors and migrating from old versions. /// - public EphemeralConfig(SaveService saveService) + public EphemeralConfig(SaveService saveService, ModPathChanged modPathChanged) { - _saveService = saveService; + _saveService = saveService; + _modPathChanged = modPathChanged; Load(); + _modPathChanged.Subscribe(OnModPathChanged, ModPathChanged.Priority.EphemeralConfig); } + public void Dispose() + => _modPathChanged.Unsubscribe(OnModPathChanged); + private void Load() { static void HandleDeserializationError(object? sender, ErrorEventArgs errorArgs) @@ -80,8 +93,19 @@ public class EphemeralConfig : ISavable public void Save(StreamWriter writer) { - using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented }; + using var jWriter = new JsonTextWriter(writer); + jWriter.Formatting = Formatting.Indented; var serializer = new JsonSerializer { Formatting = Formatting.Indented }; serializer.Serialize(jWriter, this); } + + /// Overwrite the last saved mod path if it changes. + private void OnModPathChanged(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? _) + { + if (type is not ModPathChangeType.Moved || !string.Equals(old?.Name, LastModPath, StringComparison.OrdinalIgnoreCase)) + return; + + LastModPath = mod.Identifier; + Save(); + } } diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 96957ba8..167adafe 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -145,12 +145,20 @@ public partial class ModEditWindow : Window, IDisposable _materialTab.Reset(); _modelTab.Reset(); _shaderPackageTab.Reset(); + _config.Ephemeral.AdvancedEditingOpen = false; + _config.Ephemeral.Save(); } public override void Draw() { using var performance = _performance.Measure(PerformanceType.UiAdvancedWindow); + if (!_config.Ephemeral.AdvancedEditingOpen) + { + _config.Ephemeral.AdvancedEditingOpen = true; + _config.Ephemeral.Save(); + } + using var tabBar = ImRaii.TabBar("##tabs"); if (!tabBar) return; @@ -566,34 +574,36 @@ public partial class ModEditWindow : Window, IDisposable public ModEditWindow(PerformanceTracker performance, FileDialogService fileDialog, ItemSwapTab itemSwapTab, IDataManager gameData, Configuration config, ModEditor editor, ResourceTreeFactory resourceTreeFactory, MetaFileManager metaFileManager, StainService stainService, ActiveCollections activeCollections, ModMergeTab modMergeTab, - CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager, + CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager, ChangedItemDrawer changedItemDrawer, IObjectTable objects, IFramework framework, CharacterBaseDestructor characterBaseDestructor) : base(WindowBaseLabel) { - _performance = performance; - _itemSwapTab = itemSwapTab; - _gameData = gameData; - _config = config; - _editor = editor; - _metaFileManager = metaFileManager; - _stainService = stainService; - _activeCollections = activeCollections; - _modMergeTab = modMergeTab; - _communicator = communicator; - _dragDropManager = dragDropManager; - _textures = textures; - _models = models; - _fileDialog = fileDialog; - _objects = objects; - _framework = framework; + _performance = performance; + _itemSwapTab = itemSwapTab; + _gameData = gameData; + _config = config; + _editor = editor; + _metaFileManager = metaFileManager; + _stainService = stainService; + _activeCollections = activeCollections; + _modMergeTab = modMergeTab; + _communicator = communicator; + _dragDropManager = dragDropManager; + _textures = textures; + _models = models; + _fileDialog = fileDialog; + _objects = objects; + _framework = framework; _characterBaseDestructor = characterBaseDestructor; _materialTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", - () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, _) => new MdlTab(this, bytes, path, _mod)); + () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, + (bytes, path, _) => new MdlTab(this, bytes, path, _mod)); _shaderPackageTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", - () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty, + () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, + () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new ShpkTab(_fileDialog, bytes)); _center = new CombinedTexture(_left, _right); _textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex)); @@ -601,6 +611,7 @@ public partial class ModEditWindow : Window, IDisposable _quickImportViewer = new ResourceTreeViewer(_config, resourceTreeFactory, changedItemDrawer, 2, OnQuickImportRefresh, DrawQuickImportActions); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow); + IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true }; } public void Dispose() diff --git a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs index c42b1018..0990f27b 100644 --- a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs +++ b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs @@ -39,8 +39,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector 0) + { + var mod = _modManager.FirstOrDefault(m + => string.Equals(m.Identifier, _config.Ephemeral.LastModPath, StringComparison.OrdinalIgnoreCase)); + if (mod != null) + SelectByValue(mod); + } + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModFileSystemSelector); _communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ModFileSystemSelector); _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModFileSystemSelector); @@ -87,15 +94,15 @@ public sealed class ModFileSystemSelector : FileSystemSelector Date: Fri, 5 Jan 2024 19:02:50 +0100 Subject: [PATCH 33/35] Rework game path selection a bit. --- .../ModEditWindow.Models.MdlTab.cs | 63 +++++++++------ .../UI/AdvancedWindow/ModEditWindow.Models.cs | 79 +++++++++++++------ 2 files changed, 93 insertions(+), 49 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 20a4129d..93e674ea 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -12,27 +12,29 @@ public partial class ModEditWindow { private ModEditWindow _edit; - public readonly MdlFile Mdl; + public readonly MdlFile Mdl; private readonly List[] _attributes; - public List? GamePaths { get; private set ;} - public int GamePathIndex; - - public bool PendingIo { get; private set; } = false; + public List? GamePaths { get; private set; } + public int GamePathIndex; + + public bool PendingIo { get; private set; } = false; public string? IoException { get; private set; } = null; [GeneratedRegex(@"chara/(?:equipment|accessory)/(?'Set'[a-z]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] private static partial Regex CharaEquipmentRegex(); - [GeneratedRegex(@"chara/human/(?'Race'c\d{4})/obj/(?'Type'[^/]+)/(?'Set'[^/]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] + [GeneratedRegex(@"chara/human/(?'Race'c\d{4})/obj/(?'Type'[^/]+)/(?'Set'[^/]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", + RegexOptions.Compiled)] private static partial Regex CharaHumanRegex(); - [GeneratedRegex(@"chara/(?'SubCategory'demihuman|monster|weapon)/(?'Set'w\d{4})/obj/body/(?'Body'b\d{4})/model/\k'Set'\k'Body'.mdl", RegexOptions.Compiled)] + [GeneratedRegex(@"chara/(?'SubCategory'demihuman|monster|weapon)/(?'Set'w\d{4})/obj/body/(?'Body'b\d{4})/model/\k'Set'\k'Body'.mdl", + RegexOptions.Compiled)] private static partial Regex CharaBodyRegex(); public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) { - _edit = edit; + _edit = edit; Mdl = new MdlFile(bytes); _attributes = CreateAttributes(Mdl); @@ -54,21 +56,29 @@ public partial class ModEditWindow /// Mod within which the .mdl is resolved. private void FindGamePaths(string path, Mod mod) { + if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) + { + GamePaths = [p]; + return; + } + PendingIo = true; - var task = Task.Run(() => { + var task = Task.Run(() => + { // TODO: Is it worth trying to order results based on option priorities for cases where more than one match is found? - // NOTE: We're using case insensitive comparisons, as option group paths in mods are stored in lower case, but the mod editor uses paths directly from the file system, which may be mixed case. + // NOTE: We're using case-insensitive comparisons, as option group paths in mods are stored in lower case, but the mod editor uses paths directly from the file system, which may be mixed case. return mod.AllSubMods - .SelectMany(submod => submod.Files.Concat(submod.FileSwaps)) + .SelectMany(m => m.Files.Concat(m.FileSwaps)) .Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) .Select(kv => kv.Key) .ToList(); }); - task.ContinueWith(task => { - IoException = task.Exception?.ToString(); - PendingIo = false; - GamePaths = task.Result; + task.ContinueWith(t => + { + IoException = t.Exception?.ToString(); + PendingIo = false; + GamePaths = t.Result; }); } @@ -77,19 +87,23 @@ public partial class ModEditWindow public void Export(string outputPath, Utf8GamePath mdlPath) { SklbFile? sklb = null; - try { + try + { var sklbPath = GetSklbPath(mdlPath.ToString()); sklb = sklbPath != null ? ReadSklb(sklbPath) : null; - } catch (Exception exception) { + } + catch (Exception exception) + { IoException = exception?.ToString(); return; } PendingIo = true; _edit._models.ExportToGltf(Mdl, sklb, outputPath) - .ContinueWith(task => { + .ContinueWith(task => + { IoException = task.Exception?.ToString(); - PendingIo = false; + PendingIo = false; }); } @@ -114,7 +128,7 @@ public partial class ModEditWindow return type switch { "body" or "tail" => $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb", - _ => throw new Exception($"Currently unsupported human model type \"{type}\"."), + _ => throw new Exception($"Currently unsupported human model type \"{type}\"."), }; } @@ -123,7 +137,7 @@ public partial class ModEditWindow if (match.Success) { var subCategory = match.Groups["SubCategory"].Value; - var set = match.Groups["Set"].Value; + var set = match.Groups["Set"].Value; return $"chara/{subCategory}/{set}/skeleton/base/b0001/skl_{set}b0001.sklb"; } @@ -137,16 +151,17 @@ public partial class ModEditWindow // TODO: if cross-collection lookups are turned off, this conversion can be skipped if (!Utf8GamePath.FromString(sklbPath, out var utf8SklbPath, true)) throw new Exception($"Resolved skeleton path {sklbPath} could not be converted to a game path."); - + var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath); // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... var bytes = resolvedPath switch { - null => _edit._gameData.GetFile(sklbPath)?.Data, + null => _edit._gameData.GetFile(sklbPath)?.Data, FullPath path => File.ReadAllBytes(path.ToPath()), }; if (bytes == null) - throw new Exception($"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?"); + throw new Exception( + $"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?"); return new SklbFile(bytes); } diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index aa69953b..3891eb95 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -20,6 +20,8 @@ public partial class ModEditWindow private string _modelNewMaterial = string.Empty; private readonly List _subMeshAttributeTagWidgets = []; + private string _customPath = string.Empty; + private Utf8GamePath _customGamePath = Utf8GamePath.Empty; private bool DrawModelPanel(MdlTab tab, bool disabled) { @@ -51,10 +53,6 @@ public partial class ModEditWindow private void DrawExport(MdlTab tab, bool disabled) { - // IO on a disabled panel doesn't really make sense. - if (disabled) - return; - if (!ImGui.CollapsingHeader("Export")) return; @@ -70,16 +68,14 @@ public partial class ModEditWindow DrawGamePathCombo(tab); - if (ImGuiUtil.DrawDisabledButton("Export to glTF", Vector2.Zero, "Exports this mdl file to glTF, for use in 3D authoring applications.", tab.PendingIo)) - { - var gamePath = tab.GamePaths[tab.GamePathIndex]; - - _fileDialog.OpenSavePicker( - "Save model as glTF.", - ".gltf", - Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()), - ".gltf", - (valid, path) => { + var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count + ? tab.GamePaths[tab.GamePathIndex] + : _customGamePath; + 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) => + { if (!valid) return; @@ -88,27 +84,60 @@ public partial class ModEditWindow _mod!.ModPath.FullName, false ); - } if (tab.IoException != null) ImGuiUtil.TextWrapped(tab.IoException); - - return; } private void DrawGamePathCombo(MdlTab tab) { - using var combo = ImRaii.Combo("Game Path", tab.GamePaths![tab.GamePathIndex].ToString()); - if (!combo) - return; - - foreach (var (path, index) in tab.GamePaths.WithIndex()) + if (tab.GamePaths!.Count == 0) { - if (!ImGui.Selectable(path.ToString(), index == tab.GamePathIndex)) - continue; + ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); + if (ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) + if (!Utf8GamePath.FromString(_customPath, out _customGamePath, false)) + _customGamePath = Utf8GamePath.Empty; - tab.GamePathIndex = index; + return; } + + DrawComboButton(tab); + } + + private static void DrawComboButton(MdlTab tab) + { + const string label = "Game Path"; + var preview = tab.GamePaths![tab.GamePathIndex].ToString(); + var labelWidth = ImGui.CalcTextSize(label).X + ImGui.GetStyle().ItemInnerSpacing.X; + var buttonWidth = ImGui.GetContentRegionAvail().X - labelWidth; + if (tab.GamePaths!.Count == 1) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.FrameBg)) + .Push(ImGuiCol.ButtonHovered, ImGui.GetColorU32(ImGuiCol.FrameBgHovered)) + .Push(ImGuiCol.ButtonActive, ImGui.GetColorU32(ImGuiCol.FrameBgActive)); + using var group = ImRaii.Group(); + ImGui.Button(preview, new Vector2(buttonWidth, 0)); + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.TextUnformatted("Game Path"); + } + else + { + ImGui.SetNextItemWidth(buttonWidth); + using var combo = ImRaii.Combo("Game Path", preview); + if (combo.Success) + foreach (var (path, index) in tab.GamePaths.WithIndex()) + { + if (!ImGui.Selectable(path.ToString(), index == tab.GamePathIndex)) + continue; + + tab.GamePathIndex = index; + } + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImGui.SetClipboardText(preview); + ImGuiUtil.HoverTooltip("Right-Click to copy to clipboard.", ImGuiHoveredFlags.AllowWhenDisabled); } private bool DrawModelMaterialDetails(MdlTab tab, bool disabled) From 51bb9cf7cdabb7f0c47eb91405aa80e4945389ed Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sat, 6 Jan 2024 18:26:30 +0100 Subject: [PATCH 34/35] Use existing game path functionality for sklb resolving, some cleanup. --- Penumbra.GameData | 2 +- Penumbra/Import/Models/ModelManager.cs | 84 ++++++++++--------- .../ModEditWindow.Models.MdlTab.cs | 79 +++-------------- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 17 ++-- 4 files changed, 69 insertions(+), 113 deletions(-) diff --git a/Penumbra.GameData b/Penumbra.GameData index ac3fc098..83c01275 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit ac3fc0981ac8f503ac91d2419bd28c54f271763e +Subproject commit 83c012752cd9d13d39248eda85ab18cc59070a76 diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 35a5e53e..dd796a42 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,27 +1,20 @@ using Dalamud.Plugin.Services; using OtterGui.Tasks; -using Penumbra.Collections.Manager; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; using Penumbra.GameData.Files; using Penumbra.Import.Models.Export; using SharpGLTF.Scenes; namespace Penumbra.Import.Models; -public sealed class ModelManager : SingleTaskQueue, IDisposable +public sealed class ModelManager(IFramework framework, GamePathParser _parser) : SingleTaskQueue, IDisposable { - private readonly IFramework _framework; - private readonly IDataManager _gameData; - private readonly ActiveCollectionData _activeCollectionData; + private readonly IFramework _framework = framework; private readonly ConcurrentDictionary _tasks = new(); - private bool _disposed = false; - public ModelManager(IFramework framework, IDataManager gameData, ActiveCollectionData activeCollectionData) - { - _framework = framework; - _gameData = gameData; - _activeCollectionData = activeCollectionData; - } + private bool _disposed; public void Dispose() { @@ -31,6 +24,31 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable _tasks.Clear(); } + public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) + => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); + + /// Try to find the .sklb path for a .mdl file. + /// .mdl file to look up the skeleton for. + public string? ResolveSklbForMdl(string mdlPath) + { + var info = _parser.GetFileInfo(mdlPath); + if (info.FileType is not FileType.Model) + return null; + + return info.ObjectType switch + { + ObjectType.Equipment => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), + ObjectType.Accessory => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), + ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", + 1), + ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."), + ObjectType.DemiHuman => GamePaths.DemiHuman.Sklb.Path(info.PrimaryId), + ObjectType.Monster => GamePaths.Monster.Sklb.Path(info.PrimaryId), + ObjectType.Weapon => GamePaths.Weapon.Sklb.Path(info.PrimaryId), + _ => null, + }; + } + private Task Enqueue(IAction action) { if (_disposed) @@ -39,44 +57,34 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable Task task; lock (_tasks) { - task = _tasks.GetOrAdd(action, action => + task = _tasks.GetOrAdd(action, a => { var token = new CancellationTokenSource(); - var task = Enqueue(action, token.Token); - task.ContinueWith(_ => _tasks.TryRemove(action, out var unused), CancellationToken.None); - return (task, token); + var t = Enqueue(a, token.Token); + t.ContinueWith(_ => + { + lock (_tasks) + { + return _tasks.TryRemove(a, out var unused); + } + }, CancellationToken.None); + return (t, token); }).Item1; } return task; } - public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) - => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); - - private class ExportToGltfAction : IAction + private class ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) + : IAction { - private readonly ModelManager _manager; - - private readonly MdlFile _mdl; - private readonly SklbFile? _sklb; - private readonly string _outputPath; - - public ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) - { - _manager = manager; - _mdl = mdl; - _sklb = sklb; - _outputPath = outputPath; - } - public void Execute(CancellationToken cancel) { Penumbra.Log.Debug("Reading skeleton."); var xivSkeleton = BuildSkeleton(cancel); Penumbra.Log.Debug("Converting model."); - var model = ModelExporter.Export(_mdl, xivSkeleton); + var model = ModelExporter.Export(mdl, xivSkeleton); Penumbra.Log.Debug("Building scene."); var scene = new SceneBuilder(); @@ -84,16 +92,16 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable Penumbra.Log.Debug("Saving."); var gltfModel = scene.ToGltf2(); - gltfModel.SaveGLTF(_outputPath); + gltfModel.SaveGLTF(outputPath); } /// Attempt to read out the pertinent information from a .sklb. private XivSkeleton? BuildSkeleton(CancellationToken cancel) { - if (_sklb == null) + if (sklb == null) return null; - var xmlTask = _manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(_sklb.Skeleton)); + var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton)); xmlTask.Wait(cancel); var xml = xmlTask.Result; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 93e674ea..b8573780 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -8,9 +8,9 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { - private partial class MdlTab : IWritable + private class MdlTab : IWritable { - private ModEditWindow _edit; + private readonly ModEditWindow _edit; public readonly MdlFile Mdl; private readonly List[] _attributes; @@ -18,21 +18,10 @@ public partial class ModEditWindow public List? GamePaths { get; private set; } public int GamePathIndex; - public bool PendingIo { get; private set; } = false; - public string? IoException { get; private set; } = null; + public bool PendingIo { get; private set; } + public string? IoException { get; private set; } - [GeneratedRegex(@"chara/(?:equipment|accessory)/(?'Set'[a-z]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] - private static partial Regex CharaEquipmentRegex(); - - [GeneratedRegex(@"chara/human/(?'Race'c\d{4})/obj/(?'Type'[^/]+)/(?'Set'[^/]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", - RegexOptions.Compiled)] - private static partial Regex CharaHumanRegex(); - - [GeneratedRegex(@"chara/(?'SubCategory'demihuman|monster|weapon)/(?'Set'w\d{4})/obj/body/(?'Body'b\d{4})/model/\k'Set'\k'Body'.mdl", - RegexOptions.Compiled)] - private static partial Regex CharaBodyRegex(); - - public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) + public MdlTab(ModEditWindow edit, byte[] bytes, string path, IMod? mod) { _edit = edit; @@ -54,7 +43,7 @@ public partial class ModEditWindow /// Find the list of game paths that may correspond to this model. /// Resolved path to a .mdl. /// Mod within which the .mdl is resolved. - private void FindGamePaths(string path, Mod mod) + private void FindGamePaths(string path, IMod mod) { if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) { @@ -77,8 +66,8 @@ public partial class ModEditWindow task.ContinueWith(t => { IoException = t.Exception?.ToString(); - PendingIo = false; GamePaths = t.Result; + PendingIo = false; }); } @@ -89,7 +78,7 @@ public partial class ModEditWindow SklbFile? sklb = null; try { - var sklbPath = GetSklbPath(mdlPath.ToString()); + var sklbPath = _edit._models.ResolveSklbForMdl(mdlPath.ToString()); sklb = sklbPath != null ? ReadSklb(sklbPath) : null; } catch (Exception exception) @@ -107,43 +96,6 @@ public partial class ModEditWindow }); } - /// Try to find the .sklb path for a .mdl file. - /// .mdl file to look up the skeleton for. - private string? GetSklbPath(string mdlPath) - { - // Equipment is skinned to the base body skeleton of the race they target. - var match = CharaEquipmentRegex().Match(mdlPath); - if (match.Success) - { - var race = match.Groups["Race"].Value; - return $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb"; - } - - // Some parts of human have their own skeletons. - match = CharaHumanRegex().Match(mdlPath); - if (match.Success) - { - var type = match.Groups["Type"].Value; - var race = match.Groups["Race"].Value; - return type switch - { - "body" or "tail" => $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb", - _ => throw new Exception($"Currently unsupported human model type \"{type}\"."), - }; - } - - // A few subcategories - such as weapons, demihumans, and monsters - have dedicated per-"body" skeletons. - match = CharaBodyRegex().Match(mdlPath); - if (match.Success) - { - var subCategory = match.Groups["SubCategory"].Value; - var set = match.Groups["Set"].Value; - return $"chara/{subCategory}/{set}/skeleton/base/b0001/skl_{set}b0001.sklb"; - } - - return null; - } - /// Read a .sklb from the active collection or game. /// Game path to the .sklb to load. private SklbFile ReadSklb(string sklbPath) @@ -153,17 +105,12 @@ public partial class ModEditWindow throw new Exception($"Resolved skeleton path {sklbPath} could not be converted to a game path."); var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath); - // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... - var bytes = resolvedPath switch - { - null => _edit._gameData.GetFile(sklbPath)?.Data, - FullPath path => File.ReadAllBytes(path.ToPath()), - }; - if (bytes == null) - throw new Exception( + // TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... + var bytes = resolvedPath == null ? _edit._gameData.GetFile(sklbPath)?.Data : File.ReadAllBytes(resolvedPath.Value.ToPath()); + return bytes != null + ? new SklbFile(bytes) + : throw new Exception( $"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?"); - - return new SklbFile(bytes); } /// Remove the material given by the index. diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 3891eb95..24b45b88 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -15,7 +15,6 @@ public partial class ModEditWindow private const int MdlMaterialMaximum = 4; private readonly FileEditor _modelTab; - private readonly ModelManager _models; private string _modelNewMaterial = string.Empty; @@ -91,19 +90,21 @@ public partial class ModEditWindow private void DrawGamePathCombo(MdlTab tab) { - if (tab.GamePaths!.Count == 0) + if (tab.GamePaths!.Count != 0) { - ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); - if (ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) - if (!Utf8GamePath.FromString(_customPath, out _customGamePath, false)) - _customGamePath = Utf8GamePath.Empty; - + DrawComboButton(tab); return; } - DrawComboButton(tab); + ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); + if (!ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) + return; + + if (!Utf8GamePath.FromString(_customPath, out _customGamePath, false)) + _customGamePath = Utf8GamePath.Empty; } + /// I disliked the combo with only one selection so turn it into a button in that case. private static void DrawComboButton(MdlTab tab) { const string label = "Game Path"; From 677c9bd801490ddb961f5c923ab11a5c879c946f Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sat, 6 Jan 2024 18:37:52 +0100 Subject: [PATCH 35/35] Some cleanup and using new features / intellisense recommendations. --- Penumbra/Import/Models/Export/MeshExporter.cs | 113 +++++++++--------- .../Import/Models/Export/ModelExporter.cs | 23 +--- Penumbra/Import/Models/Export/Skeleton.cs | 9 +- Penumbra/Import/Models/HavokConverter.cs | 50 ++++---- Penumbra/Import/Models/SkeletonConverter.cs | 50 ++++---- 5 files changed, 117 insertions(+), 128 deletions(-) diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 1b53df8a..84628c2c 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -13,24 +13,17 @@ namespace Penumbra.Import.Models.Export; public class MeshExporter { - public class Mesh + public class Mesh(IEnumerable> meshes, NodeBuilder[]? joints) { - private IMeshBuilder[] _meshes; - private NodeBuilder[]? _joints; - - public Mesh(IMeshBuilder[] meshes, NodeBuilder[]? joints) - { - _meshes = meshes; - _joints = joints; - } - public void AddToScene(SceneBuilder scene) { - foreach (var mesh in _meshes) - if (_joints == null) + foreach (var mesh in meshes) + { + if (joints == null) scene.AddRigidMesh(mesh, Matrix4x4.Identity); else - scene.AddSkinnedMesh(mesh, Matrix4x4.Identity, _joints); + scene.AddSkinnedMesh(mesh, Matrix4x4.Identity, joints); + } } } @@ -43,9 +36,11 @@ public class MeshExporter private const byte MaximumMeshBufferStreams = 3; private readonly MdlFile _mdl; - private readonly byte _lod; - private readonly ushort _meshIndex; - private MdlStructs.MeshStruct XivMesh => _mdl.Meshes[_meshIndex]; + private readonly byte _lod; + private readonly ushort _meshIndex; + + private MdlStructs.MeshStruct XivMesh + => _mdl.Meshes[_meshIndex]; private readonly Dictionary? _boneIndexMap; @@ -53,10 +48,10 @@ public class MeshExporter private readonly Type _materialType; private readonly Type _skinningType; - private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, Dictionary? boneNameMap) + private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, IReadOnlyDictionary? boneNameMap) { - _mdl = mdl; - _lod = lod; + _mdl = mdl; + _lod = lod; _meshIndex = meshIndex; if (boneNameMap != null) @@ -76,11 +71,12 @@ public class MeshExporter if (_skinningType != typeof(VertexEmpty) && _boneIndexMap == null) Penumbra.Log.Warning($"Mesh {meshIndex} has skinned vertex usages but no bone information was provided."); - Penumbra.Log.Debug($"Mesh {meshIndex} using vertex types geometry: {_geometryType.Name}, material: {_materialType.Name}, skinning: {_skinningType.Name}"); + Penumbra.Log.Debug( + $"Mesh {meshIndex} using vertex types geometry: {_geometryType.Name}, material: {_materialType.Name}, skinning: {_skinningType.Name}"); } - /// Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provdied. - private Dictionary? BuildBoneIndexMap(Dictionary boneNameMap) + /// Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. + private Dictionary? BuildBoneIndexMap(IReadOnlyDictionary boneNameMap) { // A BoneTableIndex of 255 means that this mesh is not skinned. if (XivMesh.BoneTableIndex == 255) @@ -105,11 +101,10 @@ public class MeshExporter /// Build glTF meshes for this XIV mesh. private IMeshBuilder[] BuildMeshes() { - var indices = BuildIndices(); + 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)]; @@ -117,7 +112,8 @@ public class MeshExporter .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)) + .Select(subMesh => BuildMesh($"mesh {_meshIndex}.{subMesh.Index}", indices, vertices, + (int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount)) .ToArray(); } @@ -161,7 +157,7 @@ public class MeshExporter } var primitiveVertices = meshBuilder.Primitives.First().Vertices; - var shapeNames = new List(); + var shapeNames = new List(); foreach (var shape in _mdl.Shapes) { @@ -177,24 +173,28 @@ public class MeshExporter ) .Where(shapeValue => shapeValue.BaseIndicesIndex >= indexBase - && shapeValue.BaseIndicesIndex < indexBase + indexCount + && shapeValue.BaseIndicesIndex < indexBase + indexCount ) .ToList(); - if (shapeValues.Count == 0) continue; + if (shapeValues.Count == 0) + continue; var morphBuilder = meshBuilder.UseMorphTarget(shapeNames.Count); shapeNames.Add(shape.ShapeName); foreach (var shapeValue in shapeValues) + { morphBuilder.SetVertex( primitiveVertices[gltfIndices[shapeValue.BaseIndicesIndex - indexBase]].GetGeometry(), vertices[shapeValue.ReplacingVertexIndex].GetGeometry() ); + } } - meshBuilder.Extras = JsonContent.CreateFrom(new Dictionary() { - {"targetNames", shapeNames} + meshBuilder.Extras = JsonContent.CreateFrom(new Dictionary() + { + { "targetNames", shapeNames }, }); return meshBuilder; @@ -249,23 +249,25 @@ public class MeshExporter } /// Read a vertex attribute of the specified type from a vertex buffer stream. - private object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader) + private static object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader) { return type switch { 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.UInt => reader.ReadBytes(4), - MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f), + MdlFile.VertexType.UInt => reader.ReadBytes(4), + MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, + reader.ReadByte() / 255f), MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()), - MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf()), + MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), + (float)reader.ReadHalf()), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(), }; } /// Get the vertex geometry type for this mesh's vertex usages. - private Type GetGeometryType(IReadOnlyDictionary usages) + private static Type GetGeometryType(IReadOnlyDictionary usages) { if (!usages.ContainsKey(MdlFile.VertexUsage.Position)) throw new Exception("Mesh does not contain position vertex elements."); @@ -304,16 +306,16 @@ public class MeshExporter } /// Get the vertex material type for this mesh's vertex usages. - private Type GetMaterialType(IReadOnlyDictionary usages) + private static Type GetMaterialType(IReadOnlyDictionary usages) { var uvCount = 0; if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var type)) uvCount = type switch { - MdlFile.VertexType.Half2 => 1, - MdlFile.VertexType.Half4 => 2, + MdlFile.VertexType.Half2 => 1, + MdlFile.VertexType.Half4 => 2, MdlFile.VertexType.Single4 => 2, - _ => throw new Exception($"Unexpected UV vertex type {type}.") + _ => throw new Exception($"Unexpected UV vertex type {type}."), }; var materialUsages = ( @@ -323,11 +325,11 @@ public class MeshExporter return materialUsages switch { - (2, true) => typeof(VertexColor1Texture2), + (2, true) => typeof(VertexColor1Texture2), (2, false) => typeof(VertexTexture2), - (1, true) => typeof(VertexColor1Texture1), + (1, true) => typeof(VertexColor1Texture1), (1, false) => typeof(VertexTexture1), - (0, true) => typeof(VertexColor1), + (0, true) => typeof(VertexColor1), (0, false) => typeof(VertexEmpty), _ => throw new Exception("Unreachable."), @@ -377,7 +379,7 @@ public class MeshExporter } /// Get the vertex skinning type for this mesh's vertex usages. - private Type GetSkinningType(IReadOnlyDictionary usages) + private static Type GetSkinningType(IReadOnlyDictionary usages) { if (usages.ContainsKey(MdlFile.VertexUsage.BlendWeights) && usages.ContainsKey(MdlFile.VertexUsage.BlendIndices)) return typeof(VertexJoints4); @@ -400,7 +402,8 @@ public class MeshExporter var weights = ToVector4(attributes[MdlFile.VertexUsage.BlendWeights]); var bindings = Enumerable.Range(0, 4) - .Select(bindingIndex => { + .Select(bindingIndex => + { // NOTE: I've not seen any files that throw this error that aren't completely broken. var xivBoneIndex = indices[bindingIndex]; if (!_boneIndexMap.TryGetValue(xivBoneIndex, out var jointIndex)) @@ -417,44 +420,44 @@ public class MeshExporter /// Clamps any tangent W value other than 1 to -1. /// Some XIV models seemingly store -1 as 0, this patches over that. - private Vector4 FixTangentVector(Vector4 tangent) + private static Vector4 FixTangentVector(Vector4 tangent) => tangent with { W = tangent.W == 1 ? 1 : -1 }; /// Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. - private Vector2 ToVector2(object data) + private static Vector2 ToVector2(object data) => data switch { Vector2 v2 => v2, Vector3 v3 => new Vector2(v3.X, v3.Y), Vector4 v4 => new Vector2(v4.X, v4.Y), - _ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}") + _ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}"), }; /// Convert a vertex attribute value to a Vector3. Supported inputs are Vector2, Vector3, and Vector4. - private Vector3 ToVector3(object data) + private static Vector3 ToVector3(object data) => data switch { Vector2 v2 => new Vector3(v2.X, v2.Y, 0), Vector3 v3 => v3, Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z), - _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"), }; /// Convert a vertex attribute value to a Vector4. Supported inputs are Vector2, Vector3, and Vector4. - private Vector4 ToVector4(object data) + private static Vector4 ToVector4(object data) => data switch { - Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), + Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1), Vector4 v4 => v4, - _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}") + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"), }; /// Convert a vertex attribute value to a byte array. - private byte[] ToByteArray(object data) + private static byte[] ToByteArray(object data) => data switch { byte[] value => value, - _ => throw new ArgumentOutOfRangeException($"Invalid byte[] input {data}") + _ => throw new ArgumentOutOfRangeException($"Invalid byte[] input {data}"), }; } diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 35819e7a..2060c323 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -6,26 +6,17 @@ namespace Penumbra.Import.Models.Export; public class ModelExporter { - public class Model + public class Model(List meshes, GltfSkeleton? skeleton) { - private List _meshes; - private GltfSkeleton? _skeleton; - - public Model(List meshes, GltfSkeleton? skeleton) - { - _meshes = meshes; - _skeleton = skeleton; - } - public void AddToScene(SceneBuilder scene) { // If there's a skeleton, the root node should be added before we add any potentially skinned meshes. - var skeletonRoot = _skeleton?.Root; + var skeletonRoot = skeleton?.Root; if (skeletonRoot != null) scene.AddNode(skeletonRoot); // Add all the meshes to the scene. - foreach (var mesh in _meshes) + foreach (var mesh in meshes) mesh.AddToScene(scene); } } @@ -64,10 +55,8 @@ public class ModelExporter NodeBuilder? root = null; var names = new Dictionary(); var joints = new List(); - for (var boneIndex = 0; boneIndex < skeleton.Bones.Length; boneIndex++) + foreach (var bone in skeleton.Bones) { - var bone = skeleton.Bones[boneIndex]; - if (names.ContainsKey(bone.Name)) continue; var node = new NodeBuilder(bone.Name); @@ -93,10 +82,10 @@ public class ModelExporter if (root == null) return null; - return new() + return new GltfSkeleton { Root = root, - Joints = joints.ToArray(), + Joints = [.. joints], Names = names, }; } diff --git a/Penumbra/Import/Models/Export/Skeleton.cs b/Penumbra/Import/Models/Export/Skeleton.cs index 09cdcc32..fee107a0 100644 --- a/Penumbra/Import/Models/Export/Skeleton.cs +++ b/Penumbra/Import/Models/Export/Skeleton.cs @@ -3,14 +3,9 @@ using SharpGLTF.Scenes; namespace Penumbra.Import.Models.Export; /// Representation of a skeleton within XIV. -public class XivSkeleton +public class XivSkeleton(XivSkeleton.Bone[] bones) { - public Bone[] Bones; - - public XivSkeleton(Bone[] bones) - { - Bones = bones; - } + public Bone[] Bones = bones; public struct Bone { diff --git a/Penumbra/Import/Models/HavokConverter.cs b/Penumbra/Import/Models/HavokConverter.cs index 7f87d50a..01c27b61 100644 --- a/Penumbra/Import/Models/HavokConverter.cs +++ b/Penumbra/Import/Models/HavokConverter.cs @@ -16,17 +16,18 @@ public static unsafe class HavokConverter /// A byte array representing the .hkx file. public static string HkxToXml(byte[] hkx) { + const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.TextFormat + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + var tempHkx = CreateTempFile(); File.WriteAllBytes(tempHkx, hkx); var resource = Read(tempHkx); File.Delete(tempHkx); - if (resource == null) throw new Exception("Failed to read havok file."); - - var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers - | hkSerializeUtil.SaveOptionBits.TextFormat - | hkSerializeUtil.SaveOptionBits.WriteAttributes; + if (resource == null) + throw new Exception("Failed to read havok file."); var file = Write(resource, options); file.Close(); @@ -41,17 +42,19 @@ public static unsafe class HavokConverter /// A string representing the .xml file. public static byte[] XmlToHkx(string xml) { + const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + var tempXml = CreateTempFile(); File.WriteAllText(tempXml, xml); var resource = Read(tempXml); File.Delete(tempXml); - if (resource == null) throw new Exception("Failed to read havok file."); - - var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers - | hkSerializeUtil.SaveOptionBits.WriteAttributes; + if (resource == null) + throw new Exception("Failed to read havok file."); + g var file = Write(resource, options); file.Close(); @@ -74,7 +77,7 @@ public static unsafe class HavokConverter var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); var loadOptions = stackalloc hkSerializeUtil.LoadOptions[1]; - loadOptions->Flags = new() { Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; + loadOptions->Flags = new hkFlags { Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); @@ -92,37 +95,42 @@ public static unsafe class HavokConverter ) { var tempFile = CreateTempFile(); - var path = Marshal.StringToHGlobalAnsi(tempFile); - var oStream = new hkOstream(); + var path = Marshal.StringToHGlobalAnsi(tempFile); + var oStream = new hkOstream(); oStream.Ctor((byte*)path); var result = stackalloc hkResult[1]; var saveOptions = new hkSerializeUtil.SaveOptions() { - Flags = new() { Storage = (int)optionBits } + Flags = new hkFlags { Storage = (int)optionBits }, }; - var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); - var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); - var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); try { - var name = "hkRootLevelContainer"; + const string name = "hkRootLevelContainer"; var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry); - if (resourcePtr == null) throw new Exception("Failed to retrieve havok root level container resource."); + if (resourcePtr == null) + throw new Exception("Failed to retrieve havok root level container resource."); var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name); - if (hkRootLevelContainerClass == null) throw new Exception("Failed to retrieve havok root level container type."); + if (hkRootLevelContainerClass == null) + throw new Exception("Failed to retrieve havok root level container type."); hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions); } - finally { oStream.Dtor(); } + finally + { + oStream.Dtor(); + } - if (result->Result == hkResult.hkResultEnum.Failure) throw new Exception("Failed to serialize havok file."); + if (result->Result == hkResult.hkResultEnum.Failure) + throw new Exception("Failed to serialize havok file."); return new FileStream(tempFile, FileMode.Open); } diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs index 24bcf3e0..7058a159 100644 --- a/Penumbra/Import/Models/SkeletonConverter.cs +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -15,16 +15,15 @@ public static class SkeletonConverter var mainSkeletonId = GetMainSkeletonId(document); - var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']"); - if (skeletonNode == null) - throw new InvalidDataException($"Failed to find skeleton with id {mainSkeletonId}."); - + var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']") + ?? throw new InvalidDataException($"Failed to find skeleton with id {mainSkeletonId}."); var referencePose = ReadReferencePose(skeletonNode); var parentIndices = ReadParentIndices(skeletonNode); - var boneNames = ReadBoneNames(skeletonNode); + var boneNames = ReadBoneNames(skeletonNode); if (boneNames.Length != parentIndices.Length || boneNames.Length != referencePose.Length) - throw new InvalidDataException($"Mismatch in bone value array lengths: names({boneNames.Length}) parents({parentIndices.Length}) pose({referencePose.Length})"); + throw new InvalidDataException( + $"Mismatch in bone value array lengths: names({boneNames.Length}) parents({parentIndices.Length}) pose({referencePose.Length})"); var bones = referencePose .Zip(parentIndices, boneNames) @@ -33,9 +32,9 @@ public static class SkeletonConverter var (transform, parentIndex, name) = values; return new XivSkeleton.Bone() { - Transform = transform, + Transform = transform, ParentIndex = parentIndex, - Name = name, + Name = name, }; }) .ToArray(); @@ -63,14 +62,14 @@ public static class SkeletonConverter { return ReadArray( CheckExists(node.SelectSingleNode("array[@name='referencePose']")), - node => + n => { - var raw = ReadVec12(node); + var raw = ReadVec12(n); return new XivSkeleton.Transform() { - Translation = new(raw[0], raw[1], raw[2]), - Rotation = new(raw[4], raw[5], raw[6], raw[7]), - Scale = new(raw[8], raw[9], raw[10]), + Translation = new Vector3(raw[0], raw[1], raw[2]), + Rotation = new Quaternion(raw[4], raw[5], raw[6], raw[7]), + Scale = new Vector3(raw[8], raw[9], raw[10]), }; } ); @@ -82,11 +81,11 @@ public static class SkeletonConverter { var array = node.ChildNodes .Cast() - .Where(node => node.NodeType != XmlNodeType.Comment) - .Select(node => + .Where(n => n.NodeType != XmlNodeType.Comment) + .Select(n => { - var text = node.InnerText.Trim()[1..]; - // TODO: surely there's a less shit way to do this i mean seriously + var text = n.InnerText.Trim()[1..]; + // TODO: surely there's a less shit way to do this I mean seriously return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(text, NumberStyles.HexNumber))); }) .ToArray(); @@ -100,24 +99,20 @@ public static class SkeletonConverter /// Read the bone parent relations for a skeleton. /// XML node for the skeleton. private static int[] ReadParentIndices(XmlNode node) - { // todo: would be neat to genericise array between bare and children - return CheckExists(node.SelectSingleNode("array[@name='parentIndices']")) + => CheckExists(node.SelectSingleNode("array[@name='parentIndices']")) .InnerText - .Split(new char[] { ' ', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Split((char[]) [' ', '\n'], StringSplitOptions.RemoveEmptyEntries) .Select(int.Parse) .ToArray(); - } /// Read the names of bones in a skeleton. /// XML node for the skeleton. private static string[] ReadBoneNames(XmlNode node) - { - return ReadArray( + => ReadArray( CheckExists(node.SelectSingleNode("array[@name='bones']")), - node => CheckExists(node.SelectSingleNode("string[@name='name']")).InnerText + n => CheckExists(n.SelectSingleNode("string[@name='name']")).InnerText ); - } /// Read an XML tagfile array, converting it via the provided conversion function. /// Tagfile XML array node. @@ -125,10 +120,9 @@ public static class SkeletonConverter private static T[] ReadArray(XmlNode node, Func convert) { var element = (XmlElement)node; + var size = int.Parse(element.GetAttribute("size")); + var array = new T[size]; - var size = int.Parse(element.GetAttribute("size")); - - var array = new T[size]; foreach (var (childNode, index) in element.ChildNodes.Cast().WithIndex()) array[index] = convert(childNode);