From f633feeb253784e1a1536a3bfc889b36c2a8c3e5 Mon Sep 17 00:00:00 2001 From: VitalyR Date: Mon, 9 Jun 2025 02:13:54 +0800 Subject: [PATCH 01/14] Add bevy_fbx, an fbx loader based on ufbx --- Cargo.toml | 14 + assets/models/cube/cube.fbx | Bin 0 -> 26428 bytes crates/bevy_fbx/Cargo.toml | 37 +++ crates/bevy_fbx/README.md | 5 + crates/bevy_fbx/src/label.rs | 41 +++ crates/bevy_fbx/src/lib.rs | 350 ++++++++++++++++++++ crates/bevy_internal/Cargo.toml | 2 + crates/bevy_internal/src/default_plugins.rs | 2 + crates/bevy_internal/src/lib.rs | 2 + crates/bevy_internal/src/prelude.rs | 4 + examples/3d/load_fbx.rs | 62 ++++ examples/README.md | 1 + examples/asset/asset_loading.rs | 7 +- 13 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 assets/models/cube/cube.fbx create mode 100644 crates/bevy_fbx/Cargo.toml create mode 100644 crates/bevy_fbx/README.md create mode 100644 crates/bevy_fbx/src/label.rs create mode 100644 crates/bevy_fbx/src/lib.rs create mode 100644 examples/3d/load_fbx.rs diff --git a/Cargo.toml b/Cargo.toml index 688147ff9ba3e..b4042479ae711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ default = [ "bevy_gilrs", "bevy_gizmos", "bevy_gltf", + "bevy_fbx", "bevy_input_focus", "bevy_log", "bevy_mesh_picking_backend", @@ -229,6 +230,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 = [ @@ -1162,6 +1165,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" diff --git a/assets/models/cube/cube.fbx b/assets/models/cube/cube.fbx new file mode 100644 index 0000000000000000000000000000000000000000..01788cefd055c705c7feb18bee0b27c287b0be9f GIT binary patch literal 26428 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 literal 0 HcmV?d00001 diff --git a/crates/bevy_fbx/Cargo.toml b/crates/bevy_fbx/Cargo.toml new file mode 100644 index 0000000000000..369cc032bf10b --- /dev/null +++ b/crates/bevy_fbx/Cargo.toml @@ -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 diff --git a/crates/bevy_fbx/README.md b/crates/bevy_fbx/README.md new file mode 100644 index 0000000000000..5c705c4e117c3 --- /dev/null +++ b/crates/bevy_fbx/README.md @@ -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`]. diff --git a/crates/bevy_fbx/src/label.rs b/crates/bevy_fbx/src/label.rs new file mode 100644 index 0000000000000..ab20cb38c6567 --- /dev/null +++ b/crates/bevy_fbx/src/label.rs @@ -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> { + path.into().with_label(self.to_string()) + } +} + diff --git a/crates/bevy_fbx/src/lib.rs b/crates/bevy_fbx/src/lib.rs new file mode 100644 index 0000000000000..f0b9107d6e1ff --- /dev/null +++ b/crates/bevy_fbx/src/lib.rs @@ -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, + /// Timestamp when the file was created. + pub creation_time: Option, + /// Original application that generated the file. + pub original_application: Option, +} + +/// 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>, + /// Triangulated meshes extracted from the FBX. + pub meshes: Vec>, + /// PBR materials or fallbacks converted from FBX materials. + pub materials: Vec>, + /// Flattened animation takes. + pub animations: Vec>, + /// Skinning skeletons. + pub skeletons: Vec>, + + /* ===== Quick name lookups ===== */ + pub named_meshes: HashMap, Handle>, + pub named_materials: HashMap, Handle>, + pub named_animations: HashMap, Handle>, + pub named_skeletons: HashMap, Handle>, + + /* ===== FBX specific info ===== */ + /// Flattened parent/child/constraint relations. + pub connections: Vec, + /// 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>, +} + +/// 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 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 { + // 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::() + .register_asset_loader(FbxLoader::default()); + } +} diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 5e5c95f3ec16b..75bd17645ee41 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -186,6 +186,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_image = ["dep:bevy_image"] @@ -412,6 +413,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_image = { path = "../bevy_image", optional = true, version = "0.17.0-dev" } bevy_input_focus = { path = "../bevy_input_focus", optional = true, version = "0.17.0-dev", default-features = false, features = [ "bevy_reflect", diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 93ba3cb8899c2..8df6b320e370b 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -52,6 +52,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")] diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index b9934088f1d1a..f5a363de7da96 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -41,6 +41,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; diff --git a/crates/bevy_internal/src/prelude.rs b/crates/bevy_internal/src/prelude.rs index 26d5c7e2af0f5..0fedaf68578d0 100644 --- a/crates/bevy_internal/src/prelude.rs +++ b/crates/bevy_internal/src/prelude.rs @@ -79,6 +79,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::*; diff --git a/examples/3d/load_fbx.rs b/examples/3d/load_fbx.rs new file mode 100644 index 0000000000000..982d301c2d20b --- /dev/null +++ b/examples/3d/load_fbx.rs @@ -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) { + 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