#![warn(missing_docs)]
mod capability;
pub use capability::{
FbxBindMatrixProvenance, FbxCoordinateAxis, FbxCoordinateNormalization,
FbxScaleCapabilityInventory, FbxScaleDomainInventory, FbxScaleDomainStatus, FbxScaleSource,
FbxSourceIdentity, capability_facts,
};
use animsmith_core::model::{
Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
SourceSkinAttachment, TextureAsset, Track, TrackValues, Transform,
};
use capability::AssetConversionFacts;
use glam::{Mat4, Quat, Vec3};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
#[error("path is not valid UTF-8: {0}")]
Path(String),
#[error("FBX parse error: {0}")]
Fbx(String),
#[error("animation bake failed for take {take:?}: {message}")]
Bake {
take: String,
message: String,
},
}
fn vec3(v: ufbx::Vec3) -> Vec3 {
Vec3::new(v.x as f32, v.y as f32, v.z as f32)
}
fn quat(q: ufbx::Quat) -> Quat {
Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
}
fn transform(t: &ufbx::Transform) -> Transform {
Transform {
translation: vec3(t.translation),
rotation: quat(t.rotation),
scale: vec3(t.scale),
}
}
fn mat4(m: &ufbx::Matrix) -> Mat4 {
Mat4::from_cols_array(&[
m.m00 as f32,
m.m10 as f32,
m.m20 as f32,
0.0,
m.m01 as f32,
m.m11 as f32,
m.m21 as f32,
0.0,
m.m02 as f32,
m.m12 as f32,
m.m22 as f32,
0.0,
m.m03 as f32,
m.m13 as f32,
m.m23 as f32,
1.0,
])
}
fn project_cluster_bind(cluster: &ufbx::SkinCluster) -> Option<(Mat4, Mat4)> {
cluster.bone_node.as_ref()?;
let bind_to_world = mat4(&cluster.bind_to_world);
let geometry_to_world = mat4(&cluster.geometry_to_world);
if !bind_to_world.is_finite() || !geometry_to_world.is_finite() {
return None;
}
let bone_inverse = bind_to_world.inverse();
let instance_inverse = bone_inverse * geometry_to_world;
(bone_inverse.is_finite() && instance_inverse.is_finite())
.then_some((bone_inverse, instance_inverse))
}
pub fn load(path: &Path) -> Result<Document, LoadError> {
Ok(load_scale_source(path)?.into_document())
}
pub fn load_scale_source(path: &Path) -> Result<FbxScaleSource, LoadError> {
path.to_str()
.ok_or_else(|| LoadError::Path(path.display().to_string()))?;
let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
load_scale_source_bytes(path, &bytes)
}
pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
Ok(load_scale_source_bytes(path, bytes)?.into_document())
}
pub fn load_scale_source_bytes(path: &Path, bytes: &[u8]) -> Result<FbxScaleSource, LoadError> {
let filename = path
.to_str()
.ok_or_else(|| LoadError::Path(path.display().to_string()))?;
let opts = ufbx::LoadOpts {
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
target_unit_meters: 1.0,
space_conversion: ufbx::SpaceConversion::AdjustTransforms,
geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
generate_missing_normals: true,
filename: filename.into(),
..Default::default()
};
let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
for node in &scene.nodes {
let name = if node.element.name.is_empty() {
if node.is_root {
"<fbx-root>".to_string()
} else {
format!("node{}", node.element.typed_id)
}
} else {
node.element.name.to_string()
};
bones.push(Bone {
name,
parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
rest: transform(&node.local_transform),
inverse_bind: None,
});
}
for cluster in &scene.skin_clusters {
if let (Some(bone_node), Some((bone_inverse, _))) =
(&cluster.bone_node, project_cluster_bind(cluster))
{
let id = bone_node.element.typed_id as usize;
if id < bones.len() {
bones[id].inverse_bind = Some(bone_inverse);
}
}
}
let mut clips = Vec::new();
for (index, stack) in scene.anim_stacks.iter().enumerate() {
let take = if stack.element.name.is_empty() {
format!("take{index}")
} else {
stack.element.name.to_string()
};
let baked = ufbx::bake_anim(
&scene,
&stack.anim,
ufbx::BakeOpts {
trim_start_time: true,
..Default::default()
},
)
.map_err(|e| LoadError::Bake {
take: take.clone(),
message: format!("{e:?}"),
})?;
let mut tracks = Vec::new();
let mut duration = 0.0f64;
for node in &baked.nodes {
let bone = node.typed_id as usize;
if !node.translation_keys.is_empty() {
let times: Vec<f32> = node
.translation_keys
.iter()
.map(|k| k.time as f32)
.collect();
let values: Vec<Vec3> = node
.translation_keys
.iter()
.map(|k| vec3(k.value))
.collect();
duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
tracks.push(Track {
bone,
property: Property::Translation,
interpolation: Interpolation::Linear,
times,
values: TrackValues::Vec3s(values),
});
}
if !node.rotation_keys.is_empty() {
let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
tracks.push(Track {
bone,
property: Property::Rotation,
interpolation: Interpolation::Linear,
times,
values: TrackValues::Quats(values),
});
}
if !node.scale_keys.is_empty() {
let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
tracks.push(Track {
bone,
property: Property::Scale,
interpolation: Interpolation::Linear,
times,
values: TrackValues::Vec3s(values),
});
}
}
clips.push(Clip {
name: take,
duration_s: duration,
tracks,
});
}
let (assets, conversion) = extract_assets(&scene, path.parent());
let inventory = capability::inventory(&scene, &conversion);
Ok(FbxScaleSource {
document: Document {
skeleton: Skeleton { bones },
clips,
assets,
source: SourceInfo {
path: Some(path.display().to_string()),
format: Some("fbx".into()),
},
},
inventory,
})
}
fn texture_asset(texture: &ufbx::Texture, base_dir: Option<&Path>) -> Option<TextureAsset> {
let bytes: Vec<u8> = if !texture.content.is_empty() {
texture.content.to_vec()
} else {
let mut found = None;
for candidate in [
texture.absolute_filename.as_ref(),
texture.relative_filename.as_ref(),
texture.filename.as_ref(),
] {
if candidate.is_empty() {
continue;
}
let direct = Path::new(candidate);
let path = if direct.is_absolute() {
direct.to_path_buf()
} else {
base_dir.unwrap_or(Path::new(".")).join(direct)
};
if let Ok(data) = std::fs::read(&path) {
found = Some(data);
break;
}
}
found?
};
let mime = match bytes.get(..3) {
Some([0x89, b'P', b'N']) => "image/png",
Some([0xFF, 0xD8, _]) => "image/jpeg",
_ => return None,
};
Some(TextureAsset {
bytes,
mime: mime.into(),
})
}
fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
let texture = material.pbr.base_color.texture.as_ref().or(material
.fbx
.diffuse_color
.texture
.as_ref())?;
texture_asset(texture, base_dir)
}
fn normal_texture(
material: &ufbx::Material,
base_dir: Option<&Path>,
) -> Option<NormalTextureAsset> {
let texture = material.pbr.normal_map.texture.as_ref().or(material
.fbx
.normal_map
.texture
.as_ref())?;
texture_asset(texture, base_dir).map(|texture| NormalTextureAsset {
texture,
scale: 1.0,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ProjectedInfluence {
Absent,
Retained(u16, f32),
Rejected,
}
fn project_skin_influence(
source_weight: f64,
cluster_index: Option<usize>,
cluster_count: usize,
cluster_has_bone: bool,
) -> ProjectedInfluence {
let weight = source_weight as f32;
if !source_weight.is_finite()
|| source_weight < 0.0
|| !weight.is_finite()
|| (source_weight > 0.0 && weight == 0.0)
{
return ProjectedInfluence::Rejected;
}
if weight == 0.0 {
return ProjectedInfluence::Absent;
}
let Some(cluster_index) = cluster_index else {
return ProjectedInfluence::Rejected;
};
if cluster_index >= cluster_count || !cluster_has_bone {
return ProjectedInfluence::Rejected;
}
match u16::try_from(cluster_index) {
Ok(index) => ProjectedInfluence::Retained(index, weight),
Err(_) => ProjectedInfluence::Rejected,
}
}
fn extract_source_skeleton(scene: &ufbx::Scene) -> SourceSkeletonAssets {
let nodes = scene
.nodes
.iter()
.map(|node| {
let mut source = SourceNodeAsset::new(
node.element.typed_id as usize,
SourceNodeLocalRest::Trs {
translation: vec3(node.local_transform.translation),
rotation: quat(node.local_transform.rotation),
scale: vec3(node.local_transform.scale),
},
);
source.name = (!node.element.name.is_empty()).then(|| node.element.name.to_string());
source.parent_source_node_index = node
.parent
.as_ref()
.map(|parent| parent.element.typed_id as usize);
source.scene_root_indices = if node.is_root { vec![0] } else { Vec::new() };
source.bone = Some(node.element.typed_id as usize);
source
})
.collect();
if scene.skin_clusters.iter().any(|cluster| {
cluster.bone_node.as_ref().is_none_or(|bone| {
usize::try_from(bone.element.typed_id)
.ok()
.is_none_or(|index| index >= scene.nodes.len())
})
}) {
return SourceSkeletonAssets::default();
}
let mut attachments = vec![Vec::new(); scene.skin_deformers.len()];
for node in &scene.nodes {
let Some(mesh) = &node.mesh else { continue };
for skin in &mesh.skin_deformers {
let Some(for_skin) = attachments.get_mut(skin.element.typed_id as usize) else {
return SourceSkeletonAssets::default();
};
for_skin.push(SourceSkinAttachment {
source_node_index: node.element.typed_id as usize,
source_mesh_index: Some(mesh.element.typed_id as usize),
});
}
}
let skins = scene
.skin_deformers
.iter()
.map(|skin| {
let source_skin_index = skin.element.typed_id as usize;
let projected_matrices = skin
.clusters
.iter()
.map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
.collect::<Option<Vec<_>>>();
let (status, matrices) = match (skin.clusters.is_empty(), projected_matrices) {
(true, _) => (SourceInverseBindAccessorStatus::Absent, Vec::new()),
(false, Some(matrices)) => (SourceInverseBindAccessorStatus::Available, matrices),
(false, None) => (SourceInverseBindAccessorStatus::Unreadable, Vec::new()),
};
SourceSkinAsset {
source_skin_index,
name: (!skin.element.name.is_empty()).then(|| skin.element.name.to_string()),
skeleton_root_source_node_index: None,
joint_source_node_indices: skin
.clusters
.iter()
.filter_map(|cluster| {
cluster
.bone_node
.as_ref()
.map(|node| node.element.typed_id as usize)
})
.collect(),
inverse_bind_accessor: SourceInverseBindAccessor {
status,
declared_count: (!skin.clusters.is_empty()).then_some(skin.clusters.len()),
matrices,
},
attachments: attachments
.get_mut(source_skin_index)
.map(std::mem::take)
.unwrap_or_default(),
}
})
.collect();
SourceSkeletonAssets {
coverage: SourceSkeletonCoverage::Complete,
nodes,
skins,
}
}
fn extract_assets(
scene: &ufbx::Scene,
base_dir: Option<&Path>,
) -> (SceneAssets, AssetConversionFacts) {
let mut assets = SceneAssets::default();
let mut conversion = AssetConversionFacts::default();
let mut material_index: std::collections::BTreeMap<u32, usize> =
std::collections::BTreeMap::new();
let mut normalized_mesh_index_by_source = std::collections::BTreeMap::<u32, usize>::new();
for (source_node_index, node) in scene.nodes.iter().enumerate() {
let Some(mesh) = &node.mesh else { continue };
let node_id = node.element.typed_id as usize;
let local_materials: Vec<usize> = mesh
.materials
.iter()
.map(|m| {
*material_index
.entry(m.element.element_id)
.or_insert_with(|| {
let base = if m.pbr.base_color.has_value {
m.pbr.base_color.value_vec4
} else {
m.fbx.diffuse_color.value_vec4
};
let texture = base_color_texture(m, base_dir);
let normal_texture = normal_texture(m, base_dir);
assets.materials.push(MaterialAsset {
name: m.element.name.to_string(),
base_color: if texture.is_some() {
[1.0, 1.0, 1.0, 1.0]
} else {
[base.x as f32, base.y as f32, base.z as f32, base.w as f32]
},
metallic: if m.pbr.metalness.has_value {
m.pbr.metalness.value_vec4.x as f32
} else {
0.0
},
roughness: if m.pbr.roughness.has_value {
m.pbr.roughness.value_vec4.x as f32
} else {
1.0
},
base_color_texture: texture,
normal_texture,
metallic_roughness_texture: None,
occlusion_texture: None,
});
assets.materials.len() - 1
})
})
.collect();
let skin = mesh.skin_deformers.first();
let skin_joints: Vec<usize> = skin
.map(|s| {
s.clusters
.iter()
.map(|c| {
c.bone_node
.as_ref()
.map(|b| b.element.typed_id as usize)
.unwrap_or(0)
})
.collect()
})
.unwrap_or_default();
let skin_ibms: Vec<glam::Mat4> = skin
.and_then(|s| {
s.clusters
.iter()
.map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
.collect::<Option<Vec<_>>>()
})
.unwrap_or_default();
if let Some(&normalized_mesh_index) =
normalized_mesh_index_by_source.get(&mesh.element.typed_id)
{
assets.instances.push(MeshInstance {
source_node_index,
node: node_id,
mesh: normalized_mesh_index,
skin_joints,
skin_ibms,
});
continue;
}
let vertex_influences: Vec<Option<([u16; 4], [f32; 4])>> = skin
.map(|s| {
(0..mesh.num_vertices)
.map(|v| {
let mut pairs: Vec<(u16, f32)> = Vec::new();
if let Some(sv) = s.vertices.get(v) {
let begin = sv.weight_begin as usize;
let end = begin.saturating_add(sv.num_weights as usize);
for sw in s.weights.get(begin..end).unwrap_or_default() {
let source_weight = sw.weight;
let cluster_index = usize::try_from(sw.cluster_index).ok();
let cluster_has_bone = cluster_index
.and_then(|index| s.clusters.get(index))
.is_some_and(|cluster| cluster.bone_node.is_some());
match project_skin_influence(
source_weight,
cluster_index,
s.clusters.len(),
cluster_has_bone,
) {
ProjectedInfluence::Absent => {}
ProjectedInfluence::Retained(index, weight) => {
pairs.push((index, weight));
}
ProjectedInfluence::Rejected => {
conversion.rejected_influence_count += 1;
}
}
}
}
pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
if pairs.len() > 4 {
conversion.truncated_influence_vertex_count += 1;
conversion.discarded_influence_count += pairs.len() - 4;
}
pairs.truncate(4);
let total: f32 = pairs.iter().map(|p| p.1).sum();
if pairs.is_empty() || !total.is_finite() || total <= 0.0 {
return None;
}
let mut joints = [0u16; 4];
let mut weights = [0f32; 4];
let mut renormalized = false;
for (slot, (j, w)) in pairs.into_iter().enumerate() {
joints[slot] = j;
let normalized = if total > 0.0 { w / total } else { 0.0 };
renormalized |= normalized.to_bits() != w.to_bits();
weights[slot] = normalized;
}
if renormalized {
conversion.renormalized_influence_vertex_count += 1;
}
Some((joints, weights))
})
.collect()
})
.unwrap_or_default();
let slots = local_materials.len().max(1);
let mut primitives: Vec<Primitive> = (0..slots)
.map(|slot| Primitive {
material: local_materials.get(slot).copied(),
..Primitive::default()
})
.collect();
let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
for (face_index, &face) in mesh.faces.iter().enumerate() {
let slot = mesh
.face_material
.get(face_index)
.map(|&m| m as usize)
.filter(|&m| m < slots)
.unwrap_or(0);
let prim = &mut primitives[slot];
let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
for &corner in &tri_indices[..tris * 3] {
let corner = corner as usize;
let p = mesh.vertex_position[corner];
prim.positions
.push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
if mesh.vertex_normal.exists {
let n = mesh.vertex_normal[corner];
prim.normals
.push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
}
if mesh.vertex_uv.exists {
let uv = mesh.vertex_uv[corner];
prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
}
if !vertex_influences.is_empty() {
let vertex = mesh.vertex_indices[corner] as usize;
let (joints, weights) = vertex_influences
.get(vertex)
.copied()
.flatten()
.unwrap_or_else(|| {
conversion.missing_skin_influence_corner_count += 1;
([0; 4], [0.0; 4])
});
prim.joints.push(joints);
prim.weights.push(weights);
}
}
}
primitives.retain(|p| !p.positions.is_empty());
for prim in &mut primitives {
conversion.pre_weld_vertex_count += prim.positions.len();
prim.weld();
conversion.post_weld_vertex_count += prim.positions.len();
}
if primitives.is_empty() {
continue;
}
let normalized_mesh_index = assets.meshes.len();
let source_mesh_index = mesh.element.typed_id as usize;
normalized_mesh_index_by_source.insert(mesh.element.typed_id, normalized_mesh_index);
assets.meshes.push(MeshAsset {
name: mesh.element.name.to_string(),
source_mesh_index,
primitives,
});
assets.instances.push(MeshInstance {
source_node_index,
node: node_id,
mesh: normalized_mesh_index,
skin_joints,
skin_ibms,
});
}
assets.scenes.push(SceneAsset {
source_scene_index: 0,
name: None,
roots: scene
.nodes
.iter()
.filter(|node| node.is_root)
.map(|node| node.element.typed_id as usize)
.collect(),
});
assets.default_scene = Some(0);
assets.source_skeleton = extract_source_skeleton(scene);
(assets, conversion)
}
#[cfg(test)]
mod tests {
use super::{ProjectedInfluence, project_skin_influence};
#[test]
fn influence_projection_checks_sign_range_and_u16_cluster_narrowing() {
assert_eq!(
project_skin_influence(0.0, Some(0), 1, true),
ProjectedInfluence::Absent
);
assert_eq!(
project_skin_influence(-0.25, Some(0), 1, true),
ProjectedInfluence::Rejected
);
assert_eq!(
project_skin_influence(1.0, Some(1), 1, true),
ProjectedInfluence::Rejected,
"a source cluster index outside the declaration must not survive"
);
assert_eq!(
project_skin_influence(
1.0,
Some(usize::from(u16::MAX) + 1),
usize::from(u16::MAX) + 2,
true,
),
ProjectedInfluence::Rejected,
"u32/usize cluster identity must not wrap while narrowing to u16"
);
assert_eq!(
project_skin_influence(0.5, Some(7), 8, true),
ProjectedInfluence::Retained(7, 0.5)
);
}
}