Add bevy_fbx, an fbx loader based on ufbx

This commit is contained in:
VitalyR 2025-06-09 02:13:54 +08:00
parent f1eace62f0
commit 4dc363c10b
13 changed files with 526 additions and 1 deletions

View File

@ -138,6 +138,7 @@ default = [
"bevy_gilrs",
"bevy_gizmos",
"bevy_gltf",
"bevy_fbx",
"bevy_input_focus",
"bevy_log",
"bevy_mesh_picking_backend",
@ -230,6 +231,8 @@ bevy_gilrs = ["bevy_internal/bevy_gilrs"]
# [glTF](https://www.khronos.org/gltf/) support
bevy_gltf = ["bevy_internal/bevy_gltf", "bevy_asset", "bevy_scene", "bevy_pbr"]
# [FBX](https://www.autodesk.com/products/fbx)
bevy_fbx = ["bevy_internal/bevy_fbx", "bevy_asset", "bevy_scene", "bevy_pbr", "bevy_animation"]
# Adds PBR rendering
bevy_pbr = [
@ -1196,6 +1199,17 @@ description = "Loads and renders a glTF file as a scene, including the gltf extr
category = "3D Rendering"
wasm = true
[[example]]
name = "load_fbx"
path = "examples/3d/load_fbx.rs"
doc-scrape-examples = true
[package.metadata.example.load_fbx]
name = "Load FBX"
description = "Loads and renders an FBX file as a scene"
category = "3D Rendering"
wasm = false
[[example]]
name = "query_gltf_primitives"
path = "examples/3d/query_gltf_primitives.rs"

BIN
assets/models/cube/cube.fbx Normal file

Binary file not shown.

View File

@ -0,0 +1,37 @@
[package]
name = "bevy_fbx"
version = "0.16.0-dev"
edition = "2024"
description = "Bevy Engine FBX loading"
homepage = "https://bevyengine.org"
repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0"
keywords = ["bevy"]
[dependencies]
bevy_app = { path = "../bevy_app", version = "0.16.0-dev" }
bevy_asset = { path = "../bevy_asset", version = "0.16.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev" }
bevy_scene = { path = "../bevy_scene", version = "0.16.0-dev", features = ["bevy_render"] }
bevy_render = { path = "../bevy_render", version = "0.16.0-dev" }
bevy_pbr = { path = "../bevy_pbr", version = "0.16.0-dev" }
bevy_mesh = { path = "../bevy_mesh", version = "0.16.0-dev" }
bevy_transform = { path = "../bevy_transform", version = "0.16.0-dev" }
bevy_math = { path = "../bevy_math", version = "0.16.0-dev" }
bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev" }
bevy_utils = { path = "../bevy_utils", version = "0.16.0-dev" }
bevy_platform = { path = "../bevy_platform", version = "0.16.0-dev", default-features = false, features = ["std"] }
bevy_animation = { path = "../bevy_animation", version = "0.16.0-dev" }
thiserror = "1"
tracing = { version = "0.1", default-features = false, features = ["std"] }
ufbx = "0.8"
[dev-dependencies]
bevy_log = { path = "../bevy_log", version = "0.16.0-dev" }
[lints]
workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"]
all-features = true

View File

@ -0,0 +1,5 @@
# Bevy FBX Loader
This crate provides basic support for importing FBX files into Bevy using the [`ufbx`](https://github.com/ufbx/ufbx-rust) library.
The loader converts meshes contained in an FBX scene into Bevy [`Mesh`] assets and groups them into a [`Scene`].

View File

@ -0,0 +1,41 @@
//! Labels that can be used to load part of an FBX asset
use bevy_asset::AssetPath;
/// Labels that can be used to load part of an FBX
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FbxAssetLabel {
/// `Scene{}`: FBX Scene as a Bevy [`Scene`](bevy_scene::Scene)
Scene(usize),
/// `Mesh{}`: FBX Mesh as a Bevy [`Mesh`](bevy_mesh::Mesh)
Mesh(usize),
/// `Material{}`: FBX material as a Bevy [`StandardMaterial`](bevy_pbr::StandardMaterial)
Material(usize),
/// `Animation{}`: FBX animation as a Bevy [`AnimationClip`](bevy_animation::AnimationClip)
Animation(usize),
/// `Skeleton{}`: FBX skeleton as a Bevy [`Skeleton`](crate::Skeleton)
Skeleton(usize),
/// `DefaultMaterial`: fallback material used when no material is present
DefaultMaterial,
}
impl core::fmt::Display for FbxAssetLabel {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FbxAssetLabel::Scene(index) => f.write_str(&format!("Scene{index}")),
FbxAssetLabel::Mesh(index) => f.write_str(&format!("Mesh{index}")),
FbxAssetLabel::Material(index) => f.write_str(&format!("Material{index}")),
FbxAssetLabel::Animation(index) => f.write_str(&format!("Animation{index}")),
FbxAssetLabel::Skeleton(index) => f.write_str(&format!("Skeleton{index}")),
FbxAssetLabel::DefaultMaterial => f.write_str("DefaultMaterial"),
}
}
}
impl FbxAssetLabel {
/// Add this label to an asset path
pub fn from_asset(&self, path: impl Into<AssetPath<'static>>) -> AssetPath<'static> {
path.into().with_label(self.to_string())
}
}

350
crates/bevy_fbx/src/lib.rs Normal file
View File

@ -0,0 +1,350 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://bevyengine.org/assets/icon.png",
html_favicon_url = "https://bevyengine.org/assets/icon.png"
)]
//!
//! Loader for FBX scenes using [`ufbx`](https://github.com/ufbx/ufbx-rust).
//! The implementation is intentionally minimal and focuses on importing
//! mesh geometry into Bevy.
use bevy_app::prelude::*;
use bevy_asset::{
io::Reader, Asset, AssetApp, AssetLoader, Handle, LoadContext, RenderAssetUsages,
};
use bevy_ecs::prelude::*;
use bevy_mesh::{Indices, Mesh, PrimitiveTopology};
use bevy_pbr::{MeshMaterial3d, StandardMaterial};
use bevy_platform::collections::HashMap;
use bevy_reflect::TypePath;
use bevy_render::mesh::Mesh3d;
use bevy_render::prelude::Visibility;
use bevy_scene::Scene;
use std::sync::Arc;
use bevy_animation::AnimationClip;
use bevy_transform::prelude::*;
use bevy_math::{Mat4, Vec3};
mod label;
pub use label::FbxAssetLabel;
pub mod prelude {
//! Commonly used items.
pub use crate::{Fbx, FbxAssetLabel, FbxPlugin};
}
/// Type of relationship between two objects in the FBX hierarchy.
#[derive(Debug, Clone)]
pub enum FbxConnKind {
/// Standard parent-child connection.
Parent,
/// Connection from an object to one of its properties.
ObjectProperty,
/// Constraint relationship.
Constraint,
}
/// Simplified connection entry extracted from the FBX file.
#[derive(Debug, Clone)]
pub struct FbxConnection {
/// Source object identifier.
pub src: String,
/// Destination object identifier.
pub dst: String,
/// The type of this connection.
pub kind: FbxConnKind,
}
/// Handedness of a coordinate system.
#[derive(Debug, Clone, Copy)]
pub enum Handedness {
/// Right handed coordinate system.
Right,
/// Left handed coordinate system.
Left,
}
/// Coordinate axes definition stored in an FBX file.
#[derive(Debug, Clone, Copy)]
pub struct FbxAxisSystem {
/// Up axis.
pub up: Vec3,
/// Forward axis.
pub front: Vec3,
/// Coordinate system handedness.
pub handedness: Handedness,
}
/// Metadata found in the FBX header.
#[derive(Debug, Clone)]
pub struct FbxMeta {
/// Creator string.
pub creator: Option<String>,
/// Timestamp when the file was created.
pub creation_time: Option<String>,
/// Original application that generated the file.
pub original_application: Option<String>,
}
/// Placeholder type for skeleton data.
#[derive(Asset, Debug, Clone, TypePath)]
pub struct Skeleton;
/// Resulting asset for an FBX file.
#[derive(Asset, Debug, TypePath)]
pub struct Fbx {
/* ===== Core sub-asset handles ===== */
/// Split Bevy scenes. A single FBX may contain many scenes.
pub scenes: Vec<Handle<Scene>>,
/// Triangulated meshes extracted from the FBX.
pub meshes: Vec<Handle<Mesh>>,
/// PBR materials or fallbacks converted from FBX materials.
pub materials: Vec<Handle<StandardMaterial>>,
/// Flattened animation takes.
pub animations: Vec<Handle<AnimationClip>>,
/// Skinning skeletons.
pub skeletons: Vec<Handle<Skeleton>>,
/* ===== Quick name lookups ===== */
pub named_meshes: HashMap<Box<str>, Handle<Mesh>>,
pub named_materials: HashMap<Box<str>, Handle<StandardMaterial>>,
pub named_animations: HashMap<Box<str>, Handle<AnimationClip>>,
pub named_skeletons: HashMap<Box<str>, Handle<Skeleton>>,
/* ===== FBX specific info ===== */
/// Flattened parent/child/constraint relations.
pub connections: Vec<FbxConnection>,
/// Original axis system of the file.
pub axis_system: FbxAxisSystem,
/// Conversion factor from the original unit to meters.
pub unit_scale: f32,
/// Copyright, creator and tool information.
pub metadata: FbxMeta,
/* ===== Optional original scene bytes ===== */
#[cfg(debug_assertions)]
pub raw_scene_bytes: Option<Arc<[u8]>>,
}
/// Errors that may occur while loading an FBX asset.
#[derive(Debug)]
pub enum FbxError {
/// IO error while reading the file.
Io(std::io::Error),
/// Error reported by the `ufbx` parser.
Parse(String),
}
impl core::fmt::Display for FbxError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FbxError::Io(err) => write!(f, "{}", err),
FbxError::Parse(err) => write!(f, "{}", err),
}
}
}
impl std::error::Error for FbxError {}
impl From<std::io::Error> for FbxError {
fn from(err: std::io::Error) -> Self {
FbxError::Io(err)
}
}
/// Loader implementation for FBX files.
#[derive(Default)]
pub struct FbxLoader;
impl AssetLoader for FbxLoader {
type Asset = Fbx;
type Settings = ();
type Error = FbxError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<Fbx, FbxError> {
// Read the complete file.
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
// Parse using `ufbx` and normalize the units/axes so that `1.0` equals
// one meter and the coordinate system matches Bevy's.
let root = ufbx::load_memory(
&bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
..Default::default()
},
)
.map_err(|e| FbxError::Parse(format!("{:?}", e)))?;
let scene: &ufbx::Scene = &*root;
let mut meshes = Vec::new();
let mut named_meshes = HashMap::new();
let mut transforms = Vec::new();
let mut scratch = Vec::new();
for (index, node) in scene.nodes.as_ref().iter().enumerate() {
let Some(mesh_ref) = node.mesh.as_ref() else { continue };
let mesh = mesh_ref.as_ref();
// Each mesh becomes a Bevy `Mesh` asset.
let handle =
load_context.labeled_asset_scope::<_, FbxError>(FbxAssetLabel::Mesh(index).to_string(), |_lc| {
let positions: Vec<[f32; 3]> = mesh
.vertex_position
.values
.as_ref()
.iter()
.map(|v| [v.x as f32, v.y as f32, v.z as f32])
.collect();
let mut bevy_mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
if mesh.vertex_normal.exists {
let normals: Vec<[f32; 3]> = (0..mesh.num_vertices)
.map(|i| {
let n = mesh.vertex_normal[i];
[n.x as f32, n.y as f32, n.z as f32]
})
.collect();
bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
}
if mesh.vertex_uv.exists {
let uvs: Vec<[f32; 2]> = (0..mesh.num_vertices)
.map(|i| {
let uv = mesh.vertex_uv[i];
[uv.x as f32, uv.y as f32]
})
.collect();
bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
}
let mut indices = Vec::new();
for &face in mesh.faces.as_ref() {
scratch.clear();
ufbx::triangulate_face_vec(&mut scratch, mesh, face);
for idx in &scratch {
let v = mesh.vertex_indices[*idx as usize];
indices.push(v);
}
}
bevy_mesh.insert_indices(Indices::U32(indices));
Ok(bevy_mesh)
})?;
if !node.element.name.is_empty() {
named_meshes.insert(Box::from(node.element.name.as_ref()), handle.clone());
}
meshes.push(handle);
transforms.push(node.geometry_to_world);
}
// Convert materials. Currently these are simple placeholders.
let mut materials = Vec::new();
let mut named_materials = HashMap::new();
for (index, mat) in scene.materials.as_ref().iter().enumerate() {
let handle = load_context.add_labeled_asset(
FbxAssetLabel::Material(index).to_string(),
StandardMaterial::default(),
);
if !mat.element.name.is_empty() {
named_materials.insert(Box::from(mat.element.name.as_ref()), handle.clone());
}
materials.push(handle);
}
// Build a simple scene with all meshes at the origin.
let mut world = World::new();
let default_material = materials.get(0).cloned().unwrap_or_else(|| {
load_context.add_labeled_asset(
FbxAssetLabel::DefaultMaterial.to_string(),
StandardMaterial::default(),
)
});
for (mesh_handle, matrix) in meshes.iter().zip(transforms.iter()) {
let mat = Mat4::from_cols_array(&[
matrix.m00 as f32,
matrix.m10 as f32,
matrix.m20 as f32,
0.0,
matrix.m01 as f32,
matrix.m11 as f32,
matrix.m21 as f32,
0.0,
matrix.m02 as f32,
matrix.m12 as f32,
matrix.m22 as f32,
0.0,
matrix.m03 as f32,
matrix.m13 as f32,
matrix.m23 as f32,
1.0,
]);
let transform = Transform::from_matrix(mat);
world.spawn((
Mesh3d(mesh_handle.clone()),
MeshMaterial3d(default_material.clone()),
transform,
GlobalTransform::default(),
Visibility::default(),
));
}
let scene_handle = load_context.add_labeled_asset(FbxAssetLabel::Scene(0).to_string(), Scene::new(world));
Ok(Fbx {
scenes: vec![scene_handle.clone()],
meshes,
materials,
animations: Vec::new(),
skeletons: Vec::new(),
named_meshes,
named_materials,
named_animations: HashMap::new(),
named_skeletons: HashMap::new(),
connections: Vec::new(),
axis_system: FbxAxisSystem {
up: Vec3::Y,
front: Vec3::Z,
handedness: Handedness::Right,
},
unit_scale: 1.0,
metadata: FbxMeta {
creator: None,
creation_time: None,
original_application: None,
},
#[cfg(debug_assertions)]
raw_scene_bytes: Some(bytes.into()),
})
}
fn extensions(&self) -> &[&str] {
&["fbx"]
}
}
/// Plugin adding the FBX loader to an [`App`].
#[derive(Default)]
pub struct FbxPlugin;
impl Plugin for FbxPlugin {
fn build(&self, app: &mut App) {
app.init_asset::<Fbx>()
.register_asset_loader(FbxLoader::default());
}
}

