Merge branch 'mdl-import'

This commit is contained in:
Ottermandias 2024-01-07 23:23:42 +01:00
commit e344bd4258
9 changed files with 1118 additions and 18 deletions

@ -1 +1 @@
Subproject commit 2c603cea9b1d4dd500e30972b64bd2f25012dc4c Subproject commit df754445aa6f67fbeb84a292fe808ee560bc3cf7

View file

@ -0,0 +1,222 @@
using Lumina.Data.Parsing;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
public class MeshImporter(IEnumerable<Node> nodes)
{
public struct Mesh
{
public MdlStructs.MeshStruct MeshStruct;
public List<MdlStructs.SubmeshStruct> SubMeshStructs;
public MdlStructs.VertexDeclarationStruct VertexDeclaration;
public IEnumerable<byte> VertexBuffer;
public List<ushort> Indices;
public List<string>? Bones;
public List<MeshShapeKey> ShapeKeys;
}
public struct MeshShapeKey
{
public string Name;
public MdlStructs.ShapeMeshStruct ShapeMesh;
public List<MdlStructs.ShapeValueStruct> ShapeValues;
}
public static Mesh Import(IEnumerable<Node> nodes)
{
var importer = new MeshImporter(nodes);
return importer.Create();
}
private readonly List<MdlStructs.SubmeshStruct> _subMeshes = [];
private MdlStructs.VertexDeclarationStruct? _vertexDeclaration;
private byte[]? _strides;
private ushort _vertexCount;
private readonly List<byte>[] _streams = [[], [], []];
private readonly List<ushort> _indices = [];
private List<string>? _bones;
private readonly Dictionary<string, List<MdlStructs.ShapeValueStruct>> _shapeValues = [];
private Mesh Create()
{
foreach (var node in nodes)
BuildSubMeshForNode(node);
ArgumentNullException.ThrowIfNull(_strides);
ArgumentNullException.ThrowIfNull(_vertexDeclaration);
return new Mesh
{
MeshStruct = new MdlStructs.MeshStruct
{
VertexBufferOffset = [0, (uint)_streams[0].Count, (uint)(_streams[0].Count + _streams[1].Count)],
VertexBufferStride = _strides,
VertexCount = _vertexCount,
VertexStreamCount = (byte)_vertexDeclaration.Value.VertexElements
.Select(element => element.Stream + 1)
.Max(),
StartIndex = 0,
IndexCount = (uint)_indices.Count,
// TODO: import material names
MaterialIndex = 0,
SubMeshIndex = 0,
SubMeshCount = (ushort)_subMeshes.Count,
BoneTableIndex = 0,
},
SubMeshStructs = _subMeshes,
VertexDeclaration = _vertexDeclaration.Value,
VertexBuffer = _streams[0].Concat(_streams[1]).Concat(_streams[2]),
Indices = _indices,
Bones = _bones,
ShapeKeys = _shapeValues
.Select(pair => new MeshShapeKey()
{
Name = pair.Key,
ShapeMesh = new MdlStructs.ShapeMeshStruct()
{
MeshIndexOffset = 0,
ShapeValueOffset = 0,
ShapeValueCount = (uint)pair.Value.Count,
},
ShapeValues = pair.Value,
})
.ToList(),
};
}
private void BuildSubMeshForNode(Node node)
{
// Record some offsets we'll be using later, before they get mutated with sub-mesh values.
var vertexOffset = _vertexCount;
var indexOffset = _indices.Count;
var nodeBoneMap = CreateNodeBoneMap(node);
var subMesh = SubMeshImporter.Import(node, nodeBoneMap);
var subMeshName = node.Name ?? node.Mesh.Name;
// Check that vertex declarations match - we need to combine the buffers, so a mismatch would take a whole load of resolution.
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.");
// 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.
_strides ??= subMesh.Strides;
// Merge the sub-mesh streams into the main mesh stream bodies.
_vertexCount += subMesh.VertexCount;
foreach (var (stream, subStream) in _streams.Zip(subMesh.Streams))
stream.AddRange(subStream);
// As we're appending vertex data to the buffers, we need to update indices to point into that later block.
_indices.AddRange(subMesh.Indices.Select(index => (ushort)(index + vertexOffset)));
// Merge the sub-mesh's shape values into the mesh's.
foreach (var (name, subMeshShapeValues) in subMesh.ShapeValues)
{
if (!_shapeValues.TryGetValue(name, out var meshShapeValues))
{
meshShapeValues = [];
_shapeValues.Add(name, meshShapeValues);
}
meshShapeValues.AddRange(subMeshShapeValues.Select(value => value with
{
BaseIndicesIndex = (ushort)(value.BaseIndicesIndex + indexOffset),
ReplacingVertexIndex = (ushort)(value.ReplacingVertexIndex + vertexOffset),
}));
}
// And finally, merge in the sub-mesh struct itself.
_subMeshes.Add(subMesh.SubMeshStruct with
{
IndexOffset = (ushort)(subMesh.SubMeshStruct.IndexOffset + indexOffset),
});
}
private static bool VertexDeclarationMismatch(MdlStructs.VertexDeclarationStruct a, MdlStructs.VertexDeclarationStruct b)
{
var elA = a.VertexElements;
var elB = b.VertexElements;
if (elA.Length != elB.Length)
return true;
// NOTE: This assumes that elements will always be in the same order. Under the current implementation, that's guaranteed.
return elA.Zip(elB).Any(pair =>
pair.First.Usage != pair.Second.Usage
|| pair.First.Type != pair.Second.Type
|| pair.First.Offset != pair.Second.Offset
|| pair.First.Stream != pair.Second.Stream
);
}
private Dictionary<ushort, ushort>? CreateNodeBoneMap(Node node)
{
// Unskinned assets can skip this all of this.
if (node.Skin == null)
return null;
// Build an array of joint names, preserving the joint index from the skin.
// Any unnamed joints we'll be coalescing on a fallback bone name - though this is realistically unlikely to occur.
var jointNames = Enumerable.Range(0, node.Skin.JointsCount)
.Select(index => node.Skin.GetJoint(index).Joint.Name ?? "unnamed_joint")
.ToArray();
// TODO: This is duplicated with the sub mesh importer - would be good to avoid (not that it's a huge issue).
var mesh = node.Mesh;
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.");
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.");
// 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.
var usedJoints = new HashSet<ushort>();
foreach (var joints in jointsAccessor.AsVector4Array())
{
for (var index = 0; index < 4; index++)
usedJoints.Add((ushort)joints[index]);
}
// Only initialise the bones list if we're actually going to put something in it.
_bones ??= [];
// Build a dictionary of node-specific joint indices mesh-wide bone indices.
var nodeBoneMap = new Dictionary<ushort, ushort>();
foreach (var usedJoint in usedJoints)
{
var jointName = jointNames[usedJoint];
var boneIndex = _bones.IndexOf(jointName);
if (boneIndex == -1)
{
boneIndex = _bones.Count;
_bones.Add(jointName);
}
nodeBoneMap.Add(usedJoint, (ushort)boneIndex);
}
return nodeBoneMap;
}
}

