#![warn(missing_docs)]
use animsmith_core::model::{
Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, Primitive, Property,
SceneAssets, Skeleton, SourceInfo, TextureAsset, Track, TrackValues, Transform,
};
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,
])
}
pub fn load(path: &Path) -> Result<Document, 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,
..Default::default()
};
let scene = ufbx::load_file(filename, 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) = &cluster.bone_node {
let id = bone_node.element.typed_id as usize;
if id < bones.len() {
bones[id].inverse_bind = Some(mat4(&cluster.bind_to_world).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 = extract_assets(&scene, path.parent());
Ok(Document {
skeleton: Skeleton { bones },
clips,
assets,
source: SourceInfo {
path: Some(path.display().to_string()),
format: Some("fbx".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())?;
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 extract_assets(scene: &ufbx::Scene, base_dir: Option<&Path>) -> SceneAssets {
let mut assets = SceneAssets::default();
let mut material_index: std::collections::BTreeMap<u32, usize> =
std::collections::BTreeMap::new();
for node in &scene.nodes {
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);
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,
});
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
.map(|s| {
s.clusters
.iter()
.map(|c| mat4(&c.bind_to_world).inverse() * mat4(&c.geometry_to_world))
.collect()
})
.unwrap_or_default();
let vertex_influences: Vec<([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) {
for w in 0..sv.num_weights as usize {
let sw = &s.weights[sv.weight_begin as usize + w];
pairs.push((sw.cluster_index as u16, sw.weight as f32));
}
}
pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
pairs.truncate(4);
let total: f32 = pairs.iter().map(|p| p.1).sum();
let mut joints = [0u16; 4];
let mut weights = [0f32; 4];
for (slot, (j, w)) in pairs.into_iter().enumerate() {
joints[slot] = j;
weights[slot] = if total > 0.0 { w / total } else { 0.0 };
}
(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[vertex];
prim.joints.push(joints);
prim.weights.push(weights);
}
}
}
primitives.retain(|p| !p.positions.is_empty());
for prim in &mut primitives {
prim.weld();
}
if primitives.is_empty() {
continue;
}
assets.meshes.push(MeshAsset {
name: mesh.element.name.to_string(),
node: node_id,
primitives,
skin_joints,
skin_ibms,
});
}
assets
}