Import all primitives of a mesh

This commit is contained in:
ackwell 2024-01-24 01:54:56 +11:00
parent 8487661bc8
commit b2bd31a166
5 changed files with 341 additions and 218 deletions

View file

@ -1,4 +1,5 @@
using Lumina.Data.Parsing;
using OtterGui;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
@ -117,21 +118,19 @@ public class MeshImporter(IEnumerable<Node> nodes, IoNotifier notifier)
var subMeshName = node.Name ?? node.Mesh.Name;
var nodeBoneMap = CreateNodeBoneMap(node);
var subMesh = SubMeshImporter.Import(node, nodeBoneMap, notifier.WithContext($"Sub-mesh {subMeshName}"));
var subNotifier = notifier.WithContext($"Sub-mesh {subMeshName}");
var nodeBoneMap = CreateNodeBoneMap(node, subNotifier);
var subMesh = SubMeshImporter.Import(node, nodeBoneMap, subNotifier);
// 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;
if (subMesh.Material != null && _material != subMesh.Material)
notifier.Warning($"Meshes may only reference one material. Sub-mesh {subMeshName} material \"{subMesh.Material}\" has been ignored.");
// 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 notifier.Exception(
$@"All sub-meshes of a mesh must have equivalent vertex declarations.
Current: {FormatVertexDeclaration(_vertexDeclaration.Value)}
Sub-mesh ""{subMeshName}"": {FormatVertexDeclaration(subMesh.VertexDeclaration)}"
);
else
Utility.EnsureVertexDeclarationMatch(_vertexDeclaration.Value, subMesh.VertexDeclaration, notifier);
// 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.
@ -173,27 +172,7 @@ public class MeshImporter(IEnumerable<Node> nodes, IoNotifier notifier)
});
}
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;
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)
private Dictionary<ushort, ushort>? CreateNodeBoneMap(Node node, IoNotifier notifier)
{
// Unskinned assets can skip this all of this.
if (node.Skin == null)
@ -205,32 +184,27 @@ public class MeshImporter(IEnumerable<Node> nodes, IoNotifier notifier)
.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 notifier.Exception($"Mesh \"{meshName}\" has {primitiveCount} primitives, expected 1.");
var usedJoints = new HashSet<ushort>();
var primitive = mesh.Primitives[0];
// Per glTF specification, an asset with a skin MUST contain skinning attributes on its mesh.
foreach (var (primitive, primitiveIndex) in node.Mesh.Primitives.WithIndex())
{
// Per glTF specification, an asset with a skin MUST contain skinning attributes on its meshes.
var jointsAccessor = primitive.GetVertexAccessor("JOINTS_0")
?? throw notifier.Exception($"Mesh \"{meshName}\" is skinned but does not contain skinning vertex attributes.");
?? throw notifier.Exception($"Primitive {primitiveIndex} 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.
// Build a dictionary of node-specific joint indices mapped to mesh-wide bone indices.
var nodeBoneMap = new Dictionary<ushort, ushort>();
foreach (var usedJoint in usedJoints)
{

View file

@ -0,0 +1,210 @@
using Lumina.Data.Parsing;
using OtterGui;
using SharpGLTF.Schema2;
namespace Penumbra.Import.Models.Import;
public class PrimitiveImporter
{
public struct Primitive
{
public string? Material;
public MdlStructs.VertexDeclarationStruct VertexDeclaration;
public ushort VertexCount;
public byte[] Strides;
public List<byte>[] Streams;
public ushort[] Indices;
public BoundingBox BoundingBox;
public List<List<MdlStructs.ShapeValueStruct>> ShapeValues;
}
public static Primitive Import(MeshPrimitive primitive, IDictionary<ushort, ushort>? nodeBoneMap, IoNotifier notifier)
{
var importer = new PrimitiveImporter(primitive, nodeBoneMap, notifier);
return importer.Create();
}
private readonly IoNotifier _notifier;
private readonly MeshPrimitive _primitive;
private readonly IDictionary<ushort, ushort>? _nodeBoneMap;
private ushort[]? _indices;
private List<VertexAttribute>? _vertexAttributes;
private ushort _vertexCount;
private byte[] _strides = [0, 0, 0];
private readonly List<byte>[] _streams = [[], [], []];
private BoundingBox _boundingBox = new BoundingBox();
private List<List<MdlStructs.ShapeValueStruct>>? _shapeValues;
private PrimitiveImporter(MeshPrimitive primitive, IDictionary<ushort, ushort>? nodeBoneMap, IoNotifier notifier)
{
_notifier = notifier;
_primitive = primitive;
_nodeBoneMap = nodeBoneMap;
}
private Primitive Create()
{
// TODO: This structure is verging on a little silly. Reconsider.
BuildIndices();
BuildVertexAttributes();
BuildVertices();
BuildBoundingBox();
ArgumentNullException.ThrowIfNull(_vertexAttributes);
ArgumentNullException.ThrowIfNull(_indices);
ArgumentNullException.ThrowIfNull(_shapeValues);
var material = _primitive.Material.Name;
if (material == "")
material = null;
return new Primitive
{
Material = material,
VertexDeclaration = new MdlStructs.VertexDeclarationStruct
{
VertexElements = _vertexAttributes.Select(attribute => attribute.Element).ToArray(),
},
VertexCount = _vertexCount,
Strides = _strides,
Streams = _streams,
Indices = _indices,
BoundingBox = _boundingBox,
ShapeValues = _shapeValues,
};
}
private void BuildIndices()
{
// TODO: glTF supports a bunch of primitive types, ref. Schema2.PrimitiveType. All this code is currently assuming that it's using plain triangles (4). It should probably be generalised to other formats - I _suspect_ we should be able to get away with evaluating the indices to triangles with GetTriangleIndices, but will need investigation.
_indices = _primitive.GetIndices().Select(idx => (ushort)idx).ToArray();
}
private void BuildVertexAttributes()
{
// Tangent calculation requires indices if missing.
ArgumentNullException.ThrowIfNull(_indices);
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, _notifier),
VertexAttribute.BlendWeight(accessors, _notifier),
VertexAttribute.BlendIndex(accessors, _nodeBoneMap, _notifier),
VertexAttribute.Normal(accessors, morphAccessors),
VertexAttribute.Tangent1(accessors, morphAccessors, _indices, _notifier),
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;
}
_vertexAttributes = attributes;
// After building the attributes, the resulting next offsets are our stream strides.
_strides = offsets;
}
private void BuildVertices()
{
ArgumentNullException.ThrowIfNull(_vertexAttributes);
// 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 _vertexAttributes)
_streams[attribute.Stream].AddRange(attribute.Build(vertexIndex));
// Record which morph targets have values for this vertex, if any.
var changedMorphs = morphModifiedVertices
.WithIndex()
.Where(pair => _vertexAttributes.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(_vertexAttributes);
var morphShapeValues = new List<List<MdlStructs.ShapeValueStruct>>();
foreach (var (modifiedVertices, morphIndex) in morphModifiedVertices.WithIndex())
{
// 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 _vertexAttributes)
_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++;
}
morphShapeValues.Add(shapeValues);
}
_shapeValues = morphShapeValues;
}
private void BuildBoundingBox()
{
var positions = _primitive.VertexAccessors["POSITION"].AsVector3Array();
foreach (var position in positions)
_boundingBox.Merge(position);
}
}

View file

@ -19,7 +19,7 @@ public class SubMeshImporter
public byte[] Strides;
public List<byte>[] Streams;
public ushort[] Indices;
public List<ushort> Indices;
public BoundingBox BoundingBox;
@ -36,85 +36,56 @@ public class SubMeshImporter
private readonly IoNotifier _notifier;
private readonly MeshPrimitive _primitive;
private readonly Node _node;
private readonly IDictionary<ushort, ushort>? _nodeBoneMap;
private readonly IDictionary<string, JsonElement>? _nodeExtras;
private List<VertexAttribute>? _vertexAttributes;
private string? _material;
private MdlStructs.VertexDeclarationStruct? _vertexDeclaration;
private ushort _vertexCount;
private byte[] _strides = [0, 0, 0];
private readonly List<byte>[] _streams;
private byte[]? _strides;
private readonly List<byte>[] _streams = [[], [], []];
private ushort[]? _indices;
private List<ushort> _indices = [];
private BoundingBox _boundingBox = new BoundingBox();
private string[]? _metaAttributes;
private readonly List<string>? _morphNames;
private Dictionary<string, List<MdlStructs.ShapeValueStruct>>? _shapeValues;
private Dictionary<string, List<MdlStructs.ShapeValueStruct>> _shapeValues = [];
private SubMeshImporter(Node node, IDictionary<ushort, ushort>? nodeBoneMap, IoNotifier notifier)
{
_notifier = notifier;
var mesh = node.Mesh;
var primitiveCount = mesh.Primitives.Count;
if (primitiveCount != 1)
throw _notifier.Exception($"Mesh has {primitiveCount} primitives, expected 1.");
_primitive = mesh.Primitives[0];
_node = node;
_nodeBoneMap = nodeBoneMap;
try
{
_nodeExtras = node.Extras.Deserialize<Dictionary<string, JsonElement>>();
}
catch
{
_nodeExtras = null;
}
try
{
_morphNames = mesh.Extras.GetNode("targetNames").Deserialize<List<string>>();
_morphNames = node.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.
// TODO: This structure is verging on a little silly. Reconsider.
BuildIndices();
BuildVertexAttributes();
BuildVertices();
BuildBoundingBox();
BuildMetaAttributes();
foreach (var (primitive, index) in _node.Mesh.Primitives.WithIndex())
BuildPrimitive(primitive, index);
ArgumentNullException.ThrowIfNull(_indices);
ArgumentNullException.ThrowIfNull(_vertexAttributes);
ArgumentNullException.ThrowIfNull(_vertexDeclaration);
ArgumentNullException.ThrowIfNull(_strides);
ArgumentNullException.ThrowIfNull(_shapeValues);
ArgumentNullException.ThrowIfNull(_metaAttributes);
var material = _primitive.Material.Name;
if (material == "")
material = null;
var metaAttributes = BuildMetaAttributes();
// At this level, we assume that attributes are wholly controlled by this sub-mesh.
var attributeMask = _metaAttributes.Length switch
var attributeMask = metaAttributes.Length switch
{
< 32 => (1u << _metaAttributes.Length) - 1,
< 32 => (1u << metaAttributes.Length) - 1,
32 => uint.MaxValue,
> 32 => throw _notifier.Exception("Models may utilise a maximum of 32 attributes."),
};
@ -124,156 +95,92 @@ public class SubMeshImporter
SubMeshStruct = new MdlStructs.SubmeshStruct()
{
IndexOffset = 0,
IndexCount = (uint)_indices.Length,
IndexCount = (uint)_indices.Count,
AttributeIndexMask = attributeMask,
// TODO: Flesh these out. Game doesn't seem to rely on them existing, though.
BoneStartIndex = 0,
BoneCount = 0,
},
Material = material,
VertexDeclaration = new MdlStructs.VertexDeclarationStruct()
{
VertexElements = _vertexAttributes.Select(attribute => attribute.Element).ToArray(),
},
Material = _material,
VertexDeclaration = _vertexDeclaration.Value,
VertexCount = _vertexCount,
Strides = _strides,
Streams = _streams,
Indices = _indices,
BoundingBox = _boundingBox,
MetaAttributes = _metaAttributes,
MetaAttributes = metaAttributes,
ShapeValues = _shapeValues,
};
}
private void BuildIndices()
private void BuildPrimitive(MeshPrimitive meshPrimitive, int index)
{
// TODO: glTF supports a bunch of primitive types, ref. Schema2.PrimitiveType. All this code is currently assuming that it's using plain triangles (4). It should probably be generalised to other formats - I _suspect_ we should be able to get away with evaluating the indices to triangles with GetTriangleIndices, but will need investigation.
_indices = _primitive.GetIndices().Select(idx => (ushort)idx).ToArray();
}
var vertexOffset = _vertexCount;
var indexOffset = _indices.Count;
private void BuildVertexAttributes()
var primitive = PrimitiveImporter.Import(meshPrimitive, _nodeBoneMap, _notifier.WithContext($"Primitive {index}"));
// Material
_material ??= primitive.Material;
if (primitive.Material != null && _material != primitive.Material)
_notifier.Warning($"Meshes may only reference one material. Primitive {index} material \"{primitive.Material}\" has been ignored.");
// Vertex metadata
if (_vertexDeclaration == null)
_vertexDeclaration = primitive.VertexDeclaration;
else
Utility.EnsureVertexDeclarationMatch(_vertexDeclaration.Value, primitive.VertexDeclaration, _notifier);
_strides ??= primitive.Strides;
// Vertices
_vertexCount += primitive.VertexCount;
foreach (var (stream, primitiveStream) in _streams.Zip(primitive.Streams))
stream.AddRange(primitiveStream);
// Indices
_indices.AddRange(primitive.Indices.Select(index => (ushort)(index + vertexOffset)));
// Shape values
foreach (var (primitiveShapeValues, morphIndex) in primitive.ShapeValues.WithIndex())
{
// Tangent calculation requires indices if missing.
ArgumentNullException.ThrowIfNull(_indices);
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, _notifier),
VertexAttribute.BlendWeight(accessors, _notifier),
VertexAttribute.BlendIndex(accessors, _nodeBoneMap, _notifier),
VertexAttribute.Normal(accessors, morphAccessors),
VertexAttribute.Tangent1(accessors, morphAccessors, _indices, _notifier),
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;
}
_vertexAttributes = attributes;
// After building the attributes, the resulting next offsets are our stream strides.
_strides = offsets;
}
private void BuildVertices()
{
ArgumentNullException.ThrowIfNull(_vertexAttributes);
// 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 _vertexAttributes)
_streams[attribute.Stream].AddRange(attribute.Build(vertexIndex));
// Record which morph targets have values for this vertex, if any.
var changedMorphs = morphModifiedVertices
.WithIndex()
.Where(pair => _vertexAttributes.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(_vertexAttributes);
var morphShapeValues = new Dictionary<string, List<MdlStructs.ShapeValueStruct>>();
foreach (var (modifiedVertices, morphIndex) in morphModifiedVertices.WithIndex())
{
// 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 _vertexAttributes)
_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++;
}
// Per glTF spec, all primitives MUST have the same number of morph targets in the same order.
// As such, this lookup should be safe - a failure here is a broken glTF file.
var name = _morphNames != null ? _morphNames[morphIndex] : $"unnamed_shape_{morphIndex}";
morphShapeValues.Add(name, shapeValues);
}
_shapeValues = morphShapeValues;
}
private void BuildBoundingBox()
if (!_shapeValues.TryGetValue(name, out var subMeshShapeValues))
{
var positions = _primitive.VertexAccessors["POSITION"].AsVector3Array();
foreach (var position in positions)
_boundingBox.Merge(position);
subMeshShapeValues = [];
_shapeValues.Add(name, subMeshShapeValues);
}
private void BuildMetaAttributes()
subMeshShapeValues.AddRange(primitiveShapeValues.Select(value => value with
{
BaseIndicesIndex = (ushort)(value.BaseIndicesIndex + indexOffset),
ReplacingVertexIndex = (ushort)(value.ReplacingVertexIndex + vertexOffset),
}));
}
// Bounds
_boundingBox.Merge(primitive.BoundingBox);
}
private string[] BuildMetaAttributes()
{
Dictionary<string, JsonElement>? nodeExtras;
try
{
nodeExtras = _node.Extras.Deserialize<Dictionary<string, JsonElement>>();
}
catch
{
nodeExtras = null;
}
// We consider any "extras" key with a boolean value set to `true` to be an attribute.
_metaAttributes = _nodeExtras?
return nodeExtras?
.Where(pair => pair.Value.ValueKind == JsonValueKind.True)
.Select(pair => pair.Key)
.ToArray() ?? [];

View file

@ -1,3 +1,5 @@
using Lumina.Data.Parsing;
namespace Penumbra.Import.Models.Import;
public static class Utility
@ -31,4 +33,35 @@ public static class Utility
return newMask;
}
/// <summary> Ensures that the two vertex declarations provided are equal, throwing if not. </summary>
public static void EnsureVertexDeclarationMatch(MdlStructs.VertexDeclarationStruct current, MdlStructs.VertexDeclarationStruct @new, IoNotifier notifier)
{
if (VertexDeclarationMismatch(current, @new))
throw notifier.Exception(
$@"All sub-meshes of a mesh must have equivalent vertex declarations.
Current: {FormatVertexDeclaration(current)}
New: {FormatVertexDeclaration(@new)}"
);
}
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;
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
);
}
}

View file

@ -2,7 +2,6 @@ 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;