View file

@ -0,0 +1,206 @@
using Lumina.Data.Parsing;
using Penumbra.GameData.Files;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
public partial class ModelImporter(ModelRoot _model)
{
public static MdlFile Import(ModelRoot model)
{
var importer = new ModelImporter(model);
return importer.Create();
}
// NOTE: This is intended to match TexTool's grouping regex, ".*[_ ^]([0-9]+)[\\.\\-]?([0-9]+)?$"
[GeneratedRegex(@"[_ ^](?'Mesh'[0-9]+)[.-]?(?'SubMesh'[0-9]+)?$", RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)]
private static partial Regex MeshNameGroupingRegex();
private readonly List<MdlStructs.MeshStruct> _meshes = [];
private readonly List<MdlStructs.SubmeshStruct> _subMeshes = [];
private readonly List<MdlStructs.VertexDeclarationStruct> _vertexDeclarations = [];
private readonly List<byte> _vertexBuffer = [];
private readonly List<ushort> _indices = [];
private readonly List<string> _bones = [];
private readonly List<MdlStructs.BoneTableStruct> _boneTables = [];
private readonly Dictionary<string, List<MdlStructs.ShapeMeshStruct>> _shapeMeshes = [];
private readonly List<MdlStructs.ShapeValueStruct> _shapeValues = [];
private MdlFile Create()
{
// Group and build out meshes in this model.
foreach (var subMeshNodes in GroupedMeshNodes())
BuildMeshForGroup(subMeshNodes);
// Now that all the meshes have been built, we can build some of the model-wide metadata.
var shapes = new List<MdlFile.Shape>();
var shapeMeshes = new List<MdlStructs.ShapeMeshStruct>();
foreach (var (keyName, keyMeshes) in _shapeMeshes)
{
shapes.Add(new MdlFile.Shape()
{
ShapeName = keyName,
// NOTE: these values are per-LoD.
ShapeMeshStartIndex = [(ushort)shapeMeshes.Count, 0, 0],
ShapeMeshCount = [(ushort)keyMeshes.Count, 0, 0],
});
shapeMeshes.AddRange(keyMeshes);
}
var indexBuffer = _indices.SelectMany(BitConverter.GetBytes).ToArray();
// And finally, the MdlFile itself.
return new MdlFile
{
VertexOffset = [0, 0, 0],
VertexBufferSize = [(uint)_vertexBuffer.Count, 0, 0],
IndexOffset = [(uint)_vertexBuffer.Count, 0, 0],
IndexBufferSize = [(uint)indexBuffer.Length, 0, 0],
VertexDeclarations = [.. _vertexDeclarations],
Meshes = [.. _meshes],
SubMeshes = [.. _subMeshes],
BoneTables = [.. _boneTables],
Bones = [.. _bones],
// TODO: Game doesn't seem to rely on this, but would be good to populate.
SubMeshBoneMap = [],
Shapes = [.. shapes],
ShapeMeshes = [.. shapeMeshes],
ShapeValues = [.. _shapeValues],
LodCount = 1,
Lods =
[
new MdlStructs.LodStruct
{
MeshIndex = 0,
MeshCount = (ushort)_meshes.Count,
ModelLodRange = 0,
TextureLodRange = 0,
VertexDataOffset = 0,
VertexBufferSize = (uint)_vertexBuffer.Count,
IndexDataOffset = (uint)_vertexBuffer.Count,
IndexBufferSize = (uint)indexBuffer.Length,
},
],
// TODO: Would be good to populate from gltf material names.
Materials = ["/NO_MATERIAL"],
// TODO: Would be good to calculate all of this up the tree.
Radius = 1,
BoundingBoxes = MdlFile.EmptyBoundingBox,
BoneBoundingBoxes = Enumerable.Repeat(MdlFile.EmptyBoundingBox, _bones.Count).ToArray(),
RemainingData = [.._vertexBuffer, ..indexBuffer],
};
}
/// <summary> Returns an iterator over sorted, grouped mesh nodes. </summary>
private IEnumerable<IEnumerable<Node>> GroupedMeshNodes()
=> _model.LogicalNodes
.Where(node => node.Mesh != null)
.Select(node =>
{
var name = node.Name ?? node.Mesh.Name ?? "NOMATCH";
var match = MeshNameGroupingRegex().Match(name);
return (node, match);
})
.Where(pair => pair.match.Success)
.OrderBy(pair =>
{
var subMeshGroup = pair.match.Groups["SubMesh"];
return subMeshGroup.Success ? int.Parse(subMeshGroup.Value) : 0;
})
.GroupBy(
pair => int.Parse(pair.match.Groups["Mesh"].Value),
pair => pair.node
)
.OrderBy(group => group.Key);
private void BuildMeshForGroup(IEnumerable<Node> subMeshNodes)
{
// 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 shapeValueOffset = _shapeValues.Count;
var mesh = MeshImporter.Import(subMeshNodes);
var meshStartIndex = (uint)(mesh.MeshStruct.StartIndex + indexOffset);
// If no bone table is used for a mesh, the index is set to 255.
var boneTableIndex = 255;
if (mesh.Bones != null)
boneTableIndex = BuildBoneTable(mesh.Bones);
_meshes.Add(mesh.MeshStruct with
{
SubMeshIndex = (ushort)(mesh.MeshStruct.SubMeshIndex + subMeshOffset),
BoneTableIndex = (ushort)boneTableIndex,
StartIndex = meshStartIndex,
VertexBufferOffset = mesh.MeshStruct.VertexBufferOffset
.Select(offset => (uint)(offset + vertexOffset))
.ToArray(),
});
_subMeshes.AddRange(mesh.SubMeshStructs.Select(m => m with
{
IndexOffset = (uint)(m.IndexOffset + indexOffset),
}));
_vertexDeclarations.Add(mesh.VertexDeclaration);
_vertexBuffer.AddRange(mesh.VertexBuffer);
_indices.AddRange(mesh.Indices);
foreach (var meshShapeKey in mesh.ShapeKeys)
{
if (!_shapeMeshes.TryGetValue(meshShapeKey.Name, out var shapeMeshes))
{
shapeMeshes = [];
_shapeMeshes.Add(meshShapeKey.Name, shapeMeshes);
}
shapeMeshes.Add(meshShapeKey.ShapeMesh with
{
MeshIndexOffset = meshStartIndex,
ShapeValueOffset = (uint)shapeValueOffset,
});
_shapeValues.AddRange(meshShapeKey.ShapeValues);
}
}
private ushort BuildBoneTable(List<string> boneNames)
{
var boneIndices = new List<ushort>();
foreach (var boneName in boneNames)
{
var boneIndex = _bones.IndexOf(boneName);
if (boneIndex == -1)
{
boneIndex = _bones.Count;
_bones.Add(boneName);
}
boneIndices.Add((ushort)boneIndex);
}
if (boneIndices.Count > 64)
throw new Exception("XIV does not support meshes weighted to more than 64 bones.");
var boneIndicesArray = new ushort[64];
Array.Copy(boneIndices.ToArray(), boneIndicesArray, boneIndices.Count);
var boneTableIndex = _boneTables.Count;
_boneTables.Add(new MdlStructs.BoneTableStruct()
{
BoneIndex = boneIndicesArray,
BoneCount = (byte)boneIndices.Count,
});
return (ushort)boneTableIndex;
}
}

