Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d4f44a9
introduce GltfExtensionProcessor
ChristopherBiscardi Dec 13, 2025
9301f85
fix imports for clippy
ChristopherBiscardi Dec 13, 2025
96d67a2
new examples
ChristopherBiscardi Dec 13, 2025
f3e9f4d
clippy docs and lints
ChristopherBiscardi Dec 13, 2025
0136191
one more lint
ChristopherBiscardi Dec 13, 2025
7a951b4
one more lint
ChristopherBiscardi Dec 13, 2025
ea8d541
Update crates/bevy_gltf/src/lib.rs
ChristopherBiscardi Dec 13, 2025
a9e4f12
Update crates/bevy_gltf/src/loader/extensions/mod.rs
ChristopherBiscardi Dec 13, 2025
03b85a0
Update crates/bevy_gltf/src/loader/extensions/mod.rs
ChristopherBiscardi Dec 13, 2025
ff3bb56
implement Default instead of insert_resource
ChristopherBiscardi Dec 13, 2025
b7c2f8f
fix clippy for gh review commit
ChristopherBiscardi Dec 13, 2025
7a67742
change default extension_ids list to be an empty string
ChristopherBiscardi Dec 13, 2025
14b21e8
change to Handler to avoid AssetProcessor connotations
ChristopherBiscardi Dec 13, 2025
4514b31
cleanup resource push in examples
ChristopherBiscardi Dec 13, 2025
3342b29
clean up extension exports
ChristopherBiscardi Dec 13, 2025
3a4c762
pass extension id alongside data so advanced users can identify which…
ChristopherBiscardi Dec 13, 2025
4ca3f1b
remove dbg
ChristopherBiscardi Dec 13, 2025
ad9d4ba
more docs
ChristopherBiscardi Dec 14, 2025
2f1aa7a
publicly
ChristopherBiscardi Dec 14, 2025
1d5d16a
spawn_mesh_and_material is more useful if it accepts all of the gltf …
ChristopherBiscardi Dec 14, 2025
2d137c2
it is more useful to give read access to the relevant gltf node every…
ChristopherBiscardi Dec 14, 2025
c55c50a
address some minor feedback
ChristopherBiscardi Dec 14, 2025
f45f065
continue, not return, in example loop
ChristopherBiscardi Dec 14, 2025
aa6e2dd
use arc+async-lock::rwlock to enable modifying the handlers at runtime
ChristopherBiscardi Dec 14, 2025
5ead859
use labeled_asset instead of loaded_labeled_asset
ChristopherBiscardi Dec 14, 2025
ad07efa
alloc
ChristopherBiscardi Dec 14, 2025
5ef8f84
import reorder for clippy
ChristopherBiscardi Dec 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ event-listener = "5.3.0"
anyhow = "1"
accesskit = "0.21"
nonmax = "0.5"
gltf = "1.4"

