Wire up hair EST resolution

This commit is contained in:
ackwell 2024-01-07 19:49:13 +11:00
parent 981721ae85
commit aa7f0bace9
4 changed files with 104 additions and 38 deletions

View file

@ -22,7 +22,7 @@ public class ModelExporter
} }
/// <summary> Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. </summary> /// <summary> Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. </summary>
public static Model Export(MdlFile mdl, XivSkeleton? xivSkeleton) public static Model Export(MdlFile mdl, IEnumerable<XivSkeleton>? xivSkeleton)
{ {
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null;
var meshes = ConvertMeshes(mdl, gltfSkeleton); var meshes = ConvertMeshes(mdl, gltfSkeleton);
@ -50,12 +50,15 @@ public class ModelExporter
} }
/// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary> /// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary>
private static GltfSkeleton? ConvertSkeleton(XivSkeleton skeleton) private static GltfSkeleton? ConvertSkeleton(IEnumerable<XivSkeleton> skeletons)
{ {
NodeBuilder? root = null; NodeBuilder? root = null;
var names = new Dictionary<string, int>(); var names = new Dictionary<string, int>();
var joints = new List<NodeBuilder>(); var joints = new List<NodeBuilder>();
foreach (var bone in skeleton.Bones)
// Flatten out the bones across all the recieved skeletons, but retain a reference to the parent skeleton for lookups.
var iterator = skeletons.SelectMany(skeleton => skeleton.Bones.Select(bone => (skeleton, bone)));
foreach (var (skeleton, bone) in iterator)
{ {
if (names.ContainsKey(bone.Name)) continue; if (names.ContainsKey(bone.Name)) continue;

View file

@ -1,14 +1,19 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using OtterGui;
using OtterGui.Tasks; using OtterGui.Tasks;
using Penumbra.Collections.Manager;
using Penumbra.GameData;
using Penumbra.GameData.Data; using Penumbra.GameData.Data;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Files; using Penumbra.GameData.Files;
using Penumbra.GameData.Structs;
using Penumbra.Import.Models.Export; using Penumbra.Import.Models.Export;
using Penumbra.Meta.Manipulations;
using SharpGLTF.Scenes; using SharpGLTF.Scenes;
namespace Penumbra.Import.Models; namespace Penumbra.Import.Models;
public sealed class ModelManager(IFramework framework, GamePathParser _parser) : SingleTaskQueue, IDisposable public sealed class ModelManager(IFramework framework, ActiveCollections collections, GamePathParser parser) : SingleTaskQueue, IDisposable
{ {
private readonly IFramework _framework = framework; private readonly IFramework _framework = framework;
@ -24,31 +29,55 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) :
_tasks.Clear(); _tasks.Clear();
} }
public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) public Task ExportToGltf(MdlFile mdl, IEnumerable<SklbFile>? sklbs, string outputPath)
=> Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); => Enqueue(new ExportToGltfAction(this, mdl, sklbs, outputPath));
/// <summary> Try to find the .sklb path for a .mdl file. </summary> /// <summary> Try to find the .sklb paths for a .mdl file. </summary>
/// <param name="mdlPath"> .mdl file to look up the skeleton for. </param> /// <param name="mdlPath"> .mdl file to look up the skeletons for. </param>
public string? ResolveSklbForMdl(string mdlPath) public string[]? ResolveSklbsForMdl(string mdlPath, EstManipulation[] estManipulations)
{ {
var info = _parser.GetFileInfo(mdlPath); var info = parser.GetFileInfo(mdlPath);
if (info.FileType is not FileType.Model) if (info.FileType is not FileType.Model)
return null; return null;
var baseSkeleton = GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1);
return info.ObjectType switch return info.ObjectType switch
{ {
ObjectType.Equipment => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), ObjectType.Equipment => [baseSkeleton],
ObjectType.Accessory => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), ObjectType.Accessory => [baseSkeleton],
ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => [baseSkeleton],
1), ObjectType.Character when info.BodySlot is BodySlot.Hair
=> [baseSkeleton, ResolveHairSkeleton(info, estManipulations)],
ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."), ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."),
ObjectType.DemiHuman => GamePaths.DemiHuman.Sklb.Path(info.PrimaryId), ObjectType.DemiHuman => [GamePaths.DemiHuman.Sklb.Path(info.PrimaryId)],
ObjectType.Monster => GamePaths.Monster.Sklb.Path(info.PrimaryId), ObjectType.Monster => [GamePaths.Monster.Sklb.Path(info.PrimaryId)],
ObjectType.Weapon => GamePaths.Weapon.Sklb.Path(info.PrimaryId), ObjectType.Weapon => [GamePaths.Weapon.Sklb.Path(info.PrimaryId)],
_ => null, _ => null,
}; };
} }
private string ResolveHairSkeleton(GameObjectInfo info, EstManipulation[] estManipulations)
{
// TODO: might be able to genericse this over esttype based on incoming info
var (gender, race) = info.GenderRace.Split();
var modEst = estManipulations
.FirstOrNull(est =>
est.Gender == gender
&& est.Race == race
&& est.Slot == EstManipulation.EstType.Hair
&& est.SetId == info.PrimaryId
);
// Try to use an entry from the current mod, falling back to the current collection, and finally an unmodified value.
var targetId = modEst?.Entry
?? collections.Current.MetaCache?.GetEstEntry(EstManipulation.EstType.Hair, info.GenderRace, info.PrimaryId)
?? info.PrimaryId;
// TODO: i'm not conviced ToSuffix is correct - check!
return GamePaths.Skeleton.Sklb.Path(info.GenderRace, info.BodySlot.ToSuffix(), targetId);
}
private Task Enqueue(IAction action) private Task Enqueue(IAction action)
{ {
if (_disposed) if (_disposed)
@ -75,16 +104,16 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) :
return task; return task;
} }
private class ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) private class ExportToGltfAction(ModelManager manager, MdlFile mdl, IEnumerable<SklbFile>? sklbs, string outputPath)
: IAction : IAction
{ {
public void Execute(CancellationToken cancel) public void Execute(CancellationToken cancel)
{ {
Penumbra.Log.Debug("Reading skeleton."); Penumbra.Log.Debug("Reading skeletons.");
var xivSkeleton = BuildSkeleton(cancel); var xivSkeletons = BuildSkeletons(cancel);
Penumbra.Log.Debug("Converting model."); Penumbra.Log.Debug("Converting model.");
var model = ModelExporter.Export(mdl, xivSkeleton); var model = ModelExporter.Export(mdl, xivSkeletons);
Penumbra.Log.Debug("Building scene."); Penumbra.Log.Debug("Building scene.");
var scene = new SceneBuilder(); var scene = new SceneBuilder();
@ -96,16 +125,28 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) :
} }
/// <summary> Attempt to read out the pertinent information from a .sklb. </summary> /// <summary> Attempt to read out the pertinent information from a .sklb. </summary>
private XivSkeleton? BuildSkeleton(CancellationToken cancel) private IEnumerable<XivSkeleton>? BuildSkeletons(CancellationToken cancel)
{ {
if (sklb == null) if (sklbs == null)
return null; return null;
var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton)); // The havok methods we're relying on for this conversion are a bit
xmlTask.Wait(cancel); // finicky at the best of times, and can outright cause a CTD if they
var xml = xmlTask.Result; // get upset. Running each conversion on its own tick seems to make
// this consistently non-crashy across my testing.
Task<string> CreateHavokTask((SklbFile Sklb, int Index) pair) =>
manager._framework.RunOnTick(
() => HavokConverter.HkxToXml(pair.Sklb.Skeleton),
delayTicks: pair.Index
);
return SkeletonConverter.FromXml(xml); var havokTasks = sklbs
.WithIndex()
.Select(CreateHavokTask)
.ToArray();
Task.WaitAll(havokTasks, cancel);
return havokTasks.Select(task => SkeletonConverter.FromXml(task.Result));
} }
public bool Equals(IAction? other) public bool Equals(IAction? other)