View file

@ -0,0 +1,220 @@
using Lumina.Data.Parsing;
using OtterGui;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
public class SubMeshImporter
{
public struct SubMesh
{
public MdlStructs.SubmeshStruct SubMeshStruct;
public MdlStructs.VertexDeclarationStruct VertexDeclaration;
public ushort VertexCount;
public byte[] Strides;
public List<byte>[] Streams;
public ushort[] Indices;
public Dictionary<string, List<MdlStructs.ShapeValueStruct>> ShapeValues;
}
public static SubMesh Import(Node node, IDictionary<ushort, ushort>? nodeBoneMap)
{
var importer = new SubMeshImporter(node, nodeBoneMap);
return importer.Create();
}
private readonly MeshPrimitive _primitive;
private readonly IDictionary<ushort, ushort>? _nodeBoneMap;
private List<VertexAttribute>? _attributes;
private ushort _vertexCount;
private byte[] _strides = [0, 0, 0];
private readonly List<byte>[] _streams;
private ushort[]? _indices;
private readonly List<string>? _morphNames;
private Dictionary<string, List<MdlStructs.ShapeValueStruct>>? _shapeValues;
private SubMeshImporter(Node node, IDictionary<ushort, ushort>? nodeBoneMap)
{
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.");
}
_primitive = mesh.Primitives[0];
_nodeBoneMap = nodeBoneMap;
try
{
_morphNames = mesh.Extras.GetNode("targetNames").Deserialize<List<string>>();
}
catch
{
_morphNames = null;
}
// All meshes may use up to 3 byte streams.
_streams = new List<byte>[3];
for (var streamIndex = 0; streamIndex < 3; streamIndex++)
_streams[streamIndex] = [];
}
private SubMesh Create()
{
// Build all the data we'll need.
BuildIndices();
BuildAttributes();
BuildVertices();
ArgumentNullException.ThrowIfNull(_indices);
ArgumentNullException.ThrowIfNull(_attributes);
ArgumentNullException.ThrowIfNull(_shapeValues);
return new SubMesh()
{
SubMeshStruct = new MdlStructs.SubmeshStruct()
{
IndexOffset = 0,
IndexCount = (uint)_indices.Length,
AttributeIndexMask = 0,
// TODO: Flesh these out. Game doesn't seem to rely on them existing, though.
BoneStartIndex = 0,
BoneCount = 0,
},
VertexDeclaration = new MdlStructs.VertexDeclarationStruct()
{
VertexElements = _attributes.Select(attribute => attribute.Element).ToArray(),
},
VertexCount = _vertexCount,
Strides = _strides,
Streams = _streams,
Indices = _indices,
ShapeValues = _shapeValues,
};
}
private void BuildIndices()
{
_indices = _primitive.GetIndices().Select(idx => (ushort)idx).ToArray();
}
private void BuildAttributes()
{
var accessors = _primitive.VertexAccessors;
var morphAccessors = Enumerable.Range(0, _primitive.MorphTargetsCount)
.Select(index => _primitive.GetMorphTargetAccessors(index)).ToList();
// Try to build all the attributes the mesh might use.
// 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.Normal(accessors, morphAccessors),
VertexAttribute.Tangent1(accessors, morphAccessors),
VertexAttribute.Color(accessors),
VertexAttribute.Uv(accessors),
};
var attributes = new List<VertexAttribute>();
var offsets = new byte[]
{
0,
0,
0,
};
foreach (var attribute in rawAttributes)
{
if (attribute == null)
continue;
attributes.Add(attribute.WithOffset(offsets[attribute.Stream]));
offsets[attribute.Stream] += attribute.Size;
}
_attributes = attributes;
// After building the attributes, the resulting next offsets are our stream strides.
_strides = offsets;
}
private void BuildVertices()
{
ArgumentNullException.ThrowIfNull(_attributes);
// Lists of vertex indices that are effected by each morph target for this primitive.
var morphModifiedVertices = Enumerable.Range(0, _primitive.MorphTargetsCount)
.Select(_ => new List<int>())
.ToArray();
// We can safely assume that POSITION exists by this point - and if, by some bizarre chance, it doesn't, failing out is sane.
_vertexCount = (ushort)_primitive.VertexAccessors["POSITION"].Count;
for (var vertexIndex = 0; vertexIndex < _vertexCount; vertexIndex++)
{
// Write out vertex data to streams for each attribute.
foreach (var attribute in _attributes)
_streams[attribute.Stream].AddRange(attribute.Build(vertexIndex));
// Record which morph targets have values for this vertex, if any.
var changedMorphs = morphModifiedVertices
.WithIndex()
.Where(pair => _attributes.Any(attribute => attribute.HasMorph(pair.Index, vertexIndex)))
.Select(pair => pair.Value);
foreach (var modifiedVertices in changedMorphs)
modifiedVertices.Add(vertexIndex);
}
BuildShapeValues(morphModifiedVertices);
}
private void BuildShapeValues(IEnumerable<List<int>> morphModifiedVertices)
{
ArgumentNullException.ThrowIfNull(_indices);
ArgumentNullException.ThrowIfNull(_attributes);
var morphShapeValues = new Dictionary<string, List<MdlStructs.ShapeValueStruct>>();
foreach (var (modifiedVertices, morphIndex) in morphModifiedVertices.WithIndex())
{
// Each for a given mesh, each shape key contains a list of shape value mappings.
var shapeValues = new List<MdlStructs.ShapeValueStruct>();
foreach (var vertexIndex in modifiedVertices)
{
// Write out the morphed vertex to the vertex streams.
foreach (var attribute in _attributes)
_streams[attribute.Stream].AddRange(attribute.BuildMorph(morphIndex, vertexIndex));
// Find any indices that target this vertex index and create a mapping.
var targetingIndices = _indices.WithIndex()
.SelectWhere(pair => (pair.Value == vertexIndex, pair.Index));
shapeValues.AddRange(targetingIndices.Select(targetingIndex => new MdlStructs.ShapeValueStruct
{
BaseIndicesIndex = (ushort)targetingIndex,
ReplacingVertexIndex = _vertexCount,
}));
_vertexCount++;
}
var name = _morphNames != null ? _morphNames[morphIndex] : $"unnamed_shape_{morphIndex}";
morphShapeValues.Add(name, shapeValues);
}
_shapeValues = morphShapeValues;
}
}