[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
ureq = { version = "3.0.8", features = ["json"] }
Expand Down Expand Up @@ -4178,6 +4179,28 @@ description = "Loads and renders a glTF file as a scene, including the gltf extr
category = "glTF"
wasm = true

[[example]]
name = "gltf_extension_animation_graph"
path = "examples/gltf/gltf_extension_animation_graph.rs"
doc-scrape-examples = true

[package.metadata.example.gltf_extension_animation_graph]
name = "glTF extension AnimationGraph"
description = "Uses glTF data to build an AnimationGraph via extension processing"
category = "glTF"
wasm = true

[[example]]
name = "gltf_extension_mesh_2d"
path = "examples/gltf/gltf_extension_mesh_2d.rs"
doc-scrape-examples = true

[package.metadata.example.gltf_extension_mesh_2d]
name = "glTF extension processing to build Mesh2ds from glTF data"
description = "Uses glTF extension data to convert incoming Mesh3d/MeshMaterial3d assets to 2d"
category = "glTF"
wasm = true

[[example]]
name = "query_gltf_primitives"
path = "examples/gltf/query_gltf_primitives.rs"
Expand Down
14 changes: 14 additions & 0 deletions assets/models/barycentric/barycentric.gltf
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
{
"scene": 0,
"scenes": [
{
"nodes": [
0
]
}
],
"nodes": [
{
"name": "box",
"mesh": 0
}
],
"accessors": [
{
"bufferView": 0,
Expand Down
8 changes: 7 additions & 1 deletion crates/bevy_gltf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ pub mod prelude {
pub use crate::{assets::Gltf, assets::GltfExtras, label::GltfAssetLabel};
}

use crate::extensions::GltfExtensionHandlers;

pub use {assets::*, label::GltfAssetLabel, loader::*};

// Has to store an Arc<Mutex<...>> as there is no other way to mutate fields of asset loaders.
Expand Down Expand Up @@ -249,7 +251,8 @@ impl Plugin for GltfPlugin {
.init_asset::<GltfPrimitive>()
.init_asset::<GltfMesh>()
.init_asset::<GltfSkin>()
.preregister_asset_loader::<GltfLoader>(&["gltf", "glb"]);
.preregister_asset_loader::<GltfLoader>(&["gltf", "glb"])
.init_resource::<GltfExtensionHandlers>();
}

fn finish(&self, app: &mut App) {
Expand All @@ -267,11 +270,14 @@ impl Plugin for GltfPlugin {
let default_sampler = default_sampler_resource.get_internal();
app.insert_resource(default_sampler_resource);

let extensions = app.world().resource::<GltfExtensionHandlers>();

app.register_asset_loader(GltfLoader {
supported_compressed_formats,
custom_vertex_attributes: self.custom_vertex_attributes.clone(),
default_sampler,
default_use_model_forward_direction: self.use_model_forward_direction,
extensions: extensions.0.clone(),
});
}
}
254 changes: 254 additions & 0 deletions crates/bevy_gltf/src/loader/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,261 @@ mod khr_materials_anisotropy;
mod khr_materials_clearcoat;
mod khr_materials_specular;

use bevy_animation::AnimationClip;
use bevy_asset::{Handle, LoadContext};
use bevy_ecs::{
entity::Entity,
resource::Resource,
world::{EntityWorldMut, World},
};
use bevy_pbr::StandardMaterial;
use bevy_platform::collections::{HashMap, HashSet};
use gltf::Node;

use crate::GltfMesh;

pub(crate) use self::{
khr_materials_anisotropy::AnisotropyExtension, khr_materials_clearcoat::ClearcoatExtension,
khr_materials_specular::SpecularExtension,
};

/// Stores the `GltfExtensionHandler` implementations so that they
/// can be added by users and also passed to the glTF loader
#[derive(Resource, Default)]
pub struct GltfExtensionHandlers(pub Vec<Box<dyn GltfExtensionHandler>>);

/// glTF Extensions can attach data to any objects in a glTF file.
/// This is done by inserting data in the `extensions` sub-object, and
/// data in the extensions sub-object is keyed by the id of the extension.
/// For example: `KHR_materials_variants`, `EXT_meshopt_compression`, or `BEVY_my_tool`
///
/// A list of publicly known extensions and their ids can be found
/// in the [KhronosGroup/glTF](https://github.com/KhronosGroup/glTF/blob/main/extensions/README.md)
/// git repo. Vendors reserve prefixes, such as the `BEVY` prefix,
/// which is also listed in the [KhronosGroup repo](https://github.com/KhronosGroup/glTF/blob/main/extensions/Prefixes.md).
///
/// The `GltfExtensionHandler` trait should be implemented to participate in
/// processing glTF files as they load, and exposes glTF extension data via
/// a series of hook callbacks.
///
/// The type a `GltfExtensionHandler` is implemented for can define data
/// which will be cloned for each new glTF load. This enables stateful
/// handling of glTF extension data during a single load.
pub trait GltfExtensionHandler: Send + Sync {
/// Required for dyn cloning
fn dyn_clone(&self) -> Box<dyn GltfExtensionHandler>;

/// When loading a glTF file, a glTF object that could contain extension
/// data will cause the relevant hook to execute once for each id in this list.
/// Each invocation will receive the extension data for one of the extension ids,
/// along with the `extension_id` itself so implementors can differentiate
/// between different calls and parse data correctly.
///
/// The hooks are always called, even if there is no extension data
/// for a specified id. This is useful for scenarios where additional
/// extension data isn't required, but processing should still happen.
///
/// Most implementors will pick one extension for this list, causing the
/// relevant hooks to fire once per object. An implementor that does not
/// wish to receive any data but still wants hooks to be called can use
/// an empty string `""` as the extension id, which is also the default
/// value if the function is not implemented by an implementor. If the
/// empty string is used, all extension data in hooks will be `None`.
///
/// Some implementors will choose to list multiple extensions here.
/// This is an advanced use case and the alternative of having multiple
/// independent handlers should be considered as an option first.
/// If multiple extension ids are listed here, the hooks will fire once
/// for each extension id, and each successive call will receive the data for
/// a separate extension. The extension id is also included in hook arguments
/// for this reason, so multiple extension id implementors can differentiate
/// between the data received.
fn extension_ids(&self) -> &'static [&'static str] {
&[""]
}

/// Called when the "global" data for an extension
/// at the root of a glTF file is encountered.
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_root_data(&mut self, extension_id: &str, value: Option<&serde_json::Value>) {}

#[cfg(feature = "bevy_animation")]
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
/// Called when an individual animation is processed
fn on_animation(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
name: Option<&str>,
handle: Handle<AnimationClip>,
) {
}

#[cfg(feature = "bevy_animation")]
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
/// Called when all animations have been collected.
/// `animations` is the glTF ordered list of `Handle<AnimationClip>`s
/// `named_animations` is a `HashMap` from animation name to `Handle<AnimationClip>`
/// `animation_roots` is the glTF index of the animation root object
fn on_animations_collected(
&mut self,
load_context: &mut LoadContext<'_>,
animations: &[Handle<AnimationClip>],
named_animations: &HashMap<Box<str>, Handle<AnimationClip>>,
animation_roots: &HashSet<usize>,
) {
}

/// Called when an individual texture is processed
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_texture(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
texture: Handle<bevy_image::Image>,
) {
}

/// Called when an individual material is processed
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_material(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
name: Option<&str>,
material: Handle<StandardMaterial>,
) {
}

/// Called when an individual glTF Mesh is processed
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_gltf_mesh(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
name: Option<&str>,
mesh: Handle<GltfMesh>,
) {
}

/// mesh and material are spawned as a single Entity,
/// which means an extension would have to decide for
/// itself how to merge the extension data.
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_spawn_mesh_and_material(
&mut self,
load_context: &mut LoadContext<'_>,
gltf_node: &Node,
entity: &mut EntityWorldMut,
) {
}

/// Called when an individual Scene is done processing
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_scene_completed(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
name: Option<&str>,
world_root_id: Entity,
world: &mut World,
load_context: &mut LoadContext<'_>,
) {
}

/// Called when a node is processed
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_gltf_node(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
gltf_node: &Node,
entity: &mut EntityWorldMut,
) {
}

/// Called with a `DirectionalLight` node is spawned
/// which is typically created as a result of
/// `KHR_lights_punctual`
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_spawn_light_directional(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
gltf_node: &Node,
entity: &mut EntityWorldMut,
) {
}
/// Called with a `PointLight` node is spawned
/// which is typically created as a result of
/// `KHR_lights_punctual`
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_spawn_light_point(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
gltf_node: &Node,
entity: &mut EntityWorldMut,
) {
}
/// Called with a `SpotLight` node is spawned
/// which is typically created as a result of
/// `KHR_lights_punctual`
#[expect(
unused,
reason = "default trait implementations do not use the arguments because they are no-ops"
)]
fn on_spawn_light_spot(
&mut self,
extension_id: &str,
extension_data: Option<&serde_json::Value>,
load_context: &mut LoadContext<'_>,
gltf_node: &Node,
entity: &mut EntityWorldMut,
) {
}
}

impl Clone for Box<dyn GltfExtensionHandler> {
fn clone(&self) -> Self {
self.dyn_clone()
}
}
Loading