Allow export of missing bones with warnings

This commit is contained in:
ackwell 2024-01-20 00:03:58 +11:00
parent 0486d049b0
commit cbd99f833a
7 changed files with 71 additions and 27 deletions

View file

@ -0,0 +1,6 @@
namespace Penumbra.Import.Models.Export;
public struct ExportConfig
{
public bool GenerateMissingBones;
}

View file

@ -13,14 +13,14 @@ namespace Penumbra.Import.Models.Export;
public class MeshExporter public class MeshExporter
{ {
public class Mesh(IEnumerable<MeshData> meshes, NodeBuilder[]? joints) public class Mesh(IEnumerable<MeshData> meshes, GltfSkeleton? skeleton)
{ {
public void AddToScene(SceneBuilder scene) public void AddToScene(SceneBuilder scene)
{ {
foreach (var data in meshes) foreach (var data in meshes)
{ {
var instance = joints != null var instance = skeleton != null
? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, joints) ? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, [.. skeleton.Value.Joints])
: scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity); : scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity);
var extras = new Dictionary<string, object>(data.Attributes.Length); var extras = new Dictionary<string, object>(data.Attributes.Length);
@ -38,15 +38,16 @@ public class MeshExporter
public string[] Attributes; public string[] Attributes;
} }
public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) public static Mesh Export(ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier)
{ {
var self = new MeshExporter(mdl, lod, meshIndex, materials, skeleton?.Names, notifier); var self = new MeshExporter(config, mdl, lod, meshIndex, materials, skeleton, notifier);
return new Mesh(self.BuildMeshes(), skeleton?.Joints); return new Mesh(self.BuildMeshes(), skeleton);
} }
private const byte MaximumMeshBufferStreams = 3; private const byte MaximumMeshBufferStreams = 3;
private readonly IoNotifier _notifier; private readonly ExportConfig _config;
private readonly IoNotifier _notifier;
private readonly MdlFile _mdl; private readonly MdlFile _mdl;
private readonly byte _lod; private readonly byte _lod;
@ -63,8 +64,10 @@ public class MeshExporter
private readonly Type _materialType; private readonly Type _materialType;
private readonly Type _skinningType; private readonly Type _skinningType;
private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, IReadOnlyDictionary<string, int>? boneNameMap, IoNotifier notifier) // TODO: This signature is getting out of control.
private MeshExporter(ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier)
{ {
_config = config;
_notifier = notifier; _notifier = notifier;
_mdl = mdl; _mdl = mdl;
_lod = lod; _lod = lod;
@ -72,8 +75,8 @@ public class MeshExporter
_material = materials[XivMesh.MaterialIndex]; _material = materials[XivMesh.MaterialIndex];
if (boneNameMap != null) if (skeleton != null)
_boneIndexMap = BuildBoneIndexMap(boneNameMap); _boneIndexMap = BuildBoneIndexMap(skeleton.Value);
var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements
.ToImmutableDictionary( .ToImmutableDictionary(
@ -94,7 +97,7 @@ public class MeshExporter
} }
/// <summary> Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. </summary> /// <summary> Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. </summary>
private Dictionary<ushort, int>? BuildBoneIndexMap(IReadOnlyDictionary<string, int> boneNameMap) private Dictionary<ushort, int>? BuildBoneIndexMap(GltfSkeleton skeleton)
{ {
// A BoneTableIndex of 255 means that this mesh is not skinned. // A BoneTableIndex of 255 means that this mesh is not skinned.
if (XivMesh.BoneTableIndex == 255) if (XivMesh.BoneTableIndex == 255)
@ -107,8 +110,18 @@ public class MeshExporter
foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount).WithIndex()) foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount).WithIndex())
{ {
var boneName = _mdl.Bones[xivBoneIndex]; var boneName = _mdl.Bones[xivBoneIndex];
if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex)) if (!skeleton.Names.TryGetValue(boneName, out var gltfBoneIndex))
throw _notifier.Exception($"Armature does not contain bone \"{boneName}\". Ensure all dependencies are enabled in the current collection, and EST entries (if required) are configured."); {
if (!_config.GenerateMissingBones)
throw _notifier.Exception(
$@"Armature does not contain bone ""{boneName}"".
Ensure all dependencies are enabled in the current collection, and EST entries (if required) are configured.
If this is a known issue with this model and you would like to export anyway, enable the ""Generate missing bones"" option."
);
(_, gltfBoneIndex) = skeleton.GenerateBone(boneName);
_notifier.Warning($"Generated missing bone \"{boneName}\". Vertices weighted to this bone will not move with the rest of the armature.");
}
indexMap.Add((ushort)tableIndex, gltfBoneIndex); indexMap.Add((ushort)tableIndex, gltfBoneIndex);
} }