View file

@ -0,0 +1,341 @@
using Lumina.Data.Parsing;
using Penumbra.GameData.Files;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
using BuildFn = Func<int, byte[]>;
using HasMorphFn = Func<int, int, bool>;
using BuildMorphFn = Func<int, int, byte[]>;
using Accessors = IReadOnlyDictionary<string, Accessor>;
public class VertexAttribute
{
/// <summary> XIV vertex element metadata structure. </summary>
public readonly MdlStructs.VertexElement Element;
/// <summary> Build a byte array containing this vertex attribute's data for the specified vertex index. </summary>
public readonly BuildFn Build;
/// <summary> Check if the specified morph target index contains a morph for the specified vertex index. </summary>
public readonly HasMorphFn HasMorph;
/// <summary> Build a byte array containing this vertex attribute's data, as modified by the specified morph target, for the specified vertex index. </summary>
public readonly BuildMorphFn BuildMorph;
public byte Stream
=> Element.Stream;
/// <summary> Size in bytes of a single vertex's attribute value. </summary>
public byte Size
=> (MdlFile.VertexType)Element.Type switch
{
MdlFile.VertexType.Single3 => 12,
MdlFile.VertexType.Single4 => 16,
MdlFile.VertexType.UInt => 4,
MdlFile.VertexType.ByteFloat4 => 4,
MdlFile.VertexType.Half2 => 4,
MdlFile.VertexType.Half4 => 8,
_ => throw new Exception($"Unhandled vertex type {(MdlFile.VertexType)Element.Type}"),
};
private VertexAttribute(
MdlStructs.VertexElement element,
BuildFn write,
HasMorphFn? hasMorph = null,
BuildMorphFn? buildMorph = null
)
{
Element = element;
Build = write;
HasMorph = hasMorph ?? DefaultHasMorph;
BuildMorph = buildMorph ?? DefaultBuildMorph;
}
public VertexAttribute WithOffset(byte offset)
=> new(
Element with { Offset = offset },
Build,
HasMorph,
BuildMorph
);
/// <remarks> We assume that attributes don't have morph data unless explicitly configured. </remarks>
private static bool DefaultHasMorph(int morphIndex, int vertexIndex)
=> false;
/// <remarks>
/// XIV stores shapes as full vertex replacements, so all attributes need to output something for a morph.
/// As a fallback, we're just building the normal vertex data for the index.
/// </remarks>>
private byte[] DefaultBuildMorph(int morphIndex, int vertexIndex)
=> Build(vertexIndex);
public static VertexAttribute Position(Accessors accessors, IEnumerable<Accessors> morphAccessors)
{
if (!accessors.TryGetValue("POSITION", out var accessor))
throw new Exception("Meshes must contain a POSITION attribute.");
var element = new MdlStructs.VertexElement()
{
Stream = 0,
Type = (byte)MdlFile.VertexType.Single3,
Usage = (byte)MdlFile.VertexUsage.Position,
};
var values = accessor.AsVector3Array();
var morphValues = morphAccessors
.Select(a => a.GetValueOrDefault("POSITION")?.AsVector3Array())
.ToArray();
return new VertexAttribute(
element,
index => BuildSingle3(values[index]),
(morphIndex, vertexIndex) =>
{
var deltas = morphValues[morphIndex];
if (deltas == null)
return false;
var delta = deltas[vertexIndex];
return delta != Vector3.Zero;
},
(morphIndex, vertexIndex) =>
{
var value = values[vertexIndex];
var delta = morphValues[morphIndex]?[vertexIndex];
if (delta != null)
value += delta.Value;
return BuildSingle3(value);
}
);
}
public static VertexAttribute? BlendWeight(Accessors accessors)
{
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.");
var element = new MdlStructs.VertexElement()
{
Stream = 0,
Type = (byte)MdlFile.VertexType.ByteFloat4,
Usage = (byte)MdlFile.VertexUsage.BlendWeights,
};
var values = accessor.AsVector4Array();
return new VertexAttribute(
element,
index => BuildByteFloat4(values[index])
);
}
public static VertexAttribute? BlendIndex(Accessors accessors, IDictionary<ushort, ushort>? boneMap)
{
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.");
if (boneMap == null)
throw new Exception("Mesh contained JOINTS_0 attribute but no bone mapping was created.");
var element = new MdlStructs.VertexElement()
{
Stream = 0,
Type = (byte)MdlFile.VertexType.UInt,
Usage = (byte)MdlFile.VertexUsage.BlendIndices,
};
var values = accessor.AsVector4Array();
return new VertexAttribute(
element,
index =>
{
var gltfIndices = values[index];
return BuildUInt(new Vector4(
boneMap[(ushort)gltfIndices.X],
boneMap[(ushort)gltfIndices.Y],
boneMap[(ushort)gltfIndices.Z],
boneMap[(ushort)gltfIndices.W]
));
}
);
}
public static VertexAttribute? Normal(Accessors accessors, IEnumerable<Accessors> morphAccessors)
{
if (!accessors.TryGetValue("NORMAL", out var accessor))
return null;
var element = new MdlStructs.VertexElement()
{
Stream = 1,
Type = (byte)MdlFile.VertexType.Half4,
Usage = (byte)MdlFile.VertexUsage.Normal,
};
var values = accessor.AsVector3Array();
var morphValues = morphAccessors
.Select(a => a.GetValueOrDefault("NORMAL")?.AsVector3Array())
.ToArray();
return new VertexAttribute(
element,
index => BuildHalf4(new Vector4(values[index], 0)),
buildMorph: (morphIndex, vertexIndex) =>
{
var value = values[vertexIndex];
var delta = morphValues[morphIndex]?[vertexIndex];
if (delta != null)
value += delta.Value;
return BuildHalf4(new Vector4(value, 0));
}
);
}
public static VertexAttribute? Uv(Accessors accessors)
{
if (!accessors.TryGetValue("TEXCOORD_0", out var accessor1))
return null;
// We're omitting type here, and filling it in on return, as there's two different types we might use.
var element = new MdlStructs.VertexElement()
{
Stream = 1,
Usage = (byte)MdlFile.VertexUsage.UV,
};
var values1 = accessor1.AsVector2Array();
// There's only one TEXCOORD, output UV coordinates as vec2s.
if (!accessors.TryGetValue("TEXCOORD_1", out var accessor2))
return new VertexAttribute(
element with { Type = (byte)MdlFile.VertexType.Half2 },
index => BuildHalf2(values1[index])
);
var values2 = accessor2.AsVector2Array();
// Two TEXCOORDs are available, repack them into xiv's vec4 [0X, 0Y, 1X, 1Y] format.
return new VertexAttribute(
element with { Type = (byte)MdlFile.VertexType.Half4 },
index =>
{
var value1 = values1[index];
var value2 = values2[index];
return BuildHalf4(new Vector4(value1.X, value1.Y, value2.X, value2.Y));
}
);
}
public static VertexAttribute? Tangent1(Accessors accessors, IEnumerable<Accessors> morphAccessors)
{
if (!accessors.TryGetValue("TANGENT", out var accessor))
return null;
var element = new MdlStructs.VertexElement()
{
Stream = 1,
Type = (byte)MdlFile.VertexType.ByteFloat4,
Usage = (byte)MdlFile.VertexUsage.Tangent1,
};
var values = accessor.AsVector4Array();
// Per glTF specification, TANGENT morph values are stored as vec3, with the W component always considered to be 0.
var morphValues = morphAccessors
.Select(a => a.GetValueOrDefault("TANGENT")?.AsVector3Array())
.ToArray();
return new VertexAttribute(
element,
index => BuildByteFloat4(values[index]),
buildMorph: (morphIndex, vertexIndex) =>
{
var value = values[vertexIndex];
var delta = morphValues[morphIndex]?[vertexIndex];
if (delta != null)
value += new Vector4(delta.Value, 0);
return BuildByteFloat4(value);
}
);
}
public static VertexAttribute? Color(Accessors accessors)
{
if (!accessors.TryGetValue("COLOR_0", out var accessor))
return null;
var element = new MdlStructs.VertexElement()
{
Stream = 1,
Type = (byte)MdlFile.VertexType.ByteFloat4,
Usage = (byte)MdlFile.VertexUsage.Color,
};
var values = accessor.AsVector4Array();
return new VertexAttribute(
element,
index => BuildByteFloat4(values[index])
);
}
private static byte[] BuildSingle3(Vector3 input)
=>
[
..BitConverter.GetBytes(input.X),
..BitConverter.GetBytes(input.Y),
..BitConverter.GetBytes(input.Z),
];
private static byte[] BuildUInt(Vector4 input)
=>
[
(byte)input.X,
(byte)input.Y,
(byte)input.Z,
(byte)input.W,
];
private static byte[] BuildByteFloat4(Vector4 input)
=>
[
(byte)Math.Round(input.X * 255f),
(byte)Math.Round(input.Y * 255f),
(byte)Math.Round(input.Z * 255f),
(byte)Math.Round(input.W * 255f),
];
private static byte[] BuildHalf2(Vector2 input)
=>
[
..BitConverter.GetBytes((Half)input.X),
..BitConverter.GetBytes((Half)input.Y),
];
private static byte[] BuildHalf4(Vector4 input)
=>
[
..BitConverter.GetBytes((Half)input.X),
..BitConverter.GetBytes((Half)input.Y),
..BitConverter.GetBytes((Half)input.Z),
..BitConverter.GetBytes((Half)input.W),
];
}

