diff --git a/Cargo.toml b/Cargo.toml index 130eafda2c077..145ceb46b5ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -195,6 +195,9 @@ sysinfo_plugin = ["bevy_internal/sysinfo_plugin"] # Provides animation functionality bevy_animation = ["bevy_internal/bevy_animation", "bevy_color"] +# Provides methods to attach an entity to another +bevy_bone_attachments = ["bevy_internal/bevy_bone_attachments"] + # Provides asset functionality bevy_asset = ["bevy_internal/bevy_asset"] @@ -1439,6 +1442,28 @@ description = "Plays an animation from a skinned glTF with events" category = "Animation" wasm = true +[[example]] +name = "bone_attachments" +path = "examples/animation/bone_attachments.rs" +doc-scrape-examples = true +required-features = [ + "std", + "animation", + "bevy_bone_attachments", + "bevy_gltf", + "bevy_window", + # Application crashes on exit if disabled + "multi_threaded", + "png", + "tonemapping_luts", +] + +[package.metadata.example.bone_attachments] +name = "Bone Attachments" +description = "Shows attaching a model to another" +category = "Animation" +wasm = true + [[example]] name = "animation_graph" path = "examples/animation/animation_graph.rs" diff --git a/assets/models/animated/FoxAttachment.glb b/assets/models/animated/FoxAttachment.glb new file mode 100644 index 0000000000000..54167a4fa18b7 Binary files /dev/null and b/assets/models/animated/FoxAttachment.glb differ diff --git a/crates/bevy_animation/src/lib.rs b/crates/bevy_animation/src/lib.rs index 8663ea3f3f970..be8a9bf7ba093 100644 --- a/crates/bevy_animation/src/lib.rs +++ b/crates/bevy_animation/src/lib.rs @@ -171,7 +171,10 @@ pub type AnimationCurves = HashMap, NoOpHa /// connected to a bone named `Stomach`. /// /// [UUID]: https://en.wikipedia.org/wiki/Universally_unique_identifier -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Reflect, Debug, Serialize, Deserialize)] +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Component, Reflect, Debug, Serialize, Deserialize, +)] +#[reflect(Component)] pub struct AnimationTargetId(pub Uuid); impl Hash for AnimationTargetId { diff --git a/crates/bevy_bone_attachments/Cargo.toml b/crates/bevy_bone_attachments/Cargo.toml new file mode 100644 index 0000000000000..3a6bb5e1b72c3 --- /dev/null +++ b/crates/bevy_bone_attachments/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bevy_bone_attachments" +version = "0.16.0-dev" +edition = "2024" +description = "Experimental implementation of bone attachments" +homepage = "https://bevyengine.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy", "animation"] +rust-version = "1.85.0" + +[dependencies] +bevy_animation = { path = "../bevy_animation", version = "0.16.0-dev", default-features = false } +bevy_app = { path = "../bevy_app", version = "0.16.0-dev", default-features = false } +bevy_asset = { path = "../bevy_asset", version = "0.16.0-dev", default-features = false, optional = true } +bevy_derive = { path = "../bevy_derive", version = "0.16.0-dev", default-features = false } +bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev", default-features = false } +bevy_platform_support = { path = "../bevy_platform_support", version = "0.16.0-dev", default-features = false } +bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", default-features = false } +bevy_scene = { path = "../bevy_scene", version = "0.16.0-dev", default-features = false, optional = true } +bevy_transform = { path = "../bevy_transform", version = "0.16.0-dev", default-features = false } + +tracing = { version = "0.1", default-features = false } + +[features] +bevy_scene = ["dep:bevy_scene", "dep:bevy_asset"] + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"] +all-features = true diff --git a/crates/bevy_bone_attachments/LICENSE-APACHE b/crates/bevy_bone_attachments/LICENSE-APACHE new file mode 100644 index 0000000000000..d9a10c0d8e868 --- /dev/null +++ b/crates/bevy_bone_attachments/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/bevy_bone_attachments/LICENSE-MIT b/crates/bevy_bone_attachments/LICENSE-MIT new file mode 100644 index 0000000000000..9cf106272ac3b --- /dev/null +++ b/crates/bevy_bone_attachments/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/bevy_bone_attachments/README.md b/crates/bevy_bone_attachments/README.md new file mode 100644 index 0000000000000..40d16f765dd96 --- /dev/null +++ b/crates/bevy_bone_attachments/README.md @@ -0,0 +1,19 @@ +# Bevy Bone Attachments + +Bone attachments are used to accessorize a model with others. A common use is for example +to attach a weapon to a characters hands. + +This relies on the parent model having `AnimationTarget` components and the attachment having +`AnimationTargetId` component. + +Currently this only works by attaching a `Scene` to another entity. If the `Scene` is loaded +from a `glTf`, use the `GltfLoaderSetting::include_animation_target_ids` setting to load the `AnimationTargetId` +of the attachment. + +## Links + +[![License](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/bevyengine/bevy#license) +[![Crates.io](https://img.shields.io/crates/v/bevy_color.svg)](https://crates.io/crates/bevy_color) +[![Downloads](https://img.shields.io/crates/d/bevy_color.svg)](https://crates.io/crates/bevy_color) +[![Docs](https://docs.rs/bevy_color/badge.svg)](https://docs.rs/bevy_color/latest/bevy_color/) +[![Discord](https://img.shields.io/discord/691052431525675048.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/bevy) diff --git a/crates/bevy_bone_attachments/src/lib.rs b/crates/bevy_bone_attachments/src/lib.rs new file mode 100644 index 0000000000000..7e87a4834a2ad --- /dev/null +++ b/crates/bevy_bone_attachments/src/lib.rs @@ -0,0 +1,63 @@ +//! Bone attachments are used to connect a model to another + +#![no_std] + +pub mod relationship; +#[cfg(feature = "bevy_scene")] +pub mod scene; + +extern crate alloc; + +use alloc::vec::Vec; +use bevy_app::{Plugin, PostUpdate}; +use bevy_ecs::{ + entity::Entity, relationship::RelationshipTarget, schedule::IntoScheduleConfigs, system::Query, +}; +use bevy_transform::{components::Transform, TransformSystem}; + +/// Most frequently used objects of [`bevy_bone_attachments`](self) for easy access +pub mod prelude { + pub use super::{ + relationship::{AttachedTo, AttachingModels}, + BoneAttachmentsPlugin, + }; +} + +#[derive(Default)] +/// Plugin that setups bone attachments +pub struct BoneAttachmentsPlugin; + +impl Plugin for BoneAttachmentsPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.register_type::() + .register_type::(); + + app.add_systems( + PostUpdate, + propagate_transform_to_attachments.after(TransformSystem::TransformPropagate), + ); + } +} + +fn propagate_transform_to_attachments( + parents: Query<(Entity, &relationship::AttachingModels)>, + mut transforms: Query<&mut Transform>, +) { + let mut parents_without_transform = Vec::new(); + let mut children_without_transform = Vec::new(); + for (entity, children) in parents.iter() { + let Ok(parent_transform) = transforms.get(entity).cloned() else { + parents_without_transform.push(entity); + continue; + }; + + for child in children.iter() { + let Ok(mut transform) = transforms.get_mut(child) else { + children_without_transform.push(child); + continue; + }; + + *transform = parent_transform; + } + } +} diff --git a/crates/bevy_bone_attachments/src/relationship.rs b/crates/bevy_bone_attachments/src/relationship.rs new file mode 100644 index 0000000000000..59e6970f7f4a5 --- /dev/null +++ b/crates/bevy_bone_attachments/src/relationship.rs @@ -0,0 +1,23 @@ +//! Define the relationship between attaching and attached models + +use alloc::vec::Vec; + +use bevy_derive::Deref; +use bevy_ecs::{component::Component, entity::Entity}; +use bevy_reflect::Reflect; + +#[derive(Debug, Component, Reflect)] +#[relationship_target(relationship = AttachedTo)] +/// List of models attached to this model +pub struct AttachingModels(Vec); + +#[derive(Debug, Component, Reflect, Deref)] +#[relationship(relationship_target=AttachingModels)] +/// Model this entity is attached to +pub struct AttachedTo(Entity); + +impl From for AttachedTo { + fn from(entity: Entity) -> Self { + Self(entity) + } +} diff --git a/crates/bevy_bone_attachments/src/scene.rs b/crates/bevy_bone_attachments/src/scene.rs new file mode 100644 index 0000000000000..1b64b5233b198 --- /dev/null +++ b/crates/bevy_bone_attachments/src/scene.rs @@ -0,0 +1,115 @@ +//! Types to help attaching a scene to an entity + +use alloc::vec::Vec; +use bevy_animation::{AnimationTarget, AnimationTargetId}; +use bevy_asset::Handle; +use bevy_ecs::{ + bundle::Bundle, + hierarchy::Children, + observer::Trigger, + relationship::RelatedSpawnerCommands, + system::{Commands, EntityCommands, Query}, +}; +use bevy_platform_support::collections::{hash_map::Entry, HashMap}; +use bevy_scene::{Scene, SceneInstanceReady, SceneRoot}; + +use crate::prelude::AttachedTo; + +/// Extension trait for [`EntityCommands`] to allow attaching a [`Scene`] to an [`Entity`](bevy_ecs::entity::Entity). +pub trait SceneAttachmentExt { + /// Attaches a [`Scene`] to an [`Entity`](bevy_ecs::entity::Entity). + fn attach_scene(&mut self, scene: Handle) -> &mut Self; + + /// Attaches a [`Scene`] to an [`Entity`](bevy_ecs::entity::Entity) and inserts an extra [`Bundle`] + /// on the attachment. + fn attach_scene_with_extras(&mut self, scene: Handle, extras: impl Bundle) -> &mut Self; +} + +impl<'a> SceneAttachmentExt for EntityCommands<'a> { + #[inline] + fn attach_scene(&mut self, scene: Handle) -> &mut EntityCommands<'a> { + self.attach_scene_with_extras(scene, ()) + } + + #[inline] + fn attach_scene_with_extras( + &mut self, + scene: Handle, + extras: impl Bundle, + ) -> &mut EntityCommands<'a> { + self.with_related(|spawner: &mut RelatedSpawnerCommands| { + spawner + .spawn((SceneRoot(scene), extras)) + .observe(scene_attachment_ready); + }) + } +} + +fn scene_attachment_ready( + trigger: Trigger, + mut commands: Commands, + scene_attachments: Query<&AttachedTo>, + children: Query<&Children>, + animation_targets: Query<&AnimationTarget>, + animation_target_ids: Query<&AnimationTargetId>, +) { + let Ok(parent) = scene_attachments.get(trigger.target()) else { + unreachable!("AttachedTo must be available on SceneInstanceReady."); + }; + + let mut duplicate_target_ids_on_parent_hierarchy = Vec::new(); + let mut target_ids = HashMap::new(); + for child in children.iter_descendants(**parent) { + if child == trigger.target() { + continue; + } + + if let Ok(animation_target) = animation_targets.get(child) { + match target_ids.entry(animation_target.id) { + Entry::Vacant(vacancy) => { + vacancy.insert(animation_target.player); + } + Entry::Occupied(_) => { + duplicate_target_ids_on_parent_hierarchy.push(animation_target.id); + } + } + } + } + if !duplicate_target_ids_on_parent_hierarchy.is_empty() { + tracing::warn!( + "There where nodes with duplicate AnimationTargetId on the hierarchy if {}, using the first appearance. {:?}", + **parent, + duplicate_target_ids_on_parent_hierarchy + ); + } + + let mut count = 0; + let mut unmatched_animation_target_id = Vec::new(); + for child in children.iter_descendants(trigger.target()) { + if let Ok(animation_target_id) = animation_target_ids.get(child) { + if let Some(player) = target_ids.get(animation_target_id) { + commands.entity(child).insert(AnimationTarget { + id: *animation_target_id, + player: *player, + }); + count += 1; + } else { + unmatched_animation_target_id.push(animation_target_id); + } + } + } + if !unmatched_animation_target_id.is_empty() { + tracing::warn!( + "There where nodes with unmatched AnimationTargetId on the hierarchy if {}, this may cause bone attachment to not update correctly. {:?}", + trigger.target(), + unmatched_animation_target_id + ); + } + tracing::debug!( + "Attachment {} matched {} nodes with parent.", + trigger.target(), + count + ); + + commands.entity(trigger.target()); +} diff --git a/crates/bevy_gltf/src/loader/mod.rs b/crates/bevy_gltf/src/loader/mod.rs index 9d400e44bc0cb..1ff7fdab18af9 100644 --- a/crates/bevy_gltf/src/loader/mod.rs +++ b/crates/bevy_gltf/src/loader/mod.rs @@ -13,6 +13,8 @@ use bevy_asset::{ }; use bevy_color::{Color, LinearRgba}; use bevy_core_pipeline::prelude::Camera3d; +#[cfg(feature = "bevy_animation")] +use bevy_ecs::world::EntityWorldMut; use bevy_ecs::{ entity::{hash_map::EntityHashMap, Entity}, hierarchy::ChildSpawner, @@ -66,6 +68,8 @@ use crate::{ GltfMaterialName, GltfMeshExtras, GltfNode, GltfSceneExtras, GltfSkin, }; +#[cfg(feature = "bevy_animation")] +use self::gltf_ext::scene::collect_path; use self::{ extensions::{AnisotropyExtension, ClearcoatExtension, SpecularExtension}, gltf_ext::{ @@ -75,7 +79,7 @@ use self::{ warn_on_differing_texture_transforms, }, mesh::{primitive_name, primitive_topology}, - scene::{collect_path, node_name, node_transform}, + scene::{node_name, node_transform}, texture::{texture_handle, texture_sampler, texture_transform_to_affine2}, }, }; @@ -176,6 +180,9 @@ pub struct GltfLoaderSettings { pub load_cameras: bool, /// If true, the loader will spawn lights for gltf light nodes. pub load_lights: bool, + /// If true, the loader will include [`AnimationTargetId`] for all nodes, + /// even if there is no animation on the Gltf + pub include_animation_target_ids: bool, /// If true, the loader will include the root of the gltf root node. pub include_source: bool, } @@ -187,6 +194,7 @@ impl Default for GltfLoaderSettings { load_materials: RenderAssetUsages::default(), load_cameras: true, load_lights: true, + include_animation_target_ids: false, include_source: false, } } @@ -1312,22 +1320,25 @@ fn load_node( node.insert(name.clone()); #[cfg(feature = "bevy_animation")] - if animation_context.is_none() && animation_roots.contains(&gltf_node.index()) { - // This is an animation root. Make a new animation context. - animation_context = Some(AnimationContext { - root: node.id(), - path: SmallVec::new(), - }); + if animation_context.is_none() { + if animation_roots.contains(&gltf_node.index()) { + // This is an animation root. Make a new animation context. + animation_context = Some(AnimationContext::Animation { + root: node.id(), + path: SmallVec::new(), + }); + } else if settings.include_animation_target_ids { + // Creating a AnimationContext to collect the paths for the `AnimationTargetId`. + animation_context = Some(AnimationContext::JustTargetId { + path: SmallVec::new(), + }); + } } #[cfg(feature = "bevy_animation")] if let Some(ref mut animation_context) = animation_context { - animation_context.path.push(name); - - node.insert(AnimationTarget { - id: AnimationTargetId::from_names(animation_context.path.iter()), - player: animation_context.root, - }); + animation_context.push_name(name); + animation_context.insert_to_entity(&mut node); } if let Some(extras) = gltf_node.extras() { @@ -1740,12 +1751,44 @@ impl<'s> Iterator for PrimitiveMorphAttributesIter<'s> { /// nearest ancestor animation root. #[cfg(feature = "bevy_animation")] #[derive(Clone)] -struct AnimationContext { - /// The nearest ancestor animation root. - pub root: Entity, - /// The path to the animation root. This is used for constructing the - /// animation target UUIDs. - pub path: SmallVec<[Name; 8]>, +enum AnimationContext { + Animation { + /// The nearest ancestor animation root. + root: Entity, + /// The path to the animation root. This is used for constructing the + /// animation target UUIDs. + path: SmallVec<[Name; 8]>, + }, + JustTargetId { + /// The path to the animation root. This is used for constructing the + /// animation target UUIDs. + path: SmallVec<[Name; 8]>, + }, +} + +#[cfg(feature = "bevy_animation")] +impl AnimationContext { + fn push_name(&mut self, name: Name) { + let path = match self { + Self::JustTargetId { path } | Self::Animation { root: _, path } => path, + }; + + path.push(name); + } + + fn insert_to_entity(&self, node: &mut EntityWorldMut) { + match self { + Self::Animation { root, path } => { + node.insert(AnimationTarget { + id: AnimationTargetId::from_names(path.iter()), + player: *root, + }); + } + Self::JustTargetId { path } => { + node.insert(AnimationTargetId::from_names(path.iter())); + } + } + } } #[derive(Deserialize)] diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 073de918bd955..90e5c7915fa36 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -172,6 +172,9 @@ bevy_ci_testing = ["bevy_dev_tools/bevy_ci_testing", "bevy_render?/ci_limits"] # Enable animation support, and glTF animation loading animation = ["bevy_animation", "bevy_gltf?/bevy_animation"] +# Enables bone attachments +bevy_bone_attachments = ["dep:bevy_bone_attachments", "bevy_animation"] + bevy_sprite = ["dep:bevy_sprite", "bevy_gizmos", "bevy_image"] bevy_pbr = ["dep:bevy_pbr", "bevy_gizmos?/bevy_pbr", "bevy_image"] bevy_window = ["dep:bevy_window", "dep:bevy_a11y"] @@ -201,6 +204,7 @@ bevy_render = [ "bevy_color/wgpu-types", "bevy_color/encase", ] +bevy_scene = ["dep:bevy_scene", "bevy_bone_attachments?/bevy_scene"] # Enable assertions to check the validity of parameters passed to glam glam_assert = ["bevy_math/glam_assert"] @@ -353,6 +357,7 @@ web = [ bevy_app = { path = "../bevy_app", version = "0.16.0-dev", default-features = false, features = [ "bevy_reflect", ] } +bevy_bone_attachments = { path = "../bevy_bone_attachments", version = "0.16.0-dev", default-features = false, optional = true } bevy_derive = { path = "../bevy_derive", version = "0.16.0-dev", default-features = false } bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.16.0-dev", default-features = false } bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev", default-features = false, features = [ diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 259a8c89226cd..0af7a408bfd81 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -58,6 +58,8 @@ plugin_group! { bevy_gilrs:::GilrsPlugin, #[cfg(feature = "bevy_animation")] bevy_animation:::AnimationPlugin, + #[cfg(feature = "bevy_bone_attachments")] + bevy_bone_attachments:::BoneAttachmentsPlugin, #[cfg(feature = "bevy_gizmos")] bevy_gizmos:::GizmoPlugin, #[cfg(feature = "bevy_state")] diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index 37bcef26b2ad7..817b799eea0c8 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -23,6 +23,8 @@ pub use bevy_app as app; pub use bevy_asset as asset; #[cfg(feature = "bevy_audio")] pub use bevy_audio as audio; +#[cfg(feature = "bevy_bone_attachments")] +pub use bevy_bone_attachments as bone_attachments; #[cfg(feature = "bevy_color")] pub use bevy_color as color; #[cfg(feature = "bevy_core_pipeline")] diff --git a/crates/bevy_internal/src/prelude.rs b/crates/bevy_internal/src/prelude.rs index 9f7dd8f33e267..bd72f61a7b134 100644 --- a/crates/bevy_internal/src/prelude.rs +++ b/crates/bevy_internal/src/prelude.rs @@ -34,6 +34,10 @@ pub use crate::audio::prelude::*; #[cfg(feature = "bevy_animation")] pub use crate::animation::prelude::*; +#[doc(hidden)] +#[cfg(feature = "bevy_bone_attachments")] +pub use crate::bone_attachments::prelude::*; + #[doc(hidden)] #[cfg(feature = "bevy_color")] pub use crate::color::prelude::*; diff --git a/docs/cargo_features.md b/docs/cargo_features.md index a96c2697f5ddd..7a2906286f06d 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -62,6 +62,7 @@ The default feature set enables most of the expected features of a game engine, |asset_processor|Enables the built-in asset processor for processed assets.| |async-io|Use async-io's implementation of block_on instead of futures-lite's implementation. This is preferred if your application uses async-io.| |basis-universal|Basis Universal compressed texture support| +|bevy_bone_attachments|Provides methods to attach an entity to another| |bevy_ci_testing|Enable systems that allow for automated testing on CI| |bevy_debug_stepping|Enable stepping-based debugging of Bevy systems| |bevy_dev_tools|Provides a collection of developer tools| diff --git a/examples/README.md b/examples/README.md index 1d18be05ce8c6..49f1d8bbfbf6d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -209,6 +209,7 @@ Example | Description [Animation Events](../examples/animation/animation_events.rs) | Demonstrate how to use animation events [Animation Graph](../examples/animation/animation_graph.rs) | Blends multiple animations together with a graph [Animation Masks](../examples/animation/animation_masks.rs) | Demonstrates animation masks +[Bone Attachments](../examples/animation/bone_attachments.rs) | Shows attaching a model to another [Color animation](../examples/animation/color_animation.rs) | Demonstrates how to animate colors using mixing and splines in different color spaces [Custom Skinned Mesh](../examples/animation/custom_skinned_mesh.rs) | Skinned mesh example with mesh and joints data defined in code [Eased Motion](../examples/animation/eased_motion.rs) | Demonstrates the application of easing curves to animate an object diff --git a/examples/animation/bone_attachments.rs b/examples/animation/bone_attachments.rs new file mode 100644 index 0000000000000..8b833b35efe00 --- /dev/null +++ b/examples/animation/bone_attachments.rs @@ -0,0 +1,155 @@ +//! Shows attaching a model to another + +use std::f32::consts::PI; + +use bevy::{ + bone_attachments::scene::SceneAttachmentExt, gltf::GltfLoaderSettings, + pbr::CascadeShadowConfigBuilder, prelude::*, scene::SceneInstanceReady, +}; + +// Example asset that contains a mesh, animation, and attachment. +const GLTF_PATH: &str = "models/animated/Fox.glb"; +const ATTACHMENT_PATH: &str = "models/animated/FoxAttachment.glb"; + +fn main() { + App::new() + .insert_resource(AmbientLight { + color: Color::WHITE, + brightness: 2000., + ..default() + }) + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup_mesh_and_animation) + .add_systems(Startup, setup_camera_and_environment) + .run(); +} + +// A component that stores a reference to an animation we want to play. This is +// created when we start loading the mesh (see `setup_mesh_and_animation`) and +// read when the mesh has spawned (see `play_animation_once_loaded`). +#[derive(Component)] +struct AnimationToPlay { + graph_handle: Handle, + index: AnimationNodeIndex, +} + +fn setup_mesh_and_animation( + mut commands: Commands, + asset_server: Res, + mut graphs: ResMut>, +) { + // Create an animation graph containing a single animation. We want the "run" + // animation from our example asset, which has an index of two. + let (graph, index) = AnimationGraph::from_clip( + asset_server.load(GltfAssetLabel::Animation(2).from_asset(GLTF_PATH)), + ); + + // Store the animation graph as an asset. + let graph_handle = graphs.add(graph); + + // Create a component that stores a reference to our animation. + let animation_to_play = AnimationToPlay { + graph_handle, + index, + }; + + // Start loading the assets as a scene and store a reference to it in a + // SceneRoot component. This component will automatically spawn a scene + // containing our mesh once it has loaded. + let mesh_scene = SceneRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(GLTF_PATH))); + + // Spawn an entity with our components, and connect it to an observer that + // will trigger when the scene is loaded and spawned. + commands + .spawn((animation_to_play, mesh_scene)) + .observe(play_animation_when_ready) + .observe(attach_helm); +} + +fn play_animation_when_ready( + trigger: Trigger, + mut commands: Commands, + children: Query<&Children>, + animations_to_play: Query<&AnimationToPlay>, + mut players: Query<&mut AnimationPlayer>, +) { + // The entity we spawned in `setup_mesh_and_animation` is the trigger's target. + // Start by finding the AnimationToPlay component we added to that entity. + if let Ok(animation_to_play) = animations_to_play.get(trigger.target()) { + // The SceneRoot component will have spawned the scene as a hierarchy + // of entities parented to our entity. Since the asset contained a skinned + // mesh and animations, it will also have spawned an animation player + // component. Search our entity's descendants to find the animation player. + for child in children.iter_descendants(trigger.target()) { + if let Ok(mut player) = players.get_mut(child) { + // Tell the animation player to start the animation and keep + // repeating it. + // + // If you want to try stopping and switching animations, see the + // `animated_mesh_control.rs` example. + player.play(animation_to_play.index).repeat(); + + // Add the animation graph. This only needs to be done once to + // connect the animation player to the mesh. + commands + .entity(child) + .insert(AnimationGraphHandle(animation_to_play.graph_handle.clone())); + } + } + } +} + +/// Attaches a helm to the fox, this needs to wait for the scene of the fox to finish loading to be +/// able to query for the [`AnimationTarget`](bevy::animation::AnimationTarget)s +fn attach_helm( + trigger: Trigger, + mut commands: Commands, + asset_server: Res, +) { + // Start loading if the attachment. + let attachment_scene = asset_server.load_with_settings( + GltfAssetLabel::Scene(0).from_asset(ATTACHMENT_PATH), + |settings: &mut GltfLoaderSettings| { + settings.include_animation_target_ids = true; + }, + ); + + // Spawns an entity attached to the entity above + commands + .entity(trigger.target()) + .attach_scene(attachment_scene); +} + +// Spawn a camera and a simple environment with a ground plane and light. +fn setup_camera_and_environment( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // Camera + commands.spawn(( + Camera3d::default(), + Transform::from_xyz(100.0, 100.0, 150.0).looking_at(Vec3::new(0.0, 20.0, 0.0), Vec3::Y), + )); + + // Plane + commands.spawn(( + Mesh3d(meshes.add(Plane3d::default().mesh().size(500000.0, 500000.0))), + MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))), + )); + + // Light + commands.spawn(( + Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, 1.0, -PI / 4.)), + DirectionalLight { + shadows_enabled: true, + ..default() + }, + CascadeShadowConfigBuilder { + first_cascade_far_bound: 200.0, + maximum_distance: 400.0, + ..default() + } + .build(), + )); +}