View file

@ -23,16 +23,16 @@ 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, IEnumerable<XivSkeleton>? xivSkeleton, Dictionary<string, MaterialExporter.Material> rawMaterials, IoNotifier notifier) public static Model Export(ExportConfig config, MdlFile mdl, IEnumerable<XivSkeleton> xivSkeletons, Dictionary<string, MaterialExporter.Material> rawMaterials, IoNotifier notifier)
{ {
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; var gltfSkeleton = ConvertSkeleton(xivSkeletons);
var materials = ConvertMaterials(mdl, rawMaterials, notifier); var materials = ConvertMaterials(mdl, rawMaterials, notifier);
var meshes = ConvertMeshes(mdl, materials, gltfSkeleton, notifier); var meshes = ConvertMeshes(config, mdl, materials, gltfSkeleton, notifier);
return new Model(meshes, gltfSkeleton); return new Model(meshes, gltfSkeleton);
} }
/// <summary> Convert a .mdl to a mesh (group) per LoD. </summary> /// <summary> Convert a .mdl to a mesh (group) per LoD. </summary>
private static List<MeshExporter.Mesh> ConvertMeshes(MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) private static List<MeshExporter.Mesh> ConvertMeshes(ExportConfig config, MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier)
{ {
var meshes = new List<MeshExporter.Mesh>(); var meshes = new List<MeshExporter.Mesh>();
@ -44,7 +44,7 @@ public class ModelExporter
for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++)
{ {
var meshIndex = (ushort)(lod.MeshIndex + meshOffset); var meshIndex = (ushort)(lod.MeshIndex + meshOffset);
var mesh = MeshExporter.Export(mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}")); var mesh = MeshExporter.Export(config, mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}"));
meshes.Add(mesh); meshes.Add(mesh);
} }
} }
@ -105,7 +105,7 @@ public class ModelExporter
return new GltfSkeleton return new GltfSkeleton
{ {
Root = root, Root = root,
Joints = [.. joints], Joints = joints,
Names = names, Names = names,
}; };
} }

View file

@ -28,8 +28,18 @@ public struct GltfSkeleton
public NodeBuilder Root; public NodeBuilder Root;
/// <summary> Flattened list of skeleton nodes. </summary> /// <summary> Flattened list of skeleton nodes. </summary>
public NodeBuilder[] Joints; public List<NodeBuilder> Joints;
/// <summary> Mapping of bone names to their index within the joints array. </summary> /// <summary> Mapping of bone names to their index within the joints array. </summary>
public Dictionary<string, int> Names; public Dictionary<string, int> Names;
public (NodeBuilder, int) GenerateBone(string name)
{
var node = new NodeBuilder(name);
var index = Joints.Count;
Names[name] = index;
Joints.Add(node);
Root.AddNode(node);
return (node, index);
}
} }

View file

