Editor/WeaponImporter/Core/Import/FbxWeaponReader.cs
#nullable enable annotations
using System.Numerics;
using WeaponImporter.Core.Formats.Fbx;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Core.Import;
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// Reads an FBX weapon: skeleton and clips through <see cref="FbxImporter"/>, plus every mesh
/// in bind pose with its dominant bone, all converted to s&box model space.
/// </summary>
public static class FbxWeaponReader
{
public const int MaxTriangles = 600_000;
public static WeaponAsset Read(byte[] data, string name, string sourcePath = "", float sampleFps = 30f)
{
var tree = FbxTokenizer.Parse(data);
var scene = FbxScene.Build(tree);
var unit = (float)scene.UnitScaleFactor; // file units -> cm
SourceScene? imported = null;
var notes = new List<string>();
try
{
imported = FbxImporter.Import(data, new FbxImportOptions { SampleFps = sampleFps });
notes.AddRange(imported.Notes);
}
catch (FormatException e) when (e.Message.Contains("no skeleton", StringComparison.OrdinalIgnoreCase))
{
notes.Add("No skeleton in the file; the weapon is imported as a static prop.");
}
var conversion = imported is not null
? SpaceConversion.For(imported)
: new SpaceConversion(scene.UpAxis, scene.UpAxisSign, scene.FrontAxis, scene.FrontAxisSign, scene.CoordAxis, scene.CoordAxisSign);
var skeletonCm = imported?.Skeleton ?? WeaponAsset.SingleBone(name.Length > 0 ? "root" : "root");
var skeleton = conversion.Skeleton(skeletonCm);
var clips = imported is null ? new List<Clip>() : conversion.Clips(imported.Clips, skeletonCm, skeleton);
var links = tree.Child("Connections")?.ChildrenNamed("C")
.Where(n => n.Prop<string>(0) == "OO")
.Select(n => (Child: n.Prop<long>(1), Parent: n.Prop<long>(2)))
.ToArray() ?? Array.Empty<(long Child, long Parent)>();
IEnumerable<FbxObject> Children(long id) => links.Where(c => c.Parent == id && scene.ObjectsById.ContainsKey(c.Child)).Select(c => scene.ObjectsById[c.Child]);
IEnumerable<FbxObject> Parents(long id) => links.Where(c => c.Child == id && scene.ObjectsById.ContainsKey(c.Parent)).Select(c => scene.ObjectsById[c.Parent]);
Matrix4x4 World(FbxObject? node)
{
var m = Matrix4x4.Identity;
var seen = new HashSet<long>();
while (node is not null)
{
if (!seen.Add(node.Id))
throw new FormatException("FBX node hierarchy contains a cycle.");
m *= FbxTransform.FromModel(scene, node).LocalMatrixDefault();
node = node.ModelParent;
}
return m;
}
var materialReader = new FbxMaterialReader(tree, scene, sourcePath);
var positions = new List<Vector3>();
var vertexBone = new List<int>();
var indices = new List<int>();
var triPart = new List<int>();
var partNames = new List<string>();
var cornerNormals = new List<Vector3>();
var cornerUVs = new List<Vector2>();
var triMaterial = new List<int>();
var anyNormals = false;
var anyUVs = false;
var missingNormals = false;
foreach (var geometry in scene.ObjectsById.Values.Where(o => o.NodeType == "Geometry" && o.SubClass == "Mesh"))
{
var verts = geometry.Node.Child("Vertices")?.AsDoubleArray(0);
var polys = geometry.Node.Child("PolygonVertexIndex")?.AsIntArray(0);
if (verts is null || polys is null || verts.Length < 9 || verts.Length % 3 != 0)
continue;
var owner = Parents(geometry.Id).FirstOrDefault(o => o.NodeType == "Model");
if (owner is null)
continue;
var count = verts.Length / 3;
var geometric = Matrix4x4.CreateScale(scene.GetVector3(owner, "GeometricScaling", Vector3.One))
* Matrix4x4.CreateFromQuaternion(FbxTransform.EulerDegreesToQuaternion(scene.GetVector3(owner, "GeometricRotation", Vector3.Zero), 0))
* Matrix4x4.CreateTranslation(scene.GetVector3(owner, "GeometricTranslation", Vector3.Zero));
var model = new Vector3[count];
var bone = new int[count];
var bestWeight = new double[count];
Array.Fill(bone, -1);
// Normals follow the same matrices as positions: file-space linear part (inverse
// transpose, for non-uniform scale) then the axis conversion then the rest rotation.
var normalMatrix = new int[count];
var normalRotation = new Quaternion[count];
var normalMatrices = new List<Matrix4x4>();
// Skinned: place each vertex relative to its strongest bone's rest pose. Exporters
// do not always normalise weights (a vertex can be 1.0 on a "Control" bone and 1.0
// on its part bone); ties go to the more specific bone, the one with fewer vertices.
var skinned = false;
var tieSize = new int[count];
Array.Fill(tieSize, int.MaxValue);
foreach (var skin in Children(geometry.Id).Where(o => o.NodeType == "Deformer" && o.SubClass == "Skin"))
{
foreach (var cluster in Children(skin.Id).Where(o => o.SubClass == "Cluster"))
{
var link = Children(cluster.Id).FirstOrDefault(o => o.NodeType == "Model");
var idx = cluster.Node.Child("Indexes")?.AsIntArray(0);
var wts = cluster.Node.Child("Weights")?.AsDoubleArray(0);
if (link is null || idx is null || wts is null || idx.Length != wts.Length)
continue;
var boneIndex = skeleton.IndexOf(link.Name);
if (boneIndex < 0)
continue;
var meshToBone = ReadMatrix(cluster.Node.Child("Transform"));
var linkMatrix = ReadMatrix(cluster.Node.Child("TransformLink"));
if (meshToBone is null || linkMatrix is null)
continue;
Matrix4x4.Decompose(linkMatrix.Value, out var bindScale, out _, out _);
var local = geometric * meshToBone.Value * Matrix4x4.CreateScale(bindScale);
var rest = skeleton.RestWorld[boneIndex];
var clusterNormal = -1;
for (var i = 0; i < idx.Length; i++)
{
var v = idx[i];
if ((uint)v >= (uint)count || !(wts[i] > 1e-6))
continue;
var heavier = wts[i] > bestWeight[v] + 1e-4;
var tie = Math.Abs(wts[i] - bestWeight[v]) <= 1e-4 && idx.Length < tieSize[v];
if (!heavier && !tie)
continue;
bestWeight[v] = wts[i];
tieSize[v] = idx.Length;
bone[v] = boneIndex;
var p = Vector3.Transform(new Vector3((float)verts[v * 3], (float)verts[v * 3 + 1], (float)verts[v * 3 + 2]), local) * unit;
model[v] = rest.TransformPoint(conversion.Direction(p) * conversion.Scale);
if (clusterNormal < 0)
{
clusterNormal = normalMatrices.Count;
normalMatrices.Add(NormalMatrix(local));
}
normalMatrix[v] = clusterNormal;
normalRotation[v] = rest.Rot;
}
skinned = true;
}
}
// Rigid: relative to the nearest skeleton bone above the mesh node.
var rigidBone = -1;
FbxObject? anchor = owner;
while (anchor is not null && skeleton.IndexOf(anchor.Name) < 0)
anchor = anchor.ModelParent;
if (anchor is not null)
rigidBone = skeleton.IndexOf(anchor.Name);
var toAnchor = anchor is null
? geometric * World(owner)
: Matrix4x4.Invert(World(anchor), out var invAnchor) ? geometric * World(owner) * invAnchor : geometric;
if (anchor is not null)
{
// Keep the anchor's own scale (rigid rest transforms drop it).
Matrix4x4.Decompose(World(anchor), out var anchorScale, out _, out _);
toAnchor *= Matrix4x4.CreateScale(anchorScale);
}
var rigidNormal = normalMatrices.Count;
normalMatrices.Add(NormalMatrix(toAnchor));
var rigidRotation = rigidBone >= 0 ? skeleton.RestWorld[rigidBone].Rot : Quaternion.Identity;
for (var v = 0; v < count; v++)
{
if (skinned && bone[v] >= 0)
continue;
var p = Vector3.Transform(new Vector3((float)verts[v * 3], (float)verts[v * 3 + 1], (float)verts[v * 3 + 2]), toAnchor) * unit;
var q = conversion.Direction(p) * conversion.Scale;
model[v] = rigidBone >= 0 ? skeleton.RestWorld[rigidBone].TransformPoint(q) : q;
bone[v] = rigidBone >= 0 ? rigidBone : 0;
normalMatrix[v] = rigidNormal;
normalRotation[v] = rigidRotation;
}
var baseVertex = positions.Count;
positions.AddRange(model);
vertexBone.AddRange(bone);
var part = partNames.Count;
partNames.Add(owner.Name);
// Render attributes.
var normalLayer = FbxLayer.Read(geometry.Node, "LayerElementNormal", "Normals", new[] { "NormalsIndex", "NormalIndex" }, 3);
var uvLayer = FbxLayer.Read(geometry.Node, "LayerElementUV", "UV", new[] { "UVIndex" }, 2);
var materialLayer = FbxLayer.ReadMaterials(geometry.Node);
var modelMaterials = materialReader.MaterialsOf(owner);
if (normalLayer is not null)
anyNormals = true;
else
missingNormals = true;
if (uvLayer is not null)
anyUVs = true;
Vector3 CornerNormal(int pvi, int v, int polygon)
{
if (normalLayer?.Get(pvi, v, polygon) is not { } raw)
return new Vector3(float.NaN);
var n = new Vector3((float)raw[0], (float)raw[1], (float)raw[2]);
n = Vector3.Transform(conversion.Direction(Vector3.TransformNormal(n, normalMatrices[normalMatrix[v]])), normalRotation[v]);
var len = n.Length();
return len > 1e-12f && float.IsFinite(len) ? n / len : new Vector3(float.NaN);
}
Vector2 CornerUV(int pvi, int v, int polygon)
{
if (uvLayer?.Get(pvi, v, polygon) is not { } raw)
return Vector2.Zero;
// FBX UVs have a bottom-left origin; TriMesh stores top-left.
return new Vector2((float)raw[0], 1f - (float)raw[1]);
}
int PolygonMaterial(int polygon)
{
if (modelMaterials.Length == 0)
return materialReader.DefaultMaterial();
var local = materialLayer?.Get(polygon) ?? 0;
return (uint)local < (uint)modelMaterials.Length ? modelMaterials[local] : modelMaterials[0];
}
var face = new List<int>();
var facePvi = new List<int>();
var polygonIndex = 0;
for (var pvi = 0; pvi < polys.Length; pvi++)
{
var raw = polys[pvi];
var v = raw < 0 ? ~raw : raw;
if ((uint)v >= (uint)count)
throw new FormatException($"Mesh '{owner.Name}' has an out-of-range polygon index.");
face.Add(v);
facePvi.Add(pvi);
if (raw >= 0)
continue;
var material = face.Count >= 3 ? PolygonMaterial(polygonIndex) : 0;
for (var k = 1; k + 1 < face.Count; k++)
{
foreach (var corner in new[] { 0, k, k + 1 })
{
indices.Add(baseVertex + face[corner]);
cornerNormals.Add(CornerNormal(facePvi[corner], face[corner], polygonIndex));
cornerUVs.Add(CornerUV(facePvi[corner], face[corner], polygonIndex));
}
triPart.Add(part);
triMaterial.Add(material);
}
face.Clear();
facePvi.Clear();
polygonIndex++;
if (indices.Count / 3 > MaxTriangles)
throw new FormatException($"The model has more than {MaxTriangles:N0} triangles; decimate it before importing.");
}
}
if (indices.Count == 0)
throw new FormatException("The FBX contains no mesh geometry.");
var materials = materialReader.Materials.Count > 0 ? materialReader.Materials : new List<MaterialInfo> { MaterialInfo.Default };
Vector3[]? normalsArray = null;
if (anyNormals)
{
normalsArray = cornerNormals.ToArray();
if (missingNormals || normalsArray.Any(n => float.IsNaN(n.X)))
{
// Some meshes (or corners) had no normals: fill them with computed smooth normals.
var smooth = new TriMesh(positions.ToArray(), indices.ToArray()).EnsureNormals();
for (var c = 0; c < normalsArray.Length; c++)
if (float.IsNaN(normalsArray[c].X))
normalsArray[c] = smooth[c];
}
}
var asset = new WeaponAsset
{
Name = name,
Kind = SourceKind.Fbx,
SourcePath = sourcePath,
Skeleton = skeleton,
Mesh = new TriMesh(positions.ToArray(), indices.ToArray(), vertexBone.ToArray(), triPart.ToArray(), partNames,
normalsArray, anyUVs ? cornerUVs.ToArray() : null, triMaterial.ToArray(), materials),
Clips = clips,
};
asset.Notes.AddRange(notes);
asset.Notes.AddRange(materialReader.Notes.Distinct().Take(20));
return asset;
}
/// <summary>Row-vector normal matrix: inverse transpose of the linear part (falls back to the matrix itself when singular).</summary>
private static Matrix4x4 NormalMatrix(Matrix4x4 m)
{
m.M41 = m.M42 = m.M43 = 0f;
return Matrix4x4.Invert(m, out var inv) ? Matrix4x4.Transpose(inv) : m;
}
private static Matrix4x4? ReadMatrix(FbxNode? node)
{
var a = node?.AsDoubleArray(0);
if (a is null || a.Length != 16 || a.Any(x => !double.IsFinite(x)))
return null;
return new Matrix4x4(
(float)a[0], (float)a[1], (float)a[2], (float)a[3],
(float)a[4], (float)a[5], (float)a[6], (float)a[7],
(float)a[8], (float)a[9], (float)a[10], (float)a[11],
(float)a[12], (float)a[13], (float)a[14], (float)a[15]);
}
}