From d7594a4f3c279da43d48a728636cd713747ceac1 Mon Sep 17 00:00:00 2001 From: Fallible Things <118682743+fallible-algebra@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:55:38 +0100 Subject: [PATCH 01/19] Explanation for the 'classic' fog example (#19196) This is a bit of a test case in writing the [explanation](https://bevyengine.org/learn/contribute/helping-out/explaining-examples/) for an example whose subject (`DistanceFog` as a component on cameras) is the focus, but isn't that complicated either. Not certain if this could be an exception or something more common. Putting the controls below the explanation, as they're more of a fall-back for the on-screen info (also that's where they were before). --- examples/3d/fog.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/3d/fog.rs b/examples/3d/fog.rs index cbb8c0fa3b..82bce0b15a 100644 --- a/examples/3d/fog.rs +++ b/examples/3d/fog.rs @@ -1,5 +1,12 @@ -//! This interactive example shows how to use distance fog, -//! and allows playing around with different fog settings. +//! Distance-based fog visual effects are used in many games to give a soft falloff of visibility to the player for performance and/or visual design reasons. The further away something in a 3D world is from the camera, the more it's mixed or completely overwritten by a given color. +//! +//! In Bevy we can add the [`DistanceFog`] component to the same entity as our [`Camera3d`] to apply a distance fog effect. It has fields for color, directional light parameters, and how the fog falls off over distance. And that's it! The distance fog is now applied to the camera. +//! +//! The [`FogFalloff`] field controls most of the behavior of the fog through different descriptions of fog "curves". I.e. [`FogFalloff::Linear`] lets us define a start and end distance where up until the start distance none of the fog color is mixed in and by the end distance the fog color is as mixed in as it can be. [`FogFalloff::Exponential`] on the other hand uses an exponential curve to drive how "visible" things are with a density value. +//! +//! [Atmospheric fog](https://bevyengine.org/examples/3d-rendering/atmospheric-fog/) is another fog type that uses this same method of setup, but isn't covered here as it is a kind of fog that is most often used to imply distance and size in clear weather, while the ones shown off here are much more "dense". +//! +//! The bulk of this example is spent building a scene that suites showing off that the fog is working as intended by creating a pyramid (a 3D structure with clear delineations), a light source, input handling to modify fog settings, and UI to show what the current fog settings are. //! //! ## Controls //! From da83232fa874470c35ceadb177df6faef79491d9 Mon Sep 17 00:00:00 2001 From: Testare Date: Mon, 23 Jun 2025 14:05:04 -0700 Subject: [PATCH 02/19] Let Component::map_entities defer to MapEntities (#19414) # Objective The objective of this PR is to enable Components to use their `MapEntities` implementation for `Component::map_entities`. With the improvements to the entity mapping system, there is definitely a huge reduction in boilerplate. However, especially since `(Entity)HashMap<..>` doesn't implement `MapEntities` (I presume because the lack of specialization in rust makes `HashMap` complicated), when somebody has types that contain these hashmaps they can't use this approach. More so, we can't even depend on the previous implementation, since `Component::map_entities` is used instead of `MapEntities::map_entities`. Outside of implementing `Component `and `Component::map_entities` on these types directly, the only path forward is to create a custom type to wrap the hashmaps and implement map entities on that, or split these components into a wrapper type that implement `Component`, and an inner type that implements `MapEntities`. ## Current Solution The solution was to allow adding `#[component(map_entities)]` on the component. By default this will defer to the `MapEntities` implementation. ```rust #[derive(Component)] #[component(map_entities)] struct Inventory { items: HashMap } impl MapEntities for Inventory { fn map_entities(&mut self, entity_mapper: &mut M) { self.items = self.items .drain() .map(|(id, count)|(entity_mapper.get_mapped(id), count)) .collect(); } } ``` You can use `#[component(map_entities = )]` instead to substitute other code in for components. This function can also include generics, but sso far I haven't been able to find a case where they are needed. ```rust #[derive(Component)] #[component(map_entities = map_the_map)] // Also works #[component(map_entities = map_the_map::)] struct Inventory { items: HashMap } fn map_the_map(inv: &mut Inventory, entity_mapper: &mut M) { inv.items = inv.items .drain() .map(|(id, count)|(entity_mapper.get_mapped(id), count)) .collect(); } ``` The idea is that with the previous changes to MapEntities, MapEntities is implemented more for entity collections than for Components. If you have a component that makes sense as both, `#[component(map_entities)]` would work great, while otherwise a component can use `#[component(map_entities = )]` to change the behavior of `Component::map_entities` without opening up the component type to be included in other components. ## (Original Solution if you want to follow the PR) The solution was to allow adding `#[component(entities)]` on the component itself to defer to the `MapEntities` implementation ```rust #[derive(Component)] #[component(entities)] struct Inventory { items: HashMap } impl MapEntities for Inventory { fn map_entities(&mut self, entity_mapper: &mut M) { self.items = self.items .drain() .map(|(id, count)|(entity_mapper.get_mapped(id), count)) .collect(); } } ``` ## Testing I tested this by patching my local changes into my own bevy project. I had a system that loads a scene file and executes some logic with a Component that contains a `HashMap`, and it panics when Entity is not found from another query. Since the 0.16 update this system has reliably panicked upon attempting to the load the scene. After patching my code in, I added `#[component(entities)]` to this component, and I was able to successfully load the scene. Additionally, I wrote a doc test. ## Call-outs ### Relationships This overrules the default mapping of relationship fields. Anything else seemed more problematic, as you'd have inconsistent behavior between `MapEntities` and `Component`. --- crates/bevy_ecs/macros/src/component.rs | 67 +++++++++++++++++++++++++ crates/bevy_ecs/macros/src/lib.rs | 2 + crates/bevy_ecs/src/component.rs | 59 ++++++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs index ef7fad99f4..040c1b26b6 100644 --- a/crates/bevy_ecs/macros/src/component.rs +++ b/crates/bevy_ecs/macros/src/component.rs @@ -128,9 +128,11 @@ pub fn derive_component(input: TokenStream) -> TokenStream { let map_entities = map_entities( &ast.data, + &bevy_ecs_path, Ident::new("this", Span::call_site()), relationship.is_some(), relationship_target.is_some(), + attrs.map_entities ).map(|map_entities_impl| quote! { fn map_entities(this: &mut Self, mapper: &mut M) { use #bevy_ecs_path::entity::MapEntities; @@ -339,10 +341,19 @@ const ENTITIES: &str = "entities"; pub(crate) fn map_entities( data: &Data, + bevy_ecs_path: &Path, self_ident: Ident, is_relationship: bool, is_relationship_target: bool, + map_entities_attr: Option, ) -> Option { + if let Some(map_entities_override) = map_entities_attr { + let map_entities_tokens = map_entities_override.to_token_stream(bevy_ecs_path); + return Some(quote!( + #map_entities_tokens(#self_ident, mapper) + )); + } + match data { Data::Struct(DataStruct { fields, .. }) => { let mut map = Vec::with_capacity(fields.len()); @@ -430,6 +441,7 @@ pub const ON_INSERT: &str = "on_insert"; pub const ON_REPLACE: &str = "on_replace"; pub const ON_REMOVE: &str = "on_remove"; pub const ON_DESPAWN: &str = "on_despawn"; +pub const MAP_ENTITIES: &str = "map_entities"; pub const IMMUTABLE: &str = "immutable"; pub const CLONE_BEHAVIOR: &str = "clone_behavior"; @@ -484,6 +496,56 @@ impl Parse for HookAttributeKind { } } +#[derive(Debug)] +pub(super) enum MapEntitiesAttributeKind { + /// expressions like function or struct names + /// + /// structs will throw compile errors on the code generation so this is safe + Path(ExprPath), + /// When no value is specified + Default, +} + +impl MapEntitiesAttributeKind { + fn from_expr(value: Expr) -> Result { + match value { + Expr::Path(path) => Ok(Self::Path(path)), + // throw meaningful error on all other expressions + _ => Err(syn::Error::new( + value.span(), + [ + "Not supported in this position, please use one of the following:", + "- path to function", + "- nothing to default to MapEntities implementation", + ] + .join("\n"), + )), + } + } + + fn to_token_stream(&self, bevy_ecs_path: &Path) -> TokenStream2 { + match self { + MapEntitiesAttributeKind::Path(path) => path.to_token_stream(), + MapEntitiesAttributeKind::Default => { + quote!( + ::map_entities + ) + } + } + } +} + +impl Parse for MapEntitiesAttributeKind { + fn parse(input: syn::parse::ParseStream) -> Result { + if input.peek(Token![=]) { + input.parse::()?; + input.parse::().and_then(Self::from_expr) + } else { + Ok(Self::Default) + } + } +} + struct Attrs { storage: StorageTy, requires: Option>, @@ -496,6 +558,7 @@ struct Attrs { relationship_target: Option, immutable: bool, clone_behavior: Option, + map_entities: Option, } #[derive(Clone, Copy)] @@ -535,6 +598,7 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { relationship_target: None, immutable: false, clone_behavior: None, + map_entities: None, }; let mut require_paths = HashSet::new(); @@ -573,6 +637,9 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { } else if nested.path.is_ident(CLONE_BEHAVIOR) { attrs.clone_behavior = Some(nested.value()?.parse()?); Ok(()) + } else if nested.path.is_ident(MAP_ENTITIES) { + attrs.map_entities = Some(nested.input.parse::()?); + Ok(()) } else { Err(nested.error("Unsupported attribute")) } diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 7750f97259..9bc3e5913e 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -220,9 +220,11 @@ pub fn derive_map_entities(input: TokenStream) -> TokenStream { let map_entities_impl = map_entities( &ast.data, + &ecs_path, Ident::new("self", Span::call_site()), false, false, + None, ); let struct_name = &ast.ident; diff --git a/crates/bevy_ecs/src/component.rs b/crates/bevy_ecs/src/component.rs index 831402240d..615c5903f8 100644 --- a/crates/bevy_ecs/src/component.rs +++ b/crates/bevy_ecs/src/component.rs @@ -578,6 +578,65 @@ pub trait Component: Send + Sync + 'static { /// items: Vec> /// } /// ``` + /// + /// You might need more specialized logic. A likely cause of this is your component contains collections of entities that + /// don't implement [`MapEntities`](crate::entity::MapEntities). In that case, you can annotate your component with + /// `#[component(map_entities)]`. Using this attribute, you must implement `MapEntities` for the + /// component itself, and this method will simply call that implementation. + /// + /// ``` + /// # use bevy_ecs::{component::Component, entity::{Entity, MapEntities, EntityMapper}}; + /// # use std::collections::HashMap; + /// #[derive(Component)] + /// #[component(map_entities)] + /// struct Inventory { + /// items: HashMap + /// } + /// + /// impl MapEntities for Inventory { + /// fn map_entities(&mut self, entity_mapper: &mut M) { + /// self.items = self.items + /// .drain() + /// .map(|(id, count)|(entity_mapper.get_mapped(id), count)) + /// .collect(); + /// } + /// } + /// # let a = Entity::from_bits(0x1_0000_0001); + /// # let b = Entity::from_bits(0x1_0000_0002); + /// # let mut inv = Inventory { items: Default::default() }; + /// # inv.items.insert(a, 10); + /// # ::map_entities(&mut inv, &mut (a,b)); + /// # assert_eq!(inv.items.get(&b), Some(&10)); + /// ```` + /// + /// Alternatively, you can specify the path to a function with `#[component(map_entities = function_path)]`, similar to component hooks. + /// In this case, the inputs of the function should mirror the inputs to this method, with the second parameter being generic. + /// + /// ``` + /// # use bevy_ecs::{component::Component, entity::{Entity, MapEntities, EntityMapper}}; + /// # use std::collections::HashMap; + /// #[derive(Component)] + /// #[component(map_entities = map_the_map)] + /// // Also works: map_the_map:: or map_the_map::<_> + /// struct Inventory { + /// items: HashMap + /// } + /// + /// fn map_the_map(inv: &mut Inventory, entity_mapper: &mut M) { + /// inv.items = inv.items + /// .drain() + /// .map(|(id, count)|(entity_mapper.get_mapped(id), count)) + /// .collect(); + /// } + /// # let a = Entity::from_bits(0x1_0000_0001); + /// # let b = Entity::from_bits(0x1_0000_0002); + /// # let mut inv = Inventory { items: Default::default() }; + /// # inv.items.insert(a, 10); + /// # ::map_entities(&mut inv, &mut (a,b)); + /// # assert_eq!(inv.items.get(&b), Some(&10)); + /// ```` + /// + /// You can use the turbofish (`::`) to specify parameters when a function is generic, using either M or _ for the type of the mapper parameter. #[inline] fn map_entities(_this: &mut Self, _mapper: &mut E) {} } From d3ad66f0335122b18c5acd227def27435e4a5c9d Mon Sep 17 00:00:00 2001 From: Rob Parrett Date: Mon, 23 Jun 2025 15:32:46 -0700 Subject: [PATCH 03/19] Fix some typos (#19788) # Objective - Notice a word duplication typo - Small quest to fix similar or nearby typos with my faithful companion `\b(\w+)\s+\1\b` ## Solution Fix em --- crates/bevy_asset/src/lib.rs | 6 +++--- crates/bevy_ecs/src/hierarchy.rs | 4 ++-- crates/bevy_pbr/src/material_bind_groups.rs | 2 +- crates/bevy_reflect/src/lib.rs | 2 +- crates/bevy_sprite/src/picking_backend.rs | 2 +- .../relative_cursor_position_is_object_centered.md | 2 +- release-content/release-notes/bevy_solari.md | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 4b29beae79..16da4313f0 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -264,7 +264,7 @@ pub struct AssetPlugin { /// [`AssetSource`](io::AssetSource). Subfolders within these folders are also valid. /// /// It is strongly discouraged to use [`Allow`](UnapprovedPathMode::Allow) if your -/// app will include scripts or modding support, as it could allow allow arbitrary file +/// app will include scripts or modding support, as it could allow arbitrary file /// access for malicious code. /// /// See [`AssetPath::is_unapproved`](crate::AssetPath::is_unapproved) @@ -272,10 +272,10 @@ pub struct AssetPlugin { pub enum UnapprovedPathMode { /// Unapproved asset loading is allowed. This is strongly discouraged. Allow, - /// Fails to load any asset that is is unapproved, unless an override method is used, like + /// Fails to load any asset that is unapproved, unless an override method is used, like /// [`AssetServer::load_override`]. Deny, - /// Fails to load any asset that is is unapproved. + /// Fails to load any asset that is unapproved. #[default] Forbid, } diff --git a/crates/bevy_ecs/src/hierarchy.rs b/crates/bevy_ecs/src/hierarchy.rs index d99e89b355..fe9bf571c9 100644 --- a/crates/bevy_ecs/src/hierarchy.rs +++ b/crates/bevy_ecs/src/hierarchy.rs @@ -330,7 +330,7 @@ impl<'w> EntityWorldMut<'w> { /// /// # Panics /// - /// Panics when debug assertions are enabled if an invariant is is broken and the command is executed. + /// Panics when debug assertions are enabled if an invariant is broken and the command is executed. pub fn replace_children_with_difference( &mut self, entities_to_unrelate: &[Entity], @@ -420,7 +420,7 @@ impl<'a> EntityCommands<'a> { /// /// # Panics /// - /// Panics when debug assertions are enabled if an invariant is is broken and the command is executed. + /// Panics when debug assertions are enabled if an invariant is broken and the command is executed. pub fn replace_children_with_difference( &mut self, entities_to_unrelate: &[Entity], diff --git a/crates/bevy_pbr/src/material_bind_groups.rs b/crates/bevy_pbr/src/material_bind_groups.rs index 735bc77c99..39028fed2d 100644 --- a/crates/bevy_pbr/src/material_bind_groups.rs +++ b/crates/bevy_pbr/src/material_bind_groups.rs @@ -2051,7 +2051,7 @@ impl MaterialDataBuffer { /// The size of the piece of data supplied to this method must equal the /// [`Self::aligned_element_size`] provided to [`MaterialDataBuffer::new`]. fn insert(&mut self, data: &[u8]) -> u32 { - // Make the the data is of the right length. + // Make sure the data is of the right length. debug_assert_eq!(data.len(), self.aligned_element_size as usize); // Grab a slot. diff --git a/crates/bevy_reflect/src/lib.rs b/crates/bevy_reflect/src/lib.rs index eabfdc0eac..8b50c4b5b2 100644 --- a/crates/bevy_reflect/src/lib.rs +++ b/crates/bevy_reflect/src/lib.rs @@ -1001,7 +1001,7 @@ mod tests { /// If we don't append the strings in the `TypePath` derive correctly (i.e. explicitly specifying the type), /// we'll get a compilation error saying that "`&String` cannot be added to `String`". /// - /// So this test just ensures that we do do that correctly. + /// So this test just ensures that we do that correctly. /// /// This problem is a known issue and is unexpectedly expected behavior: /// - diff --git a/crates/bevy_sprite/src/picking_backend.rs b/crates/bevy_sprite/src/picking_backend.rs index 57c1acc6bd..bde1a34b63 100644 --- a/crates/bevy_sprite/src/picking_backend.rs +++ b/crates/bevy_sprite/src/picking_backend.rs @@ -7,7 +7,7 @@ //! //! ## Implementation Notes //! -//! - The `position` reported in `HitData` in in world space, and the `normal` is a normalized +//! - The `position` reported in `HitData` in world space, and the `normal` is a normalized //! vector provided by the target's `GlobalTransform::back()`. use crate::{Anchor, Sprite}; diff --git a/release-content/migration-guides/relative_cursor_position_is_object_centered.md b/release-content/migration-guides/relative_cursor_position_is_object_centered.md index 3b21292814..eac785e4b4 100644 --- a/release-content/migration-guides/relative_cursor_position_is_object_centered.md +++ b/release-content/migration-guides/relative_cursor_position_is_object_centered.md @@ -3,4 +3,4 @@ title: RelativeCursorPosition is object-centered pull_requests: [16615] --- -`RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the the center of the node and the corners at (±0.5, ±0.5). Its `normalized_visible_node_rect` field has been removed and replaced with a new `cursor_over: bool` field which is set to true when the cursor is hovering a visible section of the UI node. +`RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the center of the node and the corners at (±0.5, ±0.5). Its `normalized_visible_node_rect` field has been removed and replaced with a new `cursor_over: bool` field which is set to true when the cursor is hovering a visible section of the UI node. diff --git a/release-content/release-notes/bevy_solari.md b/release-content/release-notes/bevy_solari.md index 862a138c8a..7e7d36ac31 100644 --- a/release-content/release-notes/bevy_solari.md +++ b/release-content/release-notes/bevy_solari.md @@ -10,7 +10,7 @@ In Bevy 0.17, we've made the first steps towards realtime raytraced lighting in For some background, lighting in video games can be split into two parts: direct and indirect lighting. -Direct lighting is light that that is emitted from a light source, bounces off of one surface, and then reaches the camera. Indirect lighting by contrast is light that bounces off of different surfaces many times before reaching the camera, and is often called global illumination. +Direct lighting is light that is emitted from a light source, bounces off of one surface, and then reaches the camera. Indirect lighting by contrast is light that bounces off of different surfaces many times before reaching the camera, and is often called global illumination. (TODO: Diagrams of direct vs indirect light) @@ -25,7 +25,7 @@ The problem with these methods is that they all have large downsides: Bevy Solari is intended as a completely alternate, high-end lighting solution for Bevy that uses GPU-accelerated raytracing to fix all of the above problems. Emissive meshes will properly cast light and shadows, you will be able to have hundreds of shadow casting lights, quality will be much better, it will require no baking time, and it will support _fully_ dynamic scenes! -While Bevy 0.17 adds the bevy_solari crate, it's intended as a long-term project. Currently there is only a non-realtime path tracer intended as a reference and testbed for developing Bevy Solari. There is nothing usable yet for game developers. However, feel free to run the solari example to see the path tracer in action, and look forwards to more work on Bevy Solari in future releases! (TODO: Is this burying the lede?) +While Bevy 0.17 adds the bevy_solari crate, it's intended as a long-term project. Currently there is only a non-realtime path tracer intended as a reference and testbed for developing Bevy Solari. There is nothing usable yet for game developers. However, feel free to run the solari example to see the path tracer in action, and look forward to more work on Bevy Solari in future releases! (TODO: Is this burying the lede?) (TODO: Embed bevy_solari logo here, or somewhere else that looks good) From 3f187cf7526ab375f33a2f43c85f4d1d0fa3f8e2 Mon Sep 17 00:00:00 2001 From: Conner Petzold <96224+ConnerPetzold@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:55:10 -0400 Subject: [PATCH 04/19] Add TilemapChunk rendering (#18866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective An attempt to start building a base for first-party tilemaps (#13782). The objective is to create a very simple tilemap chunk rendering plugin that can be used as a building block for 3rd-party tilemap crates, and eventually a first-party tilemap implementation. ## Solution - Introduces two user-facing components, `TilemapChunk` and `TilemapChunkIndices`, and a new material `TilemapChunkMaterial`. - `TilemapChunk` holds the chunk and tile sizes, and the tileset image - The tileset image is expected to be a layered image for use with `texture_2d_array`, with the assumption that atlases or multiple images would go through an asset loader/processor. Not sure if that should be part of this PR or not.. - `TilemapChunkIndices` holds a 1d representation of all of the tile's Option index into the tileset image. - Indices are fixed to the size of tiles in a chunk (though maybe this should just be an assertion instead?) - Indices are cloned and sent to the shader through a u32 texture. ## Testing - Initial testing done with the `tilemap_chunk` example, though I need to include some way to update indices as part of it. - Tested wasm with webgl2 and webgpu - I'm thinking it would probably be good to do some basic perf testing. --- ## Showcase ```rust let chunk_size = UVec2::splat(64); let tile_size = UVec2::splat(16); let indices: Vec> = (0..chunk_size.x * chunk_size.y) .map(|_| rng.gen_range(0..5)) .map(|i| if i == 0 { None } else { Some(i - 1) }) .collect(); commands.spawn(( TilemapChunk { chunk_size, tile_size, tileset, }, TilemapChunkIndices(indices), )); ``` ![Screenshot 2025-04-17 at 11 54 56 PM](https://github.com/user-attachments/assets/850a53c1-16fc-405d-aad2-8ef5a0060fea) --- Cargo.toml | 11 + crates/bevy_sprite/src/lib.rs | 9 +- crates/bevy_sprite/src/tilemap_chunk/mod.rs | 263 ++++++++++++++++++ .../tilemap_chunk/tilemap_chunk_material.rs | 70 +++++ .../tilemap_chunk/tilemap_chunk_material.wgsl | 58 ++++ examples/2d/tilemap_chunk.rs | 70 +++++ examples/README.md | 1 + .../release-notes/tilemap-chunk-rendering.md | 25 ++ 8 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 crates/bevy_sprite/src/tilemap_chunk/mod.rs create mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs create mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl create mode 100644 examples/2d/tilemap_chunk.rs create mode 100644 release-content/release-notes/tilemap-chunk-rendering.md diff --git a/Cargo.toml b/Cargo.toml index e6c75611ba..b082f166c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -845,6 +845,17 @@ description = "Generates a texture atlas (sprite sheet) from individual sprites" category = "2D Rendering" wasm = false +[[example]] +name = "tilemap_chunk" +path = "examples/2d/tilemap_chunk.rs" +doc-scrape-examples = true + +[package.metadata.example.tilemap_chunk] +name = "Tilemap Chunk" +description = "Renders a tilemap chunk" +category = "2D Rendering" +wasm = true + [[example]] name = "transparency_2d" path = "examples/2d/transparency_2d.rs" diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 882ec5857c..3e15499dd0 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -16,6 +16,7 @@ mod picking_backend; mod render; mod sprite; mod texture_slice; +mod tilemap_chunk; /// The sprite prelude. /// @@ -40,6 +41,7 @@ pub use picking_backend::*; pub use render::*; pub use sprite::*; pub use texture_slice::*; +pub use tilemap_chunk::*; use bevy_app::prelude::*; use bevy_asset::{embedded_asset, AssetEventSystems, Assets}; @@ -87,7 +89,12 @@ impl Plugin for SpritePlugin { .register_type::() .register_type::() .register_type::() - .add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin)) + .add_plugins(( + Mesh2dRenderPlugin, + ColorMaterialPlugin, + TilemapChunkPlugin, + TilemapChunkMaterialPlugin, + )) .add_systems( PostUpdate, ( diff --git a/crates/bevy_sprite/src/tilemap_chunk/mod.rs b/crates/bevy_sprite/src/tilemap_chunk/mod.rs new file mode 100644 index 0000000000..6ca4b7f77a --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/mod.rs @@ -0,0 +1,263 @@ +use crate::{AlphaMode2d, Anchor, MeshMaterial2d}; +use bevy_app::{App, Plugin, Update}; +use bevy_asset::{Assets, Handle, RenderAssetUsages}; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::{ + component::Component, + entity::Entity, + lifecycle::Add, + observer::On, + query::Changed, + resource::Resource, + system::{Commands, Query, ResMut}, +}; +use bevy_image::{Image, ImageSampler}; +use bevy_math::{FloatOrd, UVec2, Vec2, Vec3}; +use bevy_platform::collections::HashMap; +use bevy_render::{ + mesh::{Indices, Mesh, Mesh2d, PrimitiveTopology}, + render_resource::{ + Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, + }, +}; +use tracing::warn; + +mod tilemap_chunk_material; + +pub use tilemap_chunk_material::*; + +/// Plugin that handles the initialization and updating of tilemap chunks. +/// Adds systems for processing newly added tilemap chunks and updating their indices. +pub struct TilemapChunkPlugin; + +impl Plugin for TilemapChunkPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_observer(on_add_tilemap_chunk) + .add_systems(Update, update_tilemap_chunk_indices); + } +} + +type TilemapChunkMeshCacheKey = (UVec2, FloatOrd, FloatOrd, FloatOrd, FloatOrd); + +/// A resource storing the meshes for each tilemap chunk size. +#[derive(Resource, Default, Deref, DerefMut)] +pub struct TilemapChunkMeshCache(HashMap>); + +/// A component representing a chunk of a tilemap. +/// Each chunk is a rectangular section of tiles that is rendered as a single mesh. +#[derive(Component, Clone, Debug, Default)] +#[require(Mesh2d, MeshMaterial2d, TilemapChunkIndices, Anchor)] +pub struct TilemapChunk { + /// The size of the chunk in tiles + pub chunk_size: UVec2, + /// The size to use for each tile, not to be confused with the size of a tile in the tileset image. + /// The size of the tile in the tileset image is determined by the tileset image's dimensions. + pub tile_display_size: UVec2, + /// Handle to the tileset image containing all tile textures + pub tileset: Handle, + /// The alpha mode to use for the tilemap chunk + pub alpha_mode: AlphaMode2d, +} + +/// Component storing the indices of tiles within a chunk. +/// Each index corresponds to a specific tile in the tileset. +#[derive(Component, Clone, Debug, Default, Deref, DerefMut)] +pub struct TilemapChunkIndices(pub Vec>); + +fn on_add_tilemap_chunk( + trigger: On, + tilemap_chunk_query: Query<(&TilemapChunk, &TilemapChunkIndices, &Anchor)>, + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + mut images: ResMut>, + mut tilemap_chunk_mesh_cache: ResMut, +) { + let chunk_entity = trigger.target(); + let Ok(( + TilemapChunk { + chunk_size, + tile_display_size, + tileset, + alpha_mode, + }, + indices, + anchor, + )) = tilemap_chunk_query.get(chunk_entity) + else { + warn!("Tilemap chunk {} not found", chunk_entity); + return; + }; + + let expected_indices_length = chunk_size.element_product() as usize; + if indices.len() != expected_indices_length { + warn!( + "Invalid indices length for tilemap chunk {} of size {}. Expected {}, got {}", + chunk_entity, + chunk_size, + indices.len(), + expected_indices_length + ); + return; + } + + let indices_image = make_chunk_image(chunk_size, &indices.0); + + let display_size = (chunk_size * tile_display_size).as_vec2(); + + let mesh_key: TilemapChunkMeshCacheKey = ( + *chunk_size, + FloatOrd(display_size.x), + FloatOrd(display_size.y), + FloatOrd(anchor.as_vec().x), + FloatOrd(anchor.as_vec().y), + ); + + let mesh = tilemap_chunk_mesh_cache + .entry(mesh_key) + .or_insert_with(|| meshes.add(make_chunk_mesh(chunk_size, &display_size, anchor))); + + commands.entity(chunk_entity).insert(( + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(TilemapChunkMaterial { + tileset: tileset.clone(), + indices: images.add(indices_image), + alpha_mode: *alpha_mode, + })), + )); +} + +fn update_tilemap_chunk_indices( + query: Query< + ( + Entity, + &TilemapChunk, + &TilemapChunkIndices, + &MeshMaterial2d, + ), + Changed, + >, + mut materials: ResMut>, + mut images: ResMut>, +) { + for (chunk_entity, TilemapChunk { chunk_size, .. }, indices, material) in query { + let expected_indices_length = chunk_size.element_product() as usize; + if indices.len() != expected_indices_length { + warn!( + "Invalid TilemapChunkIndices length for tilemap chunk {} of size {}. Expected {}, got {}", + chunk_entity, + chunk_size, + indices.len(), + expected_indices_length + ); + continue; + } + + let Some(material) = materials.get_mut(material.id()) else { + warn!( + "TilemapChunkMaterial not found for tilemap chunk {}", + chunk_entity + ); + continue; + }; + let Some(indices_image) = images.get_mut(&material.indices) else { + warn!( + "TilemapChunkMaterial indices image not found for tilemap chunk {}", + chunk_entity + ); + continue; + }; + let Some(data) = indices_image.data.as_mut() else { + warn!( + "TilemapChunkMaterial indices image data not found for tilemap chunk {}", + chunk_entity + ); + continue; + }; + data.clear(); + data.extend( + indices + .iter() + .copied() + .flat_map(|i| u16::to_ne_bytes(i.unwrap_or(u16::MAX))), + ); + } +} + +fn make_chunk_image(size: &UVec2, indices: &[Option]) -> Image { + Image { + data: Some( + indices + .iter() + .copied() + .flat_map(|i| u16::to_ne_bytes(i.unwrap_or(u16::MAX))) + .collect(), + ), + texture_descriptor: TextureDescriptor { + size: Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + dimension: TextureDimension::D2, + format: TextureFormat::R16Uint, + label: None, + mip_level_count: 1, + sample_count: 1, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }, + sampler: ImageSampler::nearest(), + texture_view_descriptor: None, + asset_usage: RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, + } +} + +fn make_chunk_mesh(size: &UVec2, display_size: &Vec2, anchor: &Anchor) -> Mesh { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, + ); + + let offset = display_size * (Vec2::splat(-0.5) - anchor.as_vec()); + + let num_quads = size.element_product() as usize; + let quad_size = display_size / size.as_vec2(); + + let mut positions = Vec::with_capacity(4 * num_quads); + let mut uvs = Vec::with_capacity(4 * num_quads); + let mut indices = Vec::with_capacity(6 * num_quads); + + for y in 0..size.y { + for x in 0..size.x { + let i = positions.len() as u32; + + let p0 = offset + quad_size * UVec2::new(x, y).as_vec2(); + let p1 = p0 + quad_size; + + positions.extend([ + Vec3::new(p0.x, p0.y, 0.0), + Vec3::new(p1.x, p0.y, 0.0), + Vec3::new(p0.x, p1.y, 0.0), + Vec3::new(p1.x, p1.y, 0.0), + ]); + + uvs.extend([ + Vec2::new(0.0, 1.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + ]); + + indices.extend([i, i + 2, i + 1]); + indices.extend([i + 3, i + 1, i + 2]); + } + } + + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + mesh.insert_indices(Indices::U32(indices)); + + mesh +} diff --git a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs new file mode 100644 index 0000000000..c8879a58f1 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs @@ -0,0 +1,70 @@ +use crate::{AlphaMode2d, Material2d, Material2dKey, Material2dPlugin}; +use bevy_app::{App, Plugin}; +use bevy_asset::{embedded_asset, embedded_path, Asset, AssetPath, Handle}; +use bevy_image::Image; +use bevy_reflect::prelude::*; +use bevy_render::{ + mesh::{Mesh, MeshVertexBufferLayoutRef}, + render_resource::*, +}; + +/// Plugin that adds support for tilemap chunk materials. +#[derive(Default)] +pub struct TilemapChunkMaterialPlugin; + +impl Plugin for TilemapChunkMaterialPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "tilemap_chunk_material.wgsl"); + + app.add_plugins(Material2dPlugin::::default()); + } +} + +/// Material used for rendering tilemap chunks. +/// +/// This material is used internally by the tilemap system to render chunks of tiles +/// efficiently using a single draw call per chunk. +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub struct TilemapChunkMaterial { + pub alpha_mode: AlphaMode2d, + + #[texture(0, dimension = "2d_array")] + #[sampler(1)] + pub tileset: Handle, + + #[texture(2, sample_type = "u_int")] + pub indices: Handle, +} + +impl Material2d for TilemapChunkMaterial { + fn fragment_shader() -> ShaderRef { + ShaderRef::Path( + AssetPath::from_path_buf(embedded_path!("tilemap_chunk_material.wgsl")) + .with_source("embedded"), + ) + } + + fn vertex_shader() -> ShaderRef { + ShaderRef::Path( + AssetPath::from_path_buf(embedded_path!("tilemap_chunk_material.wgsl")) + .with_source("embedded"), + ) + } + + fn alpha_mode(&self) -> AlphaMode2d { + self.alpha_mode + } + + fn specialize( + descriptor: &mut RenderPipelineDescriptor, + layout: &MeshVertexBufferLayoutRef, + _key: Material2dKey, + ) -> Result<(), SpecializedMeshPipelineError> { + let vertex_layout = layout.0.get_layout(&[ + Mesh::ATTRIBUTE_POSITION.at_shader_location(0), + Mesh::ATTRIBUTE_UV_0.at_shader_location(1), + ])?; + descriptor.vertex.buffers = vec![vertex_layout]; + Ok(()) + } +} diff --git a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl new file mode 100644 index 0000000000..7424995e22 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl @@ -0,0 +1,58 @@ +#import bevy_sprite::{ + mesh2d_functions as mesh_functions, + mesh2d_view_bindings::view, +} + +struct Vertex { + @builtin(instance_index) instance_index: u32, + @builtin(vertex_index) vertex_index: u32, + @location(0) position: vec3, + @location(1) uv: vec2, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) tile_index: u32, +} + +@group(2) @binding(0) var tileset: texture_2d_array; +@group(2) @binding(1) var tileset_sampler: sampler; +@group(2) @binding(2) var tile_indices: texture_2d; + +@vertex +fn vertex(vertex: Vertex) -> VertexOutput { + var out: VertexOutput; + + let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); + let world_position = mesh_functions::mesh2d_position_local_to_world( + world_from_local, + vec4(vertex.position, 1.0) + ); + + out.position = mesh_functions::mesh2d_position_world_to_clip(world_position); + out.uv = vertex.uv; + out.tile_index = vertex.vertex_index / 4u; + + return out; +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + let chunk_size = textureDimensions(tile_indices, 0); + let tile_xy = vec2( + in.tile_index % chunk_size.x, + in.tile_index / chunk_size.x + ); + let tile_id = textureLoad(tile_indices, tile_xy, 0).r; + + if tile_id == 0xffffu { + discard; + } + + let color = textureSample(tileset, tileset_sampler, in.uv, tile_id); + if color.a < 0.001 { + discard; + } + return color; +} \ No newline at end of file diff --git a/examples/2d/tilemap_chunk.rs b/examples/2d/tilemap_chunk.rs new file mode 100644 index 0000000000..35c2694ff5 --- /dev/null +++ b/examples/2d/tilemap_chunk.rs @@ -0,0 +1,70 @@ +//! Shows a tilemap chunk rendered with a single draw call. + +use bevy::{ + prelude::*, + sprite::{TilemapChunk, TilemapChunkIndices}, +}; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +fn main() { + App::new() + .add_plugins((DefaultPlugins.set(ImagePlugin::default_nearest()),)) + .add_systems(Startup, setup) + .add_systems(Update, (update_tileset_image, update_tilemap)) + .run(); +} + +#[derive(Component, Deref, DerefMut)] +struct UpdateTimer(Timer); + +fn setup(mut commands: Commands, assets: Res) { + let mut rng = ChaCha8Rng::seed_from_u64(42); + let chunk_size = UVec2::splat(64); + let tile_display_size = UVec2::splat(8); + let indices: Vec> = (0..chunk_size.element_product()) + .map(|_| rng.gen_range(0..5)) + .map(|i| if i == 0 { None } else { Some(i - 1) }) + .collect(); + + commands.spawn(( + TilemapChunk { + chunk_size, + tile_display_size, + tileset: assets.load("textures/array_texture.png"), + ..default() + }, + TilemapChunkIndices(indices), + UpdateTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + )); + + commands.spawn(Camera2d); +} + +fn update_tileset_image( + chunk_query: Single<&TilemapChunk>, + mut events: EventReader>, + mut images: ResMut>, +) { + let chunk = *chunk_query; + for event in events.read() { + if event.is_loaded_with_dependencies(chunk.tileset.id()) { + let image = images.get_mut(&chunk.tileset).unwrap(); + image.reinterpret_stacked_2d_as_array(4); + } + } +} + +fn update_tilemap(time: Res