View file

@ -8,8 +8,10 @@ using Penumbra.GameData.Enums;
using Penumbra.GameData.Files; using Penumbra.GameData.Files;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Import.Models.Export; using Penumbra.Import.Models.Export;
using Penumbra.Import.Models.Import;
using Penumbra.Meta.Manipulations; using Penumbra.Meta.Manipulations;
using SharpGLTF.Scenes; using SharpGLTF.Scenes;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models; namespace Penumbra.Import.Models;
@ -32,6 +34,11 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
public Task ExportToGltf(MdlFile mdl, IEnumerable<SklbFile> sklbs, string outputPath) public Task ExportToGltf(MdlFile mdl, IEnumerable<SklbFile> sklbs, string outputPath)
=> Enqueue(new ExportToGltfAction(this, mdl, sklbs, outputPath)); => Enqueue(new ExportToGltfAction(this, mdl, sklbs, outputPath));
public Task<MdlFile?> ImportGltf(string inputPath)
{
var action = new ImportGltfAction(inputPath);
return Enqueue(action).ContinueWith(_ => action.Out);
}
/// <summary> Try to find the .sklb paths 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 skeletons for. </param> /// <param name="mdlPath"> .mdl file to look up the skeletons for. </param>
/// <param name="estManipulations"> Modified extra skeleton template parameters. </param> /// <param name="estManipulations"> Modified extra skeleton template parameters. </param>
@ -166,4 +173,24 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
return true; return true;
} }
} }
private partial class ImportGltfAction(string inputPath) : IAction
{
public MdlFile? Out;
public void Execute(CancellationToken cancel)
{
var model = ModelRoot.Load(inputPath);
Out = ModelImporter.Import(model);
}
public bool Equals(IAction? other)
{
if (other is not ImportGltfAction rhs)
return false;
return true;
}
}
} }