View file

@ -1,7 +1,7 @@
using OtterGui; using OtterGui;
using Penumbra.GameData; using Penumbra.GameData;
using Penumbra.GameData.Files; using Penumbra.GameData.Files;
using Penumbra.Mods; using Penumbra.Meta.Manipulations;
using Penumbra.String.Classes; using Penumbra.String.Classes;
namespace Penumbra.UI.AdvancedWindow; namespace Penumbra.UI.AdvancedWindow;
@ -21,15 +21,14 @@ public partial class ModEditWindow
public bool PendingIo { get; private set; } public bool PendingIo { get; private set; }
public string? IoException { get; private set; } public string? IoException { get; private set; }
public MdlTab(ModEditWindow edit, byte[] bytes, string path, IMod? mod) public MdlTab(ModEditWindow edit, byte[] bytes, string path)
{ {
_edit = edit; _edit = edit;
Mdl = new MdlFile(bytes); Mdl = new MdlFile(bytes);
_attributes = CreateAttributes(Mdl); _attributes = CreateAttributes(Mdl);
if (mod != null) FindGamePaths(path);
FindGamePaths(path, mod);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -42,9 +41,13 @@ public partial class ModEditWindow
/// <summary> Find the list of game paths that may correspond to this model. </summary> /// <summary> Find the list of game paths that may correspond to this model. </summary>
/// <param name="path"> Resolved path to a .mdl. </param> /// <param name="path"> Resolved path to a .mdl. </param>
/// <param name="mod"> Mod within which the .mdl is resolved. </param> private void FindGamePaths(string path)
private void FindGamePaths(string path, IMod mod)
{ {
// If there's no current mod (somehow), there's nothing to resolve the model within.
var mod = _edit._editor.Mod;
if (mod == null)
return;
if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p))
{ {
GamePaths = [p]; GamePaths = [p];
@ -71,15 +74,34 @@ public partial class ModEditWindow
}); });
} }
private EstManipulation[] GetCurrentEstManipulations()
{
var mod = _edit._editor.Mod;
var option = _edit._editor.Option;
if (mod == null || option == null)
return [];
// Filter then prepend the current option to ensure it's chosen first.
return mod.AllSubMods
.Where(subMod => subMod != option)
.Prepend(option)
.SelectMany(subMod => subMod.Manipulations)
.Where(manipulation => manipulation.ManipulationType == MetaManipulation.Type.Est)
.Select(manipulation => manipulation.Est)
.ToArray();
}
/// <summary> Export model to an interchange format. </summary> /// <summary> Export model to an interchange format. </summary>
/// <param name="outputPath"> Disk path to save the resulting file to. </param> /// <param name="outputPath"> Disk path to save the resulting file to. </param>
public void Export(string outputPath, Utf8GamePath mdlPath) public void Export(string outputPath, Utf8GamePath mdlPath)
{ {
SklbFile? sklb = null; IEnumerable<SklbFile>? sklbs = null;
try try
{ {
var sklbPath = _edit._models.ResolveSklbForMdl(mdlPath.ToString()); var sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations());
sklb = sklbPath != null ? ReadSklb(sklbPath) : null; sklbs = sklbPaths != null
? sklbPaths.Select(ReadSklb).ToArray()
: null;
} }
catch (Exception exception) catch (Exception exception)
{ {
@ -88,7 +110,7 @@ public partial class ModEditWindow
} }
PendingIo = true; PendingIo = true;
_edit._models.ExportToGltf(Mdl, sklb, outputPath) _edit._models.ExportToGltf(Mdl, sklbs, outputPath)
.ContinueWith(task => .ContinueWith(task =>
{ {
IoException = task.Exception?.ToString(); IoException = task.Exception?.ToString();

View file

@ -600,7 +600,7 @@ public partial class ModEditWindow : Window, IDisposable
(bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable));
_modelTab = new FileEditor<MdlTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", _modelTab = new FileEditor<MdlTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl",
() => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty,
(bytes, path, _) => new MdlTab(this, bytes, path, _mod)); (bytes, path, _) => new MdlTab(this, bytes, path));
_shaderPackageTab = new FileEditor<ShpkTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", _shaderPackageTab = new FileEditor<ShpkTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk",
() => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel,
() => _mod?.ModPath.FullName ?? string.Empty, () => _mod?.ModPath.FullName ?? string.Empty,