View File

@ -195,6 +195,7 @@ bevy_core_pipeline = ["dep:bevy_core_pipeline", "bevy_image"]
bevy_anti_aliasing = ["dep:bevy_anti_aliasing", "bevy_image"]
bevy_gizmos = ["dep:bevy_gizmos", "bevy_image"]
bevy_gltf = ["dep:bevy_gltf", "bevy_image"]
bevy_fbx = ["dep:bevy_fbx", "bevy_image", "bevy_animation"]
bevy_ui = ["dep:bevy_ui", "bevy_image"]
bevy_ui_render = ["dep:bevy_ui_render"]
bevy_image = ["dep:bevy_image"]
@ -430,6 +431,7 @@ bevy_dev_tools = { path = "../bevy_dev_tools", optional = true, version = "0.17.
bevy_gilrs = { path = "../bevy_gilrs", optional = true, version = "0.17.0-dev" }
bevy_gizmos = { path = "../bevy_gizmos", optional = true, version = "0.17.0-dev", default-features = false }
bevy_gltf = { path = "../bevy_gltf", optional = true, version = "0.17.0-dev" }
bevy_fbx = { path = "../bevy_fbx", optional = true, version = "0.17.0-dev" }
bevy_feathers = { path = "../bevy_feathers", optional = true, version = "0.17.0-dev" }
bevy_image = { path = "../bevy_image", optional = true, version = "0.17.0-dev" }
bevy_mesh = { path = "../bevy_mesh", optional = true, version = "0.17.0-dev" }

View File

@ -54,6 +54,8 @@ plugin_group! {
// compressed texture formats.
#[cfg(feature = "bevy_gltf")]
bevy_gltf:::GltfPlugin,
#[cfg(feature = "bevy_fbx")]
bevy_fbx:::FbxPlugin,
#[cfg(feature = "bevy_audio")]
bevy_audio:::AudioPlugin,
#[cfg(feature = "bevy_gilrs")]

View File

@ -45,6 +45,8 @@ pub use bevy_gilrs as gilrs;
pub use bevy_gizmos as gizmos;
#[cfg(feature = "bevy_gltf")]
pub use bevy_gltf as gltf;
#[cfg(feature = "bevy_fbx")]
pub use bevy_fbx as fbx;
#[cfg(feature = "bevy_image")]
pub use bevy_image as image;
pub use bevy_input as input;

View File

@ -83,6 +83,10 @@ pub use crate::state::prelude::*;
#[cfg(feature = "bevy_gltf")]
pub use crate::gltf::prelude::*;
#[doc(hidden)]
#[cfg(feature = "bevy_fbx")]
pub use crate::fbx::prelude::*;
#[doc(hidden)]
#[cfg(feature = "bevy_picking")]
pub use crate::picking::prelude::*;

62
examples/3d/load_fbx.rs Normal file
View File

@ -0,0 +1,62 @@
use bevy::{
pbr::{CascadeShadowConfigBuilder, DirectionalLightShadowMap},
prelude::*,
};
use std::f32::consts::*;
fn main() {
App::new()
.insert_resource(DirectionalLightShadowMap { size: 4096 })
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, animate_light_direction)
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.7, 0.7, 1.0).looking_at(Vec3::new(0.0, 0.3, 0.0), Vec3::Y),
EnvironmentMapLight {
diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"),
specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"),
intensity: 250.0,
..default()
},
));
commands.spawn((
DirectionalLight {
shadows_enabled: true,
..default()
},
// This is a relatively small scene, so use tighter shadow
// cascade bounds than the default for better quality.
// We also adjusted the shadow map to be larger since we're
// only using a single cascade.
CascadeShadowConfigBuilder {
num_cascades: 1,
maximum_distance: 1.6,
..default()
}
.build(),
));
// FBX_TODO: the cube doesn't show up
commands.spawn(SceneRoot(
asset_server.load(FbxAssetLabel::Scene(0).from_asset("models/cube/cube.fbx")),
));
}
fn animate_light_direction(
time: Res<Time>,
mut query: Query<&mut Transform, With<DirectionalLight>>,
) {
for mut transform in &mut query {
transform.rotation = Quat::from_euler(
EulerRot::ZYX,
0.0,
time.elapsed_secs() * PI / 5.0,
-FRAC_PI_4,
);
}
}