View file

@ -12,25 +12,32 @@ public partial class ModEditWindow
{ {
private readonly ModEditWindow _edit; private readonly ModEditWindow _edit;
public readonly MdlFile Mdl; public MdlFile Mdl { get; private set; }
private readonly List<string>[] _attributes; private List<string>[] _attributes;
public List<Utf8GamePath>? GamePaths { get; private set; } public List<Utf8GamePath>? GamePaths { get; private set; }
public int GamePathIndex; public int GamePathIndex;
public bool PendingIo { get; private set; } private bool _dirty;
public string? IoException { get; private set; } public bool PendingIo { get; private set; }
public string? IoException { get; private set; }
public MdlTab(ModEditWindow edit, byte[] bytes, string path) public MdlTab(ModEditWindow edit, byte[] bytes, string path)
{ {
_edit = edit; _edit = edit;
Mdl = new MdlFile(bytes); Initialize(new MdlFile(bytes));
_attributes = CreateAttributes(Mdl);
FindGamePaths(path); FindGamePaths(path);
} }
[MemberNotNull(nameof(Mdl), nameof(_attributes))]
private void Initialize(MdlFile mdl)
{
Mdl = mdl;
_attributes = CreateAttributes(Mdl);
}
/// <inheritdoc/> /// <inheritdoc/>
public bool Valid public bool Valid
=> Mdl.Valid; => Mdl.Valid;
@ -39,6 +46,16 @@ public partial class ModEditWindow
public byte[] Write() public byte[] Write()
=> Mdl.Write(); => Mdl.Write();
public bool Dirty
{
get
{
var dirty = _dirty;
_dirty = false;
return dirty;
}
}
/// <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>
private void FindGamePaths(string path) private void FindGamePaths(string path)
@ -93,7 +110,6 @@ public partial class ModEditWindow
/// <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>
/// <param name="mdlPath"> The game path of the model. </param>
public void Export(string outputPath, Utf8GamePath mdlPath) public void Export(string outputPath, Utf8GamePath mdlPath)
{ {
IEnumerable<SklbFile> skeletons; IEnumerable<SklbFile> skeletons;
@ -116,6 +132,24 @@ public partial class ModEditWindow
PendingIo = false; PendingIo = false;
}); });
} }
/// <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)
{
PendingIo = true;
_edit._models.ImportGltf(inputPath)
.ContinueWith(task =>
{
IoException = task.Exception?.ToString();
if (task is { IsCompletedSuccessfully: true, Result: not null })
{
Initialize(task.Result);
_dirty = true;
}
PendingIo = false;
});
}
/// <summary> Read a .sklb from the active collection or game. </summary> /// <summary> Read a .sklb from the active collection or game. </summary>
/// <param name="sklbPath"> Game path to the .sklb to load. </param> /// <param name="sklbPath"> Game path to the .sklb to load. </param>

