mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-14 20:54:16 +01:00
Merge branch 'mdl-io-warnings'
This commit is contained in:
commit
964ddc2572
13 changed files with 328 additions and 141 deletions
6
Penumbra/Import/Models/Export/Config.cs
Normal file
6
Penumbra/Import/Models/Export/Config.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Penumbra.Import.Models.Export;
|
||||
|
||||
public struct ExportConfig
|
||||
{
|
||||
public bool GenerateMissingBones;
|
||||
}
|
||||
|
|
@ -22,8 +22,14 @@ public class MaterialExporter
|
|||
// variant?
|
||||
}
|
||||
|
||||
/// <summary> Dependency-less material configuration, for use when no material data can be resolved. </summary>
|
||||
public static readonly MaterialBuilder Unknown = new MaterialBuilder("UNKNOWN")
|
||||
.WithMetallicRoughnessShader()
|
||||
.WithDoubleSide(true)
|
||||
.WithBaseColor(Vector4.One);
|
||||
|
||||
/// <summary> Build a glTF material from a hydrated XIV model, with the provided name. </summary>
|
||||
public static MaterialBuilder Export(Material material, string name)
|
||||
public static MaterialBuilder Export(Material material, string name, IoNotifier notifier)
|
||||
{
|
||||
Penumbra.Log.Debug($"Exporting material \"{name}\".");
|
||||
return material.Mtrl.ShaderPackage.Name switch
|
||||
|
|
@ -34,7 +40,7 @@ public class MaterialExporter
|
|||
"hair.shpk" => BuildHair(material, name),
|
||||
"iris.shpk" => BuildIris(material, name),
|
||||
"skin.shpk" => BuildSkin(material, name),
|
||||
_ => BuildFallback(material, name),
|
||||
_ => BuildFallback(material, name, notifier),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -335,9 +341,9 @@ public class MaterialExporter
|
|||
|
||||
/// <summary> Build a material from a source with unknown semantics. </summary>
|
||||
/// <remarks> Will make a loose effort to fetch common / simple textures. </remarks>
|
||||
private static MaterialBuilder BuildFallback(Material material, string name)
|
||||
private static MaterialBuilder BuildFallback(Material material, string name, IoNotifier notifier)
|
||||
{
|
||||
Penumbra.Log.Warning($"Unhandled shader package: {material.Mtrl.ShaderPackage.Name}");
|
||||
notifier.Warning($"Unhandled shader package: {material.Mtrl.ShaderPackage.Name}");
|
||||
|
||||
var materialBuilder = BuildSharedBase(material, name)
|
||||
.WithMetallicRoughnessShader()
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ namespace Penumbra.Import.Models.Export;
|
|||
|
||||
public class MeshExporter
|
||||
{
|
||||
public class Mesh(IEnumerable<MeshData> meshes, NodeBuilder[]? joints)
|
||||
public class Mesh(IEnumerable<MeshData> meshes, GltfSkeleton? skeleton)
|
||||
{
|
||||
public void AddToScene(SceneBuilder scene)
|
||||
{
|
||||
foreach (var data in meshes)
|
||||
{
|
||||
var instance = joints != null
|
||||
? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, joints)
|
||||
var instance = skeleton != null
|
||||
? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, [.. skeleton.Value.Joints])
|
||||
: scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity);
|
||||
|
||||
var extras = new Dictionary<string, object>(data.Attributes.Length);
|
||||
|
|
@ -38,14 +38,19 @@ public class MeshExporter
|
|||
public string[] Attributes;
|
||||
}
|
||||
|
||||
public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, GltfSkeleton? skeleton)
|
||||
public static Mesh Export(in ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials,
|
||||
GltfSkeleton? skeleton,
|
||||
IoNotifier notifier)
|
||||
{
|
||||
var self = new MeshExporter(mdl, lod, meshIndex, materials, skeleton?.Names);
|
||||
return new Mesh(self.BuildMeshes(), skeleton?.Joints);
|
||||
var self = new MeshExporter(config, mdl, lod, meshIndex, materials, skeleton, notifier);
|
||||
return new Mesh(self.BuildMeshes(), skeleton);
|
||||
}
|
||||
|
||||
private const byte MaximumMeshBufferStreams = 3;
|
||||
|
||||
private readonly ExportConfig _config;
|
||||
private readonly IoNotifier _notifier;
|
||||
|
||||
private readonly MdlFile _mdl;
|
||||
private readonly byte _lod;
|
||||
private readonly ushort _meshIndex;
|
||||
|
|
@ -61,16 +66,20 @@ public class MeshExporter
|
|||
private readonly Type _materialType;
|
||||
private readonly Type _skinningType;
|
||||
|
||||
private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, IReadOnlyDictionary<string, int>? boneNameMap)
|
||||
// TODO: This signature is getting out of control.
|
||||
private MeshExporter(in ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials,
|
||||
GltfSkeleton? skeleton, IoNotifier notifier)
|
||||
{
|
||||
_config = config;
|
||||
_notifier = notifier;
|
||||
_mdl = mdl;
|
||||
_lod = lod;
|
||||
_meshIndex = meshIndex;
|
||||
|
||||
_material = materials[XivMesh.MaterialIndex];
|
||||
|
||||
if (boneNameMap != null)
|
||||
_boneIndexMap = BuildBoneIndexMap(boneNameMap);
|
||||
if (skeleton != null)
|
||||
_boneIndexMap = BuildBoneIndexMap(skeleton.Value);
|
||||
|
||||
var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements
|
||||
.ToImmutableDictionary(
|
||||
|
|
@ -84,14 +93,14 @@ 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.");
|
||||
_notifier.Warning($"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}");
|
||||
}
|
||||
|
||||
/// <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.
|
||||
if (XivMesh.BoneTableIndex == 255)
|
||||
|
|
@ -104,8 +113,19 @@ public class MeshExporter
|
|||
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}.");
|
||||
if (!skeleton.Names.TryGetValue(boneName, out var gltfBoneIndex))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
|
@ -128,7 +148,7 @@ public class MeshExporter
|
|||
.Take(XivMesh.SubMeshCount)
|
||||
.WithIndex()
|
||||
.Select(subMesh => BuildMesh($"mesh {_meshIndex}.{subMesh.Index}", indices, vertices,
|
||||
(int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount,
|
||||
(int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount,
|
||||
subMesh.Value.AttributeIndexMask))
|
||||
.ToArray();
|
||||
}
|
||||
|
|
@ -217,7 +237,7 @@ public class MeshExporter
|
|||
|
||||
return new MeshData
|
||||
{
|
||||
Mesh = meshBuilder,
|
||||
Mesh = meshBuilder,
|
||||
Attributes = attributes,
|
||||
};
|
||||
}
|
||||
|
|
@ -271,7 +291,7 @@ public class MeshExporter
|
|||
}
|
||||
|
||||
/// <summary> Read a vertex attribute of the specified type from a vertex buffer stream. </summary>
|
||||
private static object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader)
|
||||
private object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
|
|
@ -284,15 +304,15 @@ public class MeshExporter
|
|||
MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(),
|
||||
(float)reader.ReadHalf()),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(),
|
||||
var other => throw _notifier.Exception<ArgumentOutOfRangeException>($"Unhandled vertex type {other}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary> Get the vertex geometry type for this mesh's vertex usages. </summary>
|
||||
private static Type GetGeometryType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
|
||||
private Type GetGeometryType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
|
||||
{
|
||||
if (!usages.ContainsKey(MdlFile.VertexUsage.Position))
|
||||
throw new Exception("Mesh does not contain position vertex elements.");
|
||||
throw _notifier.Exception("Mesh does not contain position vertex elements.");
|
||||
|
||||
if (!usages.ContainsKey(MdlFile.VertexUsage.Normal))
|
||||
return typeof(VertexPosition);
|
||||
|
|
@ -330,11 +350,11 @@ public class MeshExporter
|
|||
);
|
||||
}
|
||||
|
||||
throw new Exception($"Unknown geometry type {_geometryType}.");
|
||||
throw _notifier.Exception($"Unknown geometry type {_geometryType}.");
|
||||
}
|
||||
|
||||
/// <summary> Get the vertex material type for this mesh's vertex usages. </summary>
|
||||
private static Type GetMaterialType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
|
||||
private Type GetMaterialType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
|
||||
{
|
||||
var uvCount = 0;
|
||||
if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var type))
|
||||
|
|
@ -343,7 +363,7 @@ public class MeshExporter
|
|||
MdlFile.VertexType.Half2 => 1,
|
||||
MdlFile.VertexType.Half4 => 2,
|
||||
MdlFile.VertexType.Single4 => 2,
|
||||
_ => throw new Exception($"Unexpected UV vertex type {type}."),
|
||||
_ => throw _notifier.Exception($"Unexpected UV vertex type {type}."),
|
||||
};
|
||||
|
||||
var materialUsages = (
|
||||
|
|
@ -403,7 +423,7 @@ public class MeshExporter
|
|||
);
|
||||
}
|
||||
|
||||
throw new Exception($"Unknown material type {_skinningType}");
|
||||
throw _notifier.Exception($"Unknown material type {_skinningType}");
|
||||
}
|
||||
|
||||
/// <summary> Get the vertex skinning type for this mesh's vertex usages. </summary>
|
||||
|
|
@ -424,7 +444,7 @@ public class MeshExporter
|
|||
if (_skinningType == typeof(VertexJoints4))
|
||||
{
|
||||
if (_boneIndexMap == null)
|
||||
throw new Exception("Tried to build skinned vertex but no bone mappings are available.");
|
||||
throw _notifier.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]);
|
||||
|
|
@ -435,7 +455,7 @@ public class MeshExporter
|
|||
// 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}.");
|
||||
throw _notifier.Exception($"Vertex contains weight for unknown bone index {xivBoneIndex}.");
|
||||
|
||||
return (jointIndex, weights[bindingIndex]);
|
||||
})
|
||||
|
|
@ -443,7 +463,7 @@ public class MeshExporter
|
|||
return new VertexJoints4(bindings);
|
||||
}
|
||||
|
||||
throw new Exception($"Unknown skinning type {_skinningType}");
|
||||
throw _notifier.Exception($"Unknown skinning type {_skinningType}");
|
||||
}
|
||||
|
||||
/// <summary> Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. </summary>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
public static Model Export(MdlFile mdl, IEnumerable<XivSkeleton>? xivSkeleton, Dictionary<string, MaterialExporter.Material> rawMaterials)
|
||||
public static Model Export(in ExportConfig config, MdlFile mdl, IEnumerable<XivSkeleton> xivSkeletons, Dictionary<string, MaterialExporter.Material> rawMaterials, IoNotifier notifier)
|
||||
{
|
||||
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null;
|
||||
var materials = ConvertMaterials(mdl, rawMaterials);
|
||||
var meshes = ConvertMeshes(mdl, materials, gltfSkeleton);
|
||||
var gltfSkeleton = ConvertSkeleton(xivSkeletons);
|
||||
var materials = ConvertMaterials(mdl, rawMaterials, notifier);
|
||||
var meshes = ConvertMeshes(config, mdl, materials, gltfSkeleton, notifier);
|
||||
return new Model(meshes, gltfSkeleton);
|
||||
}
|
||||
|
||||
/// <summary> Convert a .mdl to a mesh (group) per LoD. </summary>
|
||||
private static List<MeshExporter.Mesh> ConvertMeshes(MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton)
|
||||
private static List<MeshExporter.Mesh> ConvertMeshes(in ExportConfig config, MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier)
|
||||
{
|
||||
var meshes = new List<MeshExporter.Mesh>();
|
||||
|
||||
|
|
@ -43,7 +43,8 @@ public class ModelExporter
|
|||
// TODO: consider other types of mesh?
|
||||
for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++)
|
||||
{
|
||||
var mesh = MeshExporter.Export(mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), materials, skeleton);
|
||||
var meshIndex = (ushort)(lod.MeshIndex + meshOffset);
|
||||
var mesh = MeshExporter.Export(config, mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}"));
|
||||
meshes.Add(mesh);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,11 +53,16 @@ public class ModelExporter
|
|||
}
|
||||
|
||||
/// <summary> Build materials for each of the material slots in the .mdl. </summary>
|
||||
private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary<string, MaterialExporter.Material> rawMaterials)
|
||||
private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary<string, MaterialExporter.Material> rawMaterials, IoNotifier notifier)
|
||||
=> mdl.Materials
|
||||
// TODO: material generation should be fallible, which means this lookup should be a tryget, with a fallback.
|
||||
// fallback can likely be a static on the material exporter.
|
||||
.Select(name => MaterialExporter.Export(rawMaterials[name], name))
|
||||
.Select(name =>
|
||||
{
|
||||
if (rawMaterials.TryGetValue(name, out var rawMaterial))
|
||||
return MaterialExporter.Export(rawMaterial, name, notifier.WithContext($"Material {name}"));
|
||||
|
||||
notifier.Warning($"Material \"{name}\" missing, using blank fallback.");
|
||||
return MaterialExporter.Unknown;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
/// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary>
|
||||
|
|
@ -99,7 +105,7 @@ public class ModelExporter
|
|||
return new GltfSkeleton
|
||||
{
|
||||
Root = root,
|
||||
Joints = [.. joints],
|
||||
Joints = joints,
|
||||
Names = names,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,18 @@ public struct GltfSkeleton
|
|||
public NodeBuilder Root;
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using SharpGLTF.Schema2;
|
|||
|
||||
namespace Penumbra.Import.Models.Import;
|
||||
|
||||
public class MeshImporter(IEnumerable<Node> nodes)
|
||||
public class MeshImporter(IEnumerable<Node> nodes, IoNotifier notifier)
|
||||
{
|
||||
public struct Mesh
|
||||
{
|
||||
|
|
@ -33,9 +33,9 @@ public class MeshImporter(IEnumerable<Node> nodes)
|
|||
public List<MdlStructs.ShapeValueStruct> ShapeValues;
|
||||
}
|
||||
|
||||
public static Mesh Import(IEnumerable<Node> nodes)
|
||||
public static Mesh Import(IEnumerable<Node> nodes, IoNotifier notifier)
|
||||
{
|
||||
var importer = new MeshImporter(nodes);
|
||||
var importer = new MeshImporter(nodes, notifier);
|
||||
return importer.Create();
|
||||
}
|
||||
|
||||
|
|
@ -115,11 +115,11 @@ public class MeshImporter(IEnumerable<Node> nodes)
|
|||
var vertexOffset = _vertexCount;
|
||||
var indexOffset = _indices.Count;
|
||||
|
||||
var nodeBoneMap = CreateNodeBoneMap(node);
|
||||
var subMesh = SubMeshImporter.Import(node, nodeBoneMap);
|
||||
|
||||
var subMeshName = node.Name ?? node.Mesh.Name;
|
||||
|
||||
var nodeBoneMap = CreateNodeBoneMap(node);
|
||||
var subMesh = SubMeshImporter.Import(node, nodeBoneMap, notifier.WithContext($"Sub-mesh {subMeshName}"));
|
||||
|
||||
// TODO: Record a warning if there's a mismatch between current and incoming, as we can't support multiple materials per mesh.
|
||||
_material ??= subMesh.Material;
|
||||
|
||||
|
|
@ -127,8 +127,11 @@ public class MeshImporter(IEnumerable<Node> nodes)
|
|||
if (_vertexDeclaration == null)
|
||||
_vertexDeclaration = subMesh.VertexDeclaration;
|
||||
else if (VertexDeclarationMismatch(subMesh.VertexDeclaration, _vertexDeclaration.Value))
|
||||
throw new Exception(
|
||||
$"Sub-mesh \"{subMeshName}\" vertex declaration mismatch. All sub-meshes of a mesh must have equivalent vertex declarations.");
|
||||
throw notifier.Exception(
|
||||
$@"All sub-meshes of a mesh must have equivalent vertex declarations.
|
||||
Current: {FormatVertexDeclaration(_vertexDeclaration.Value)}
|
||||
Sub-mesh ""{subMeshName}"": {FormatVertexDeclaration(subMesh.VertexDeclaration)}"
|
||||
);
|
||||
|
||||
// Given that strides are derived from declarations, a lack of mismatch in declarations means the strides are fine.
|
||||
// TODO: I mean, given that strides are derivable, might be worth dropping strides from the sub mesh return structure and computing when needed.
|
||||
|
|
@ -170,6 +173,9 @@ public class MeshImporter(IEnumerable<Node> nodes)
|
|||
});
|
||||
}
|
||||
|
||||
private static string FormatVertexDeclaration(MdlStructs.VertexDeclarationStruct vertexDeclaration)
|
||||
=> string.Join(", ", vertexDeclaration.VertexElements.Select(element => $"{element.Usage} ({element.Type}@{element.Stream}:{element.Offset})"));
|
||||
|
||||
private static bool VertexDeclarationMismatch(MdlStructs.VertexDeclarationStruct a, MdlStructs.VertexDeclarationStruct b)
|
||||
{
|
||||
var elA = a.VertexElements;
|
||||
|
|
@ -204,13 +210,13 @@ public class MeshImporter(IEnumerable<Node> nodes)
|
|||
var meshName = node.Name ?? mesh.Name ?? "(no name)";
|
||||
var primitiveCount = mesh.Primitives.Count;
|
||||
if (primitiveCount != 1)
|
||||
throw new Exception($"Mesh \"{meshName}\" has {primitiveCount} primitives, expected 1.");
|
||||
throw notifier.Exception($"Mesh \"{meshName}\" has {primitiveCount} primitives, expected 1.");
|
||||
|
||||
var primitive = mesh.Primitives[0];
|
||||
|
||||
// Per glTF specification, an asset with a skin MUST contain skinning attributes on its mesh.
|
||||
var jointsAccessor = primitive.GetVertexAccessor("JOINTS_0")
|
||||
?? throw new Exception($"Skinned mesh \"{meshName}\" is skinned but does not contain skinning vertex attributes.");
|
||||
?? throw notifier.Exception($"Mesh \"{meshName}\" is skinned but does not contain skinning vertex attributes.");
|
||||
|
||||
// Build a set of joints that are referenced by this mesh.
|
||||
// TODO: Would be neat to omit 0-weighted joints here, but doing so will require some further work on bone mapping behavior to ensure the unweighted joints can still be resolved to valid bone indices during vertex data construction.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
using Lumina.Data.Parsing;
|
||||
using OtterGui;
|
||||
using Penumbra.GameData.Files;
|
||||
using SharpGLTF.Schema2;
|
||||
|
||||
namespace Penumbra.Import.Models.Import;
|
||||
|
||||
public partial class ModelImporter(ModelRoot model)
|
||||
public partial class ModelImporter(ModelRoot model, IoNotifier notifier)
|
||||
{
|
||||
public static MdlFile Import(ModelRoot model)
|
||||
public static MdlFile Import(ModelRoot model, IoNotifier notifier)
|
||||
{
|
||||
var importer = new ModelImporter(model);
|
||||
var importer = new ModelImporter(model, notifier);
|
||||
return importer.Create();
|
||||
}
|
||||
|
||||
|
|
@ -39,8 +40,8 @@ public partial class ModelImporter(ModelRoot model)
|
|||
private MdlFile Create()
|
||||
{
|
||||
// Group and build out meshes in this model.
|
||||
foreach (var subMeshNodes in GroupedMeshNodes())
|
||||
BuildMeshForGroup(subMeshNodes);
|
||||
foreach (var (subMeshNodes, index) in GroupedMeshNodes().WithIndex())
|
||||
BuildMeshForGroup(subMeshNodes, index);
|
||||
|
||||
// Now that all the meshes have been built, we can build some of the model-wide metadata.
|
||||
var materials = _materials.Count > 0 ? _materials : ["/NO_MATERIAL"];
|
||||
|
|
@ -128,14 +129,14 @@ public partial class ModelImporter(ModelRoot model)
|
|||
)
|
||||
.OrderBy(group => group.Key);
|
||||
|
||||
private void BuildMeshForGroup(IEnumerable<Node> subMeshNodes)
|
||||
private void BuildMeshForGroup(IEnumerable<Node> subMeshNodes, int index)
|
||||
{
|
||||
// Record some offsets we'll be using later, before they get mutated with mesh values.
|
||||
var subMeshOffset = _subMeshes.Count;
|
||||
var vertexOffset = _vertexBuffer.Count;
|
||||
var indexOffset = _indices.Count;
|
||||
|
||||
var mesh = MeshImporter.Import(subMeshNodes);
|
||||
var mesh = MeshImporter.Import(subMeshNodes, notifier.WithContext($"Mesh {index}"));
|
||||
var meshStartIndex = (uint)(mesh.MeshStruct.StartIndex + indexOffset);
|
||||
|
||||
var materialIndex = mesh.Material != null
|
||||
|
|
@ -195,7 +196,7 @@ public partial class ModelImporter(ModelRoot model)
|
|||
// arrays, values is practically guaranteed to be the highest of the
|
||||
// group, so a failure on any of them will be a failure on it.
|
||||
if (_shapeValues.Count > ushort.MaxValue)
|
||||
throw new Exception($"Importing this file would require more than the maximum of {ushort.MaxValue} shape values.\nTry removing or applying shape keys that do not need to be changed at runtime in-game.");
|
||||
throw notifier.Exception($"Importing this file would require more than the maximum of {ushort.MaxValue} shape values.\nTry removing or applying shape keys that do not need to be changed at runtime in-game.");
|
||||
}
|
||||
|
||||
private ushort GetMaterialIndex(string materialName)
|
||||
|
|
@ -231,7 +232,7 @@ public partial class ModelImporter(ModelRoot model)
|
|||
}
|
||||
|
||||
if (boneIndices.Count > 64)
|
||||
throw new Exception("XIV does not support meshes weighted to more than 64 bones.");
|
||||
throw notifier.Exception("XIV does not support meshes weighted to a total of more than 64 bones.");
|
||||
|
||||
var boneIndicesArray = new ushort[64];
|
||||
Array.Copy(boneIndices.ToArray(), boneIndicesArray, boneIndices.Count);
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ public class SubMeshImporter
|
|||
public Dictionary<string, List<MdlStructs.ShapeValueStruct>> ShapeValues;
|
||||
}
|
||||
|
||||
public static SubMesh Import(Node node, IDictionary<ushort, ushort>? nodeBoneMap)
|
||||
public static SubMesh Import(Node node, IDictionary<ushort, ushort>? nodeBoneMap, IoNotifier notifier)
|
||||
{
|
||||
var importer = new SubMeshImporter(node, nodeBoneMap);
|
||||
var importer = new SubMeshImporter(node, nodeBoneMap, notifier);
|
||||
return importer.Create();
|
||||
}
|
||||
|
||||
private readonly IoNotifier _notifier;
|
||||
|
||||
private readonly MeshPrimitive _primitive;
|
||||
private readonly IDictionary<ushort, ushort>? _nodeBoneMap;
|
||||
private readonly IDictionary<string, JsonElement>? _nodeExtras;
|
||||
|
|
@ -53,16 +55,15 @@ public class SubMeshImporter
|
|||
private readonly List<string>? _morphNames;
|
||||
private Dictionary<string, List<MdlStructs.ShapeValueStruct>>? _shapeValues;
|
||||
|
||||
private SubMeshImporter(Node node, IDictionary<ushort, ushort>? nodeBoneMap)
|
||||
private SubMeshImporter(Node node, IDictionary<ushort, ushort>? nodeBoneMap, IoNotifier notifier)
|
||||
{
|
||||
_notifier = notifier;
|
||||
|
||||
var mesh = node.Mesh;
|
||||
|
||||
var primitiveCount = mesh.Primitives.Count;
|
||||
if (primitiveCount != 1)
|
||||
{
|
||||
var name = node.Name ?? mesh.Name ?? "(no name)";
|
||||
throw new Exception($"Mesh \"{name}\" has {primitiveCount} primitives, expected 1.");
|
||||
}
|
||||
throw _notifier.Exception($"Mesh has {primitiveCount} primitives, expected 1.");
|
||||
|
||||
_primitive = mesh.Primitives[0];
|
||||
_nodeBoneMap = nodeBoneMap;
|
||||
|
|
@ -115,7 +116,7 @@ public class SubMeshImporter
|
|||
{
|
||||
< 32 => (1u << _metaAttributes.Length) - 1,
|
||||
32 => uint.MaxValue,
|
||||
> 32 => throw new Exception("Models may utilise a maximum of 32 attributes."),
|
||||
> 32 => throw _notifier.Exception("Models may utilise a maximum of 32 attributes."),
|
||||
};
|
||||
|
||||
return new SubMesh()
|
||||
|
|
@ -165,11 +166,11 @@ public class SubMeshImporter
|
|||
// The order here is chosen to match a typical model's element order.
|
||||
var rawAttributes = new[]
|
||||
{
|
||||
VertexAttribute.Position(accessors, morphAccessors),
|
||||
VertexAttribute.BlendWeight(accessors),
|
||||
VertexAttribute.BlendIndex(accessors, _nodeBoneMap),
|
||||
VertexAttribute.Position(accessors, morphAccessors, _notifier),
|
||||
VertexAttribute.BlendWeight(accessors, _notifier),
|
||||
VertexAttribute.BlendIndex(accessors, _nodeBoneMap, _notifier),
|
||||
VertexAttribute.Normal(accessors, morphAccessors),
|
||||
VertexAttribute.Tangent1(accessors, morphAccessors, _indices),
|
||||
VertexAttribute.Tangent1(accessors, morphAccessors, _indices, _notifier),
|
||||
VertexAttribute.Color(accessors),
|
||||
VertexAttribute.Uv(accessors),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ public class VertexAttribute
|
|||
private byte[] DefaultBuildMorph(int morphIndex, int vertexIndex)
|
||||
=> Build(vertexIndex);
|
||||
|
||||
public static VertexAttribute Position(Accessors accessors, IEnumerable<Accessors> morphAccessors)
|
||||
public static VertexAttribute Position(Accessors accessors, IEnumerable<Accessors> morphAccessors, IoNotifier notifier)
|
||||
{
|
||||
if (!accessors.TryGetValue("POSITION", out var accessor))
|
||||
throw new Exception("Meshes must contain a POSITION attribute.");
|
||||
throw notifier.Exception("Meshes must contain a POSITION attribute.");
|
||||
|
||||
var element = new MdlStructs.VertexElement()
|
||||
{
|
||||
|
|
@ -115,13 +115,13 @@ public class VertexAttribute
|
|||
);
|
||||
}
|
||||
|
||||
public static VertexAttribute? BlendWeight(Accessors accessors)
|
||||
public static VertexAttribute? BlendWeight(Accessors accessors, IoNotifier notifier)
|
||||
{
|
||||
if (!accessors.TryGetValue("WEIGHTS_0", out var accessor))
|
||||
return null;
|
||||
|
||||
if (!accessors.ContainsKey("JOINTS_0"))
|
||||
throw new Exception("Mesh contained WEIGHTS_0 attribute but no corresponding JOINTS_0 attribute.");
|
||||
throw notifier.Exception("Mesh contained WEIGHTS_0 attribute but no corresponding JOINTS_0 attribute.");
|
||||
|
||||
var element = new MdlStructs.VertexElement()
|
||||
{
|
||||
|
|
@ -138,16 +138,16 @@ public class VertexAttribute
|
|||
);
|
||||
}
|
||||
|
||||
public static VertexAttribute? BlendIndex(Accessors accessors, IDictionary<ushort, ushort>? boneMap)
|
||||
public static VertexAttribute? BlendIndex(Accessors accessors, IDictionary<ushort, ushort>? boneMap, IoNotifier notifier)
|
||||
{
|
||||
if (!accessors.TryGetValue("JOINTS_0", out var accessor))
|
||||
return null;
|
||||
|
||||
if (!accessors.ContainsKey("WEIGHTS_0"))
|
||||
throw new Exception("Mesh contained JOINTS_0 attribute but no corresponding WEIGHTS_0 attribute.");
|
||||
throw notifier.Exception("Mesh contained JOINTS_0 attribute but no corresponding WEIGHTS_0 attribute.");
|
||||
|
||||
if (boneMap == null)
|
||||
throw new Exception("Mesh contained JOINTS_0 attribute but no bone mapping was created.");
|
||||
throw notifier.Exception("Mesh contained JOINTS_0 attribute but no bone mapping was created.");
|
||||
|
||||
var element = new MdlStructs.VertexElement()
|
||||
{
|
||||
|
|
@ -242,22 +242,22 @@ public class VertexAttribute
|
|||
);
|
||||
}
|
||||
|
||||
public static VertexAttribute? Tangent1(Accessors accessors, IEnumerable<Accessors> morphAccessors, ushort[] indices)
|
||||
public static VertexAttribute? Tangent1(Accessors accessors, IEnumerable<Accessors> morphAccessors, ushort[] indices, IoNotifier notifier)
|
||||
{
|
||||
if (!accessors.TryGetValue("NORMAL", out var normalAccessor))
|
||||
{
|
||||
Penumbra.Log.Warning("Normals are required to facilitate import or calculation of tangents.");
|
||||
notifier.Warning("Normals are required to facilitate import or calculation of tangents.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var normals = normalAccessor.AsVector3Array();
|
||||
var tangents = accessors.TryGetValue("TANGENT", out var accessor)
|
||||
? accessor.AsVector4Array()
|
||||
: CalculateTangents(accessors, indices, normals);
|
||||
: CalculateTangents(accessors, indices, normals, notifier);
|
||||
|
||||
if (tangents == null)
|
||||
{
|
||||
Penumbra.Log.Warning("No tangents available for sub-mesh. This could lead to incorrect lighting, or mismatched vertex attributes.");
|
||||
notifier.Warning("No tangents available for sub-mesh. This could lead to incorrect lighting, or mismatched vertex attributes.");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ public class VertexAttribute
|
|||
}
|
||||
|
||||
/// <summary> Attempt to calculate tangent values based on other pre-existing data. </summary>
|
||||
private static Vector4[]? CalculateTangents(Accessors accessors, ushort[] indices, IList<Vector3> normals)
|
||||
private static Vector4[]? CalculateTangents(Accessors accessors, ushort[] indices, IList<Vector3> normals, IoNotifier notifier)
|
||||
{
|
||||
// To calculate tangents, we will also need access to uv data.
|
||||
if (!accessors.TryGetValue("TEXCOORD_0", out var uvAccessor))
|
||||
|
|
@ -318,8 +318,7 @@ public class VertexAttribute
|
|||
var positions = accessors["POSITION"].AsVector3Array();
|
||||
var uvs = uvAccessor.AsVector2Array();
|
||||
|
||||
// TODO: Surface this in the UI.
|
||||
Penumbra.Log.Warning(
|
||||
notifier.Warning(
|
||||
"Calculating tangents, this may result in degraded light interaction. For best results, ensure tangents are caculated or retained during export from 3D modelling tools.");
|
||||
|
||||
var vertexCount = positions.Count;
|
||||
|
|
|
|||
40
Penumbra/Import/Models/IoNotifier.cs
Normal file
40
Penumbra/Import/Models/IoNotifier.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using OtterGui.Log;
|
||||
|
||||
namespace Penumbra.Import.Models;
|
||||
|
||||
public record class IoNotifier
|
||||
{
|
||||
private readonly List<string> _messages = [];
|
||||
private string _context = "";
|
||||
|
||||
/// <summary> Create a new notifier with the specified context appended to any other context already present. </summary>
|
||||
public IoNotifier WithContext(string context)
|
||||
=> this with { _context = $"{_context}{context}: "};
|
||||
|
||||
/// <summary> Send a warning with any current context to notification channels. </summary>
|
||||
public void Warning(string content)
|
||||
=> SendMessage(content, Logger.LogLevel.Warning);
|
||||
|
||||
/// <summary> Get the current warnings for this notifier. </summary>
|
||||
/// <remarks> This does not currently filter to notifications with the current notifier's context - it will return all IO notifications from all notifiers. </remarks>
|
||||
public IEnumerable<string> GetWarnings()
|
||||
=> _messages;
|
||||
|
||||
/// <summary> Create an exception with any current context. </summary>
|
||||
[StackTraceHidden]
|
||||
public Exception Exception(string message)
|
||||
=> Exception<Exception>(message);
|
||||
|
||||
/// <summary> Create an exception of the provided type with any current context. </summary>
|
||||
[StackTraceHidden]
|
||||
public TException Exception<TException>(string message)
|
||||
where TException : Exception, new()
|
||||
=> (TException)Activator.CreateInstance(typeof(TException), $"{_context}{message}")!;
|
||||
|
||||
private void SendMessage(string message, Logger.LogLevel type)
|
||||
{
|
||||
var fullText = $"{_context}{message}";
|
||||
Penumbra.Log.Message(type, fullText);
|
||||
_messages.Add(fullText);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,20 +37,17 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
_tasks.Clear();
|
||||
}
|
||||
|
||||
public Task ExportToGltf(MdlFile mdl, IEnumerable<string> sklbPaths, Func<string, byte[]> read, string outputPath)
|
||||
=> Enqueue(new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath));
|
||||
public Task<IoNotifier> ExportToGltf(in ExportConfig config, MdlFile mdl, IEnumerable<string> sklbPaths, Func<string, byte[]?> read, string outputPath)
|
||||
=> EnqueueWithResult(
|
||||
new ExportToGltfAction(this, config, mdl, sklbPaths, read, outputPath),
|
||||
action => action.Notifier
|
||||
);
|
||||
|
||||
public Task<MdlFile?> ImportGltf(string inputPath)
|
||||
{
|
||||
var action = new ImportGltfAction(inputPath);
|
||||
return Enqueue(action).ContinueWith(task =>
|
||||
{
|
||||
if (task is { IsFaulted: true, Exception: not null })
|
||||
throw task.Exception;
|
||||
|
||||
return action.Out;
|
||||
});
|
||||
}
|
||||
public Task<(MdlFile?, IoNotifier)> ImportGltf(string inputPath)
|
||||
=> EnqueueWithResult(
|
||||
new ImportGltfAction(inputPath),
|
||||
action => (action.Out, action.Notifier)
|
||||
);
|
||||
|
||||
/// <summary> Try to find the .sklb paths for a .mdl file. </summary>
|
||||
/// <param name="mdlPath"> .mdl file to look up the skeletons for. </param>
|
||||
|
|
@ -109,7 +106,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
}
|
||||
|
||||
/// <summary> Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model. </summary>
|
||||
private string ResolveMtrlPath(string rawPath)
|
||||
private string? ResolveMtrlPath(string rawPath, IoNotifier notifier)
|
||||
{
|
||||
// TODO: this should probably be chosen in the export settings
|
||||
var variantId = 1;
|
||||
|
|
@ -122,13 +119,18 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
? rawPath
|
||||
: '/' + Path.GetFileName(rawPath);
|
||||
|
||||
// TODO: this should be a recoverable warning
|
||||
if (absolutePath == null)
|
||||
throw new Exception("Failed to resolve material path.");
|
||||
{
|
||||
notifier.Warning($"Material path \"{rawPath}\" could not be resolved.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var info = parser.GetFileInfo(absolutePath);
|
||||
if (info.FileType is not FileType.Material)
|
||||
throw new Exception($"Material path {rawPath} does not conform to material conventions.");
|
||||
{
|
||||
notifier.Warning($"Material path {rawPath} does not conform to material conventions.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedPath = info.ObjectType switch
|
||||
{
|
||||
|
|
@ -168,14 +170,27 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
return task;
|
||||
}
|
||||
|
||||
private Task<TOut> EnqueueWithResult<TAction, TOut>(TAction action, Func<TAction, TOut> process)
|
||||
where TAction : IAction
|
||||
=> Enqueue(action).ContinueWith(task =>
|
||||
{
|
||||
if (task is { IsFaulted: true, Exception: not null })
|
||||
throw task.Exception;
|
||||
|
||||
return process(action);
|
||||
});
|
||||
|
||||
private class ExportToGltfAction(
|
||||
ModelManager manager,
|
||||
ExportConfig config,
|
||||
MdlFile mdl,
|
||||
IEnumerable<string> sklbPaths,
|
||||
Func<string, byte[]> read,
|
||||
Func<string, byte[]?> read,
|
||||
string outputPath)
|
||||
: IAction
|
||||
{
|
||||
public readonly IoNotifier Notifier = new();
|
||||
|
||||
public void Execute(CancellationToken cancel)
|
||||
{
|
||||
Penumbra.Log.Debug($"[GLTF Export] Exporting model to {outputPath}...");
|
||||
|
|
@ -184,13 +199,13 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
var xivSkeletons = BuildSkeletons(cancel);
|
||||
|
||||
Penumbra.Log.Debug("[GLTF Export] Reading materials...");
|
||||
var materials = mdl.Materials.ToDictionary(
|
||||
path => path,
|
||||
path => BuildMaterial(path, cancel)
|
||||
);
|
||||
var materials = mdl.Materials
|
||||
.Select(path => (path, material: BuildMaterial(path, Notifier, cancel)))
|
||||
.Where(pair => pair.material != null)
|
||||
.ToDictionary(pair => pair.path, pair => pair.material!.Value);
|
||||
|
||||
Penumbra.Log.Debug("[GLTF Export] Converting model...");
|
||||
var model = ModelExporter.Export(mdl, xivSkeletons, materials);
|
||||
var model = ModelExporter.Export(config, mdl, xivSkeletons, materials, Notifier);
|
||||
|
||||
Penumbra.Log.Debug("[GLTF Export] Building scene...");
|
||||
var scene = new SceneBuilder();
|
||||
|
|
@ -205,8 +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>
|
||||
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
|
||||
.Select(path => new SklbFile(read(path)))
|
||||
.Select(read)
|
||||
.Where(bytes => bytes != null)
|
||||
.Select(bytes => new SklbFile(bytes!))
|
||||
.WithIndex()
|
||||
.Select(CreateHavokTask)
|
||||
.ToArray();
|
||||
|
|
@ -225,10 +245,15 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
}
|
||||
|
||||
/// <summary> Read a .mtrl and populate its textures. </summary>
|
||||
private MaterialExporter.Material BuildMaterial(string relativePath, CancellationToken cancel)
|
||||
private MaterialExporter.Material? BuildMaterial(string relativePath, IoNotifier notifier, CancellationToken cancel)
|
||||
{
|
||||
var path = manager.ResolveMtrlPath(relativePath);
|
||||
var mtrl = new MtrlFile(read(path));
|
||||
var path = manager.ResolveMtrlPath(relativePath, notifier);
|
||||
if (path == null)
|
||||
return null;
|
||||
var bytes = read(path);
|
||||
if (bytes == null)
|
||||
return null;
|
||||
var mtrl = new MtrlFile(bytes);
|
||||
|
||||
return new MaterialExporter.Material
|
||||
{
|
||||
|
|
@ -245,12 +270,23 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
{
|
||||
// Work out the texture's path - the DX11 material flag controls a file name prefix.
|
||||
GamePaths.Tex.HandleDx11Path(texture, out var texturePath);
|
||||
using var textureData = new MemoryStream(read(texturePath));
|
||||
var bytes = read(texturePath);
|
||||
if (bytes == null)
|
||||
return CreateDummyImage();
|
||||
|
||||
using var textureData = new MemoryStream(bytes);
|
||||
var image = TexFileParser.Parse(textureData);
|
||||
var pngImage = TextureManager.ConvertToPng(image, cancel).AsPng;
|
||||
return pngImage ?? throw new Exception("Failed to convert texture to png.");
|
||||
}
|
||||
|
||||
private static Image<Rgba32> CreateDummyImage()
|
||||
{
|
||||
var image = new Image<Rgba32>(1, 1);
|
||||
image[0, 0] = Color.White;
|
||||
return image;
|
||||
}
|
||||
|
||||
public bool Equals(IAction? other)
|
||||
{
|
||||
if (other is not ExportToGltfAction rhs)
|
||||
|
|
@ -263,13 +299,14 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
|||
|
||||
private partial class ImportGltfAction(string inputPath) : IAction
|
||||
{
|
||||
public MdlFile? Out;
|
||||
public MdlFile? Out;
|
||||
public readonly IoNotifier Notifier = new();
|
||||
|
||||
public void Execute(CancellationToken cancel)
|
||||
{
|
||||
var model = Schema2.ModelRoot.Load(inputPath);
|
||||
|
||||
Out = ModelImporter.Import(model);
|
||||
Out = ModelImporter.Import(model, Notifier);
|
||||
}
|
||||
|
||||
public bool Equals(IAction? other)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ using Lumina.Data.Parsing;
|
|||
using OtterGui;
|
||||
using Penumbra.GameData;
|
||||
using Penumbra.GameData.Files;
|
||||
using Penumbra.Import.Models;
|
||||
using Penumbra.Import.Models.Export;
|
||||
using Penumbra.Meta.Manipulations;
|
||||
using Penumbra.String.Classes;
|
||||
|
||||
|
|
@ -13,18 +15,21 @@ public partial class ModEditWindow
|
|||
{
|
||||
private readonly ModEditWindow _edit;
|
||||
|
||||
public MdlFile Mdl { get; private set; }
|
||||
public MdlFile Mdl { get; private set; }
|
||||
private List<string>?[] _attributes;
|
||||
|
||||
public bool ImportKeepMaterials;
|
||||
public bool ImportKeepAttributes;
|
||||
|
||||
public ExportConfig ExportConfig;
|
||||
|
||||
public List<Utf8GamePath>? GamePaths { get; private set; }
|
||||
public int GamePathIndex;
|
||||
|
||||
private bool _dirty;
|
||||
public bool PendingIo { get; private set; }
|
||||
public List<Exception> IoExceptions { get; private set; } = [];
|
||||
public List<string> IoWarnings { get; private set; } = [];
|
||||
|
||||
public MdlTab(ModEditWindow edit, byte[] bytes, string path)
|
||||
{
|
||||
|
|
@ -38,7 +43,7 @@ public partial class ModEditWindow
|
|||
[MemberNotNull(nameof(Mdl), nameof(_attributes))]
|
||||
private void Initialize(MdlFile mdl)
|
||||
{
|
||||
Mdl = mdl;
|
||||
Mdl = mdl;
|
||||
_attributes = CreateAttributes(Mdl);
|
||||
}
|
||||
|
||||
|
|
@ -90,14 +95,14 @@ public partial class ModEditWindow
|
|||
task.ContinueWith(t =>
|
||||
{
|
||||
RecordIoExceptions(t.Exception);
|
||||
GamePaths = t.Result;
|
||||
PendingIo = false;
|
||||
GamePaths = t.Result;
|
||||
PendingIo = false;
|
||||
});
|
||||
}
|
||||
|
||||
private EstManipulation[] GetCurrentEstManipulations()
|
||||
{
|
||||
var mod = _edit._editor.Mod;
|
||||
var mod = _edit._editor.Mod;
|
||||
var option = _edit._editor.Option;
|
||||
if (mod == null || option == null)
|
||||
return [];
|
||||
|
|
@ -129,15 +134,17 @@ public partial class ModEditWindow
|
|||
}
|
||||
|
||||
PendingIo = true;
|
||||
_edit._models.ExportToGltf(Mdl, sklbPaths, ReadFile, outputPath)
|
||||
_edit._models.ExportToGltf(ExportConfig, Mdl, sklbPaths, ReadFile, outputPath)
|
||||
.ContinueWith(task =>
|
||||
{
|
||||
RecordIoExceptions(task.Exception);
|
||||
PendingIo = false;
|
||||
if (task is { IsCompletedSuccessfully: true, Result: not null })
|
||||
IoWarnings = task.Result.GetWarnings().ToList();
|
||||
PendingIo = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary> Import a model from an interchange format. </summary>
|
||||
|
||||
/// <summary> Import a model from an interchange format. </summary>
|
||||
/// <param name="inputPath"> Disk path to load model data from. </param>
|
||||
public void Import(string inputPath)
|
||||
{
|
||||
|
|
@ -146,8 +153,12 @@ public partial class ModEditWindow
|
|||
.ContinueWith(task =>
|
||||
{
|
||||
RecordIoExceptions(task.Exception);
|
||||
if (task is { IsCompletedSuccessfully: true, Result: not null })
|
||||
FinalizeImport(task.Result);
|
||||
if (task is { IsCompletedSuccessfully: true, Result: (not null, _) })
|
||||
{
|
||||
IoWarnings = task.Result.Item2.GetWarnings().ToList();
|
||||
FinalizeImport(task.Result.Item1);
|
||||
}
|
||||
|
||||
PendingIo = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -168,11 +179,11 @@ public partial class ModEditWindow
|
|||
// TODO: Add flag editing.
|
||||
newMdl.Flags1 = Mdl.Flags1;
|
||||
newMdl.Flags2 = Mdl.Flags2;
|
||||
|
||||
|
||||
Initialize(newMdl);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
|
||||
/// <summary> Merge material configuration from the source onto the target. </summary>
|
||||
/// <param name="target"> Model that will be updated. </param>
|
||||
/// <param name="source"> Model to copy material configuration from. </param>
|
||||
|
|
@ -208,10 +219,12 @@ public partial class ModEditWindow
|
|||
// to maintain semantic connection between mesh index and sub mesh attributes.
|
||||
if (meshIndex >= source.Meshes.Length)
|
||||
continue;
|
||||
|
||||
var sourceMesh = source.Meshes[meshIndex];
|
||||
|
||||
if (subMeshOffset >= sourceMesh.SubMeshCount)
|
||||
continue;
|
||||
|
||||
var sourceSubMesh = source.SubMeshes[sourceMesh.SubMeshIndex + subMeshOffset];
|
||||
|
||||
target.SubMeshes[subMeshIndex].AttributeIndexMask = sourceSubMesh.AttributeIndexMask;
|
||||
|
|
@ -227,11 +240,13 @@ public partial class ModEditWindow
|
|||
|
||||
foreach (var sourceElement in source.ElementIds)
|
||||
{
|
||||
var sourceBone = source.Bones[sourceElement.ParentBoneName];
|
||||
var sourceBone = source.Bones[sourceElement.ParentBoneName];
|
||||
var targetIndex = target.Bones.IndexOf(sourceBone);
|
||||
// Given that there's no means of authoring these at the moment, this should probably remain a hard error.
|
||||
if (targetIndex == -1)
|
||||
throw new Exception($"Failed to merge element IDs. Original model contains element IDs targeting bone {sourceBone}, which is not present on the imported model.");
|
||||
throw new Exception(
|
||||
$"Failed to merge element IDs. Original model contains element IDs targeting bone {sourceBone}, which is not present on the imported model.");
|
||||
|
||||
elementIds.Add(sourceElement with
|
||||
{
|
||||
ParentBoneName = (uint)targetIndex,
|
||||
|
|
@ -243,17 +258,18 @@ public partial class ModEditWindow
|
|||
|
||||
private void RecordIoExceptions(Exception? exception)
|
||||
{
|
||||
IoExceptions = exception switch {
|
||||
IoExceptions = exception switch
|
||||
{
|
||||
null => [],
|
||||
AggregateException ae => [.. ae.Flatten().InnerExceptions],
|
||||
_ => [exception],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary> Read a file from the active collection or game. </summary>
|
||||
/// <param name="path"> Game path to the file to load. </param>
|
||||
// TODO: Also look up files within the current mod regardless of mod state?
|
||||
private byte[] ReadFile(string path)
|
||||
private byte[]? ReadFile(string path)
|
||||
{
|
||||
// TODO: if cross-collection lookups are turned off, this conversion can be skipped
|
||||
if (!Utf8GamePath.FromString(path, out var utf8Path, true))
|
||||
|
|
@ -262,13 +278,9 @@ public partial class ModEditWindow
|
|||
var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8Path) ?? new FullPath(utf8Path);
|
||||
|
||||
// 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.IsRooted
|
||||
return resolvedPath.IsRooted
|
||||
? File.ReadAllBytes(resolvedPath.FullName)
|
||||
: _edit._gameData.GetFile(resolvedPath.InternalName.ToString())?.Data;
|
||||
|
||||
// TODO: some callers may not care about failures - handle exceptions separately?
|
||||
return bytes ?? throw new Exception(
|
||||
$"Resolved path {path} could not be found. If modded, is it enabled in the current collection?");
|
||||
}
|
||||
|
||||
/// <summary> Remove the material given by the index. </summary>
|
||||
|
|
@ -291,7 +303,7 @@ public partial class ModEditWindow
|
|||
|
||||
/// <summary> Create a list of attributes per sub mesh. </summary>
|
||||
private static List<string>?[] CreateAttributes(MdlFile mdl)
|
||||
=> mdl.SubMeshes.Select(s =>
|
||||
=> mdl.SubMeshes.Select(s =>
|
||||
{
|
||||
var maxAttribute = 31 - BitOperations.LeadingZeroCount(s.AttributeIndexMask);
|
||||
// TODO: Research what results in this - it seems to primarily be reproducible on bgparts, is it garbage data, or an alternative usage of the value?
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ public partial class ModEditWindow
|
|||
DrawExport(tab, childSize, disabled);
|
||||
|
||||
DrawIoExceptions(tab);
|
||||
DrawIoWarnings(tab);
|
||||
}
|
||||
|
||||
private void DrawImport(MdlTab tab, Vector2 size, bool _1)
|
||||
|
|
@ -119,6 +120,14 @@ public partial class ModEditWindow
|
|||
}
|
||||
|
||||
DrawGamePathCombo(tab);
|
||||
|
||||
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
|
||||
? tab.GamePaths[tab.GamePathIndex]
|
||||
: _customGamePath;
|
||||
|
|
@ -160,7 +169,41 @@ public partial class ModEditWindow
|
|||
|
||||
using var exceptionNode = ImRaii.TreeNode(message);
|
||||
if (exceptionNode)
|
||||
{
|
||||
using var indent = ImRaii.PushIndent();
|
||||
ImGuiUtil.TextWrapped(exception.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawIoWarnings(MdlTab tab)
|
||||
{
|
||||
if (tab.IoWarnings.Count == 0)
|
||||
return;
|
||||
|
||||
var size = new Vector2(ImGui.GetContentRegionAvail().X, 0);
|
||||
using var frame = ImRaii.FramedGroup("Warnings", size, headerPreIcon: FontAwesomeIcon.ExclamationCircle, borderColor: 0xFF40FFFF);
|
||||
|
||||
var spaceAvail = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - 100;
|
||||
foreach (var (warning, index) in tab.IoWarnings.WithIndex())
|
||||
{
|
||||
using var id = ImRaii.PushId(index);
|
||||
var textSize = ImGui.CalcTextSize(warning).X;
|
||||
|
||||
if (textSize <= spaceAvail)
|
||||
{
|
||||
ImRaii.TreeNode(warning, ImGuiTreeNodeFlags.Leaf).Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
var firstLine = warning[..(int)Math.Floor(warning.Length * (spaceAvail / textSize))] + "...";
|
||||
|
||||
using var warningNode = ImRaii.TreeNode(firstLine);
|
||||
if (warningNode)
|
||||
{
|
||||
using var indent = ImRaii.PushIndent();
|
||||
ImGuiUtil.TextWrapped(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue