From 695c18439db5dc353def4cd1edd72e761cb0a456 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sat, 30 Dec 2023 02:41:19 +1100 Subject: [PATCH] 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());