@ -37,9 +37,9 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
_tasks.Clear(); _tasks.Clear();
} }
public Task<IoNotifier> ExportToGltf(MdlFile mdl, IEnumerable<string> sklbPaths, Func<string, byte[]?> read, string outputPath) public Task<IoNotifier> ExportToGltf(ExportConfig config, MdlFile mdl, IEnumerable<string> sklbPaths, Func<string, byte[]?> read, string outputPath)
=> EnqueueWithResult( => EnqueueWithResult(
new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath), new ExportToGltfAction(this, config, mdl, sklbPaths, read, outputPath),
action => action.Notifier action => action.Notifier
); );
@ -182,6 +182,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
private class ExportToGltfAction( private class ExportToGltfAction(
ModelManager manager, ModelManager manager,
ExportConfig config,
MdlFile mdl, MdlFile mdl,
IEnumerable<string> sklbPaths, IEnumerable<string> sklbPaths,
Func<string, byte[]?> read, Func<string, byte[]?> read,
@ -204,7 +205,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
.ToDictionary(pair => pair.path, pair => pair.material!.Value); .ToDictionary(pair => pair.path, pair => pair.material!.Value);
Penumbra.Log.Debug("[GLTF Export] Converting model..."); Penumbra.Log.Debug("[GLTF Export] Converting model...");
var model = ModelExporter.Export(mdl, xivSkeletons, materials, Notifier); var model = ModelExporter.Export(config, mdl, xivSkeletons, materials, Notifier);
Penumbra.Log.Debug("[GLTF Export] Building scene..."); Penumbra.Log.Debug("[GLTF Export] Building scene...");
var scene = new SceneBuilder(); var scene = new SceneBuilder();
@ -219,10 +220,13 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
/// <summary> Attempt to read out the pertinent information from the sklb file paths provided. </summary> /// <summary> Attempt to read out the pertinent information from the sklb file paths provided. </summary>
private IEnumerable<XivSkeleton> BuildSkeletons(CancellationToken cancel) private IEnumerable<XivSkeleton> BuildSkeletons(CancellationToken cancel)
{ {
// We're intentionally filtering failed reads here - the failure will
// be picked up, if relevant, when the model tries to create mappings
// for a bone in the failed sklb.
var havokTasks = sklbPaths var havokTasks = sklbPaths
.Select(path => read(path) ?? throw new Exception( .Select(path => read(path))
$"Resolved skeleton \"{path}\" could not be read. Ensure EST metadata is configured, and/or relevant mods are enabled in the current collection.")) .Where(bytes => bytes != null)
.Select(bytes => new SklbFile(bytes)) .Select(bytes => new SklbFile(bytes!))
.WithIndex() .WithIndex()
.Select(CreateHavokTask) .Select(CreateHavokTask)
.ToArray(); .ToArray();

View file

@ -3,6 +3,7 @@ using OtterGui;
using Penumbra.GameData; using Penumbra.GameData;
using Penumbra.GameData.Files; using Penumbra.GameData.Files;
using Penumbra.Import.Models; using Penumbra.Import.Models;
using Penumbra.Import.Models.Export;
using Penumbra.Meta.Manipulations; using Penumbra.Meta.Manipulations;
using Penumbra.String.Classes; using Penumbra.String.Classes;
@ -20,6 +21,8 @@ public partial class ModEditWindow
public bool ImportKeepMaterials; public bool ImportKeepMaterials;
public bool ImportKeepAttributes; public bool ImportKeepAttributes;
public ExportConfig ExportConfig;
public List<Utf8GamePath>? GamePaths { get; private set; } public List<Utf8GamePath>? GamePaths { get; private set; }
public int GamePathIndex; public int GamePathIndex;
@ -131,7 +134,7 @@ public partial class ModEditWindow
} }
PendingIo = true; PendingIo = true;
_edit._models.ExportToGltf(Mdl, sklbPaths, ReadFile, outputPath) _edit._models.ExportToGltf(ExportConfig, Mdl, sklbPaths, ReadFile, outputPath)
.ContinueWith(task => .ContinueWith(task =>
{ {
RecordIoExceptions(task.Exception); RecordIoExceptions(task.Exception);

View file

@ -112,6 +112,14 @@ public partial class ModEditWindow
DrawGamePathCombo(tab); DrawGamePathCombo(tab);
// ImGui.Checkbox("##exportGeneratedMissingBones", ref tab.ExportGenerateMissingBones);
ImGui.Checkbox("##exportGeneratedMissingBones", ref tab.ExportConfig.GenerateMissingBones);
ImGui.SameLine();
ImGuiUtil.LabeledHelpMarker("Generate missing bones",
"WARNING: Enabling this option can result in unusable exported meshes.\n"
+ "It is primarily intended to allow exporting models weighted to bones that do not exist.\n"
+ "Before enabling, ensure dependencies are enabled in the current collection, and EST metadata is correctly configured.");
var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count
? tab.GamePaths[tab.GamePathIndex] ? tab.GamePaths[tab.GamePathIndex]
: _customGamePath; : _customGamePath;