View File

@ -167,6 +167,7 @@ Example | Description
[Load glTF](../examples/3d/load_gltf.rs) | Loads and renders a glTF file as a scene
[Load glTF extras](../examples/3d/load_gltf_extras.rs) | Loads and renders a glTF file as a scene, including the gltf extras
[Manual Material Implementation](../examples/3d/manual_material.rs) | Demonstrates how to implement a material manually using the mid-level render APIs
[Load FBX](../examples/3d/load_fbx.rs) | Loads and renders an FBX file as a scene
[Mesh Ray Cast](../examples/3d/mesh_ray_cast.rs) | Demonstrates ray casting with the `MeshRayCast` system parameter
[Meshlet](../examples/3d/meshlet.rs) | Meshlet rendering for dense high-poly scenes (experimental)
[Mixed lighting](../examples/3d/mixed_lighting.rs) | Demonstrates how to combine baked and dynamic lighting

View File

@ -82,7 +82,7 @@ fn setup(
commands.spawn((
Mesh3d(cube_handle),
MeshMaterial3d(material_handle.clone()),
Transform::from_xyz(0.0, 0.0, 0.0),
Transform::from_xyz(0.0, 3.0, 0.0),
));
// sphere
commands.spawn((
@ -90,6 +90,11 @@ fn setup(
MeshMaterial3d(material_handle),
Transform::from_xyz(3.0, 0.0, 0.0),
));
// TODO: don't touch this example
commands.spawn(SceneRoot(
asset_server.load(FbxAssetLabel::Scene(0).from_asset("models/cube/cube.fbx")),
));
// light
commands.spawn((PointLight::default(), Transform::from_xyz(4.0, 5.0, 4.0)));
// camera