View file

@ -15,7 +15,7 @@ public partial class ModEditWindow
private const int MdlMaterialMaximum = 4; private const int MdlMaterialMaximum = 4;
private readonly FileEditor<MdlTab> _modelTab; private readonly FileEditor<MdlTab> _modelTab;
private readonly ModelManager _models; private readonly ModelManager _models;
private string _modelNewMaterial = string.Empty; private string _modelNewMaterial = string.Empty;
private readonly List<TagButtons> _subMeshAttributeTagWidgets = []; private readonly List<TagButtons> _subMeshAttributeTagWidgets = [];
@ -35,9 +35,9 @@ public partial class ModEditWindow
); );
} }
DrawExport(tab, disabled); DrawImportExport(tab, disabled);
var ret = false; var ret = tab.Dirty;
ret |= DrawModelMaterialDetails(tab, disabled); ret |= DrawModelMaterialDetails(tab, disabled);
@ -50,11 +50,53 @@ public partial class ModEditWindow
return !disabled && ret; return !disabled && ret;
} }
private void DrawExport(MdlTab tab, bool disabled) private void DrawImportExport(MdlTab tab, bool disabled)
{ {
if (!ImGui.CollapsingHeader("Export")) if (!ImGui.CollapsingHeader("Import / Export"))
return; return;
var childSize = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X) / 2, 0);
DrawImport(tab, childSize, disabled);
ImGui.SameLine();
DrawExport(tab, childSize, disabled);
if (tab.IoException != null)
ImGuiUtil.TextWrapped(tab.IoException);
}
private void DrawImport(MdlTab tab, Vector2 size, bool _1)
{
_dragDropManager.CreateImGuiSource("ModelDragDrop",
m => m.Extensions.Any(e => ValidModelExtensions.Contains(e.ToLowerInvariant())), m =>
{
if (!GetFirstModel(m.Files, out var file))
return false;
ImGui.TextUnformatted($"Dragging model for editing: {Path.GetFileName(file)}");
return true;
});
using (var frame = ImRaii.FramedGroup("Import", size))
{
if (ImGuiUtil.DrawDisabledButton("Import from glTF", Vector2.Zero, "Imports a glTF file, overriding the content of this mdl.",
tab.PendingIo))
_fileDialog.OpenFilePicker("Load model from glTF.", "glTF{.gltf,.glb}", (success, paths) =>
{
if (success && paths.Count > 0)
tab.Import(paths[0]);
}, 1, _mod!.ModPath.FullName, false);
ImGui.Dummy(new Vector2(ImGui.GetFrameHeight()));
}
if (_dragDropManager.CreateImGuiTarget("ModelDragDrop", out var files, out _) && GetFirstModel(files, out var file))
tab.Import(file);
}
private void DrawExport(MdlTab tab, Vector2 size, bool _)
{
using var frame = ImRaii.FramedGroup("Export", size);
if (tab.GamePaths == null) if (tab.GamePaths == null)
{ {
if (tab.IoException == null) if (tab.IoException == null)
@ -83,9 +125,6 @@ public partial class ModEditWindow
_mod!.ModPath.FullName, _mod!.ModPath.FullName,
false false
); );
if (tab.IoException != null)
ImGuiUtil.TextWrapped(tab.IoException);
} }
private void DrawGamePathCombo(MdlTab tab) private void DrawGamePathCombo(MdlTab tab)
@ -110,7 +149,7 @@ public partial class ModEditWindow
const string label = "Game Path"; const string label = "Game Path";
var preview = tab.GamePaths![tab.GamePathIndex].ToString(); var preview = tab.GamePaths![tab.GamePathIndex].ToString();
var labelWidth = ImGui.CalcTextSize(label).X + ImGui.GetStyle().ItemInnerSpacing.X; var labelWidth = ImGui.CalcTextSize(label).X + ImGui.GetStyle().ItemInnerSpacing.X;
var buttonWidth = ImGui.GetContentRegionAvail().X - labelWidth; var buttonWidth = ImGui.GetContentRegionAvail().X - labelWidth - ImGui.GetStyle().ItemSpacing.X;
if (tab.GamePaths!.Count == 1) if (tab.GamePaths!.Count == 1)
{ {
using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f));
@ -404,4 +443,15 @@ public partial class ModEditWindow
return false; return false;
} }
private static bool GetFirstModel(IEnumerable<string> files, [NotNullWhen(true)] out string? file)
{
file = files.FirstOrDefault(f => ValidModelExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()));
return file != null;
}
private static readonly string[] ValidModelExtensions =
[
".gltf",
];
} }

View file

@ -49,7 +49,7 @@ public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _edito
ImGui.TableNextColumn(); ImGui.TableNextColumn();
var icon = (path is ModFileSystem.Leaf ? FontAwesomeIcon.FileCircleMinus : FontAwesomeIcon.FolderMinus).ToIconString(); var icon = (path is ModFileSystem.Leaf ? FontAwesomeIcon.FileCircleMinus : FontAwesomeIcon.FolderMinus).ToIconString();
if (ImGuiUtil.DrawDisabledButton(icon, new Vector2(sizeType), "Remove from selection.", false, true)) if (ImGuiUtil.DrawDisabledButton(icon, new Vector2(sizeType), "Remove from selection.", false, true))
_selector.RemovePathFromMultiselection(path); _selector.RemovePathFromMultiSelection(path);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();