From 2747b08d14f9facb12b6b234f8e486aaad5eef4f Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Thu, 23 Oct 2025 03:22:04 +0100 Subject: [PATCH 01/23] Bevy 0.17 finish migration --- Cargo.toml | 10 +++++----- examples/filter_layers.rs | 5 ++--- examples/state.rs | 7 ++----- examples/text.rs | 15 +++++++++------ examples/text_filter.rs | 15 ++++++++------- src/animation.rs | 2 +- src/filter.rs | 1 + 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c2a1c2d..598f4a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,16 +51,16 @@ bevy_shader = { version = "0.17.1", default-features = false, optional = true } bevy_core_pipeline = { version = "0.17.1", default-features = false, optional = true } bevy_picking = { version = "0.17.1", default-features = false, optional = true } bevy_input_focus = { version = "0.17.1", default-features = false, optional = true } -bevy_turborand = { version = "0.11.0", optional = true } -seldom_map_nav = { version = "0.9.0", optional = true } +bevy_turborand = { version = "0.12.0", optional = true } +seldom_map_nav = { version = "0.10.0", optional = true } seldom_pixel_macros = { version = "0.3.0-dev", path = "macros" } -seldom_state = { version = "0.14.0", optional = true } +seldom_state = { version = "0.15.0", optional = true } [dev-dependencies] bevy = "0.17.1" -leafwing-input-manager = "0.17.0" +leafwing-input-manager = "0.18.0" rand = "0.8.5" -seldom_state = { version = "0.14.0", features = ["leafwing_input"] } +seldom_state = { version = "0.15.0", features = ["leafwing_input"] } [[example]] name = "line" diff --git a/examples/filter_layers.rs b/examples/filter_layers.rs index 8c256a5..5011966 100644 --- a/examples/filter_layers.rs +++ b/examples/filter_layers.rs @@ -85,9 +85,8 @@ fn change_filter( 1 => PxFilterLayers::single_over(Layer::Middle(0)), // Filters the Back and Front layers specifically 2 => PxFilterLayers::Many(vec![Layer::Back, Layer::Front]), - // Filters every layer matched by this `Fn` - // Use `.into()` to convert a `Fn(&Layer) -> bool` to a `PxFilterLayers::Select` - 3 => (|layer: &Layer| matches!(layer, Layer::Middle(layer) if *layer >= 0)).into(), + // Filters all Middle(n) layers where n >= 0 (using layer ordering) + 3 => PxFilterLayers::Range(Layer::Middle(0)..=Layer::Middle(i32::MAX)), _ => unreachable!(), }, )); diff --git a/examples/state.rs b/examples/state.rs index cebff9f..5442117 100644 --- a/examples/state.rs +++ b/examples/state.rs @@ -17,7 +17,7 @@ fn main() { ..default() }), InputManagerPlugin::::default(), - StateMachinePlugin, + StateMachinePlugin::default(), PxPlugin::::new(UVec2::splat(16), "palette/palette_1.palette.png"), )) .insert_resource(ClearColor(Color::BLACK)) @@ -43,10 +43,7 @@ fn init(assets: Res, mut commands: Commands) { commands.spawn(( PxSprite(idle.clone()), PxPosition(IVec2::splat(8)), - InputManagerBundle { - input_map: InputMap::default().with(Action::Cast, KeyCode::Space), - ..default() - }, + InputMap::default().with(Action::Cast, KeyCode::Space), StateMachine::default() .trans::(just_pressed(Action::Cast), Cast) .on_enter::(move |entity| { diff --git a/examples/text.rs b/examples/text.rs index ecc5bab..7f93b7d 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -23,12 +23,15 @@ fn main() { fn init(assets: Res, mut cmd: Commands) { cmd.spawn(Camera2d); - // Spawn text. Since we want the text wrap automatically, we wrap it in UI. - PxContainer::build(PxText::build( - "THE MITOCHONDRIA IS THE POWERHOUSE OF THE CELL", - assets.load("typeface/typeface.px_typeface.png"), - )) - .spawn(&mut cmd); + // Spawn text. Since we want the text to wrap automatically, we wrap it in UI. + cmd.spawn(( + Layer, + PxUiRoot, + PxText::new( + "THE MITOCHONDRIA IS THE POWERHOUSE OF THE CELL", + assets.load("typeface/typeface.px_typeface.png"), + ), + )); } #[px_layer] diff --git a/examples/text_filter.rs b/examples/text_filter.rs index dbaba95..f7ed1c2 100644 --- a/examples/text_filter.rs +++ b/examples/text_filter.rs @@ -23,15 +23,16 @@ fn main() { fn init(assets: Res, mut cmd: Commands) { cmd.spawn(Camera2d); - // Spawn text. Since we want the text wrap automatically, we wrap it in UI. - PxContainer::build( - PxText::build( + // Spawn text. Since we want the text to wrap automatically, we wrap it in UI. + cmd.spawn(( + Layer, + PxUiRoot, + PxText::new( "THE MITOCHONDRIA IS THE POWERHOUSE OF THE CELL", assets.load("typeface/typeface.px_typeface.png"), - ) - .filter(assets.load("filter/dim.px_filter.png")), - ) - .spawn(&mut cmd); + ), + PxFilter(assets.load("filter/dim.px_filter.png")), + )); } #[px_layer] diff --git a/src/animation.rs b/src/animation.rs index cd82501..55a7d30 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -306,7 +306,7 @@ fn update_animations( } #[cfg(feature = "state")] PxAnimationFinishBehavior::Done => { - commands.entity(entity).insert(Done::Success); + cmd.entity(id).insert(Done::Success); } PxAnimationFinishBehavior::Loop => (), } diff --git a/src/filter.rs b/src/filter.rs index ec835df..12ef1b2 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -214,6 +214,7 @@ pub enum PxFilterLayers { /// is rendered, including the background color. clip: bool, }, + /// Filter applies to a range of layers. Uses layer ordering for enum variants with data. Range(RangeInclusive), /// Filter applies to a set list of layers Many(Vec), From addd23cf575919fcfe3d9259f09e6621a158c3b3 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Thu, 23 Oct 2025 13:41:48 +0100 Subject: [PATCH 02/23] run 'cargo fmt' --- src/line.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/line.rs b/src/line.rs index fa67343..f65e20c 100644 --- a/src/line.rs +++ b/src/line.rs @@ -1,11 +1,11 @@ use bevy_derive::{Deref, DerefMut}; use bevy_math::{ivec2, uvec2}; use bevy_platform::collections::HashSet; -use bevy_render::{sync_world::RenderEntity, Extract, RenderApp}; +use bevy_render::{Extract, RenderApp, sync_world::RenderEntity}; use line_drawing::Bresenham; use crate::{ - animation::{draw_frame, Frames}, + animation::{Frames, draw_frame}, filter::DefaultPxFilterLayers, image::PxImageSliceMut, position::{PxLayer, Spatial}, From 4bfe13e9c4823bdc5d6c4dbaa5a56bc57c9dd95e Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Sun, 16 Nov 2025 17:26:42 +0000 Subject: [PATCH 03/23] Update to only run once by setting the flag to after loading the palette --- src/screen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screen.rs b/src/screen.rs index 459b907..aa8fa40 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -220,7 +220,7 @@ fn init_screen( screen.palette = screen_palette; - *initialized = false; + *initialized = true; } #[cfg(feature = "headed")] From 74c85d3d09fdc9e8d323a47bcd98c32d41a0b7e3 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Sun, 16 Nov 2025 17:27:21 +0000 Subject: [PATCH 04/23] =?UTF-8?q?Harden=20=20to=20early=E2=80=91return=20o?= =?UTF-8?q?n=20empty/zero=E2=80=91width=20images=20and=20to=20stop=20trimm?= =?UTF-8?q?ing=20before=20=20could=20reach=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/image.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/image.rs b/src/image.rs index 15b2a72..e3be4e6 100644 --- a/src/image.rs +++ b/src/image.rs @@ -138,7 +138,13 @@ impl PxImage { } pub(crate) fn trim_right(&mut self) { - while (0..self.height()).all(|row| self.image[self.width * (row + 1) - 1] == 0) { + if self.width == 0 || self.image.is_empty() { + return; + } + + while self.width > 1 + && (0..self.height()).all(|row| self.image[self.width * (row + 1) - 1] == 0) + { for row in (0..self.height()).rev() { self.image.remove(row * self.width + self.width - 1); } From b12f6faa790725eb76fabfd9b9b0d84a4b5a487e Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Sun, 16 Nov 2025 17:27:57 +0000 Subject: [PATCH 05/23] Make return a proper error on failed format conversion and when there are more than 255 colors, instead of panicking or silently wrapping indices --- src/palette.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/palette.rs b/src/palette.rs index 18b6976..b45ac54 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -78,7 +78,9 @@ struct LoadingAssetPaletteHandle(Handle); impl Palette { /// Create a palette from an [`Image`] pub fn new(image: &Image) -> Result { - let image = image.convert(TextureFormat::Rgba8UnormSrgb).unwrap(); + let image = image + .convert(TextureFormat::Rgba8UnormSrgb) + .ok_or("could not convert palette image to `Rgba8UnormSrgb`")?; let data = image.data.ok_or("image is uninitialized")?; if data.get(3) != Some(&0) { @@ -105,6 +107,10 @@ impl Palette { }, ); + if colors.len() > 256 { + return Err("palette contains more than 255 colors".into()); + } + Ok(Palette { size: UVec2::new( image.texture_descriptor.size.width, From 8941b30ffe5c22170c4fa08545f8ec8c6b654a87 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Mon, 17 Nov 2025 17:09:14 +0000 Subject: [PATCH 06/23] fix: harden palette init and image trimming --- src/image.rs | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/image.rs b/src/image.rs index e3be4e6..68526d1 100644 --- a/src/image.rs +++ b/src/image.rs @@ -206,26 +206,24 @@ impl<'a> PxImageSliceMut<'a> { /// First `usize` is the index in the slice. Second `usize` is the index in the image. pub(crate) fn for_each_mut(&mut self, f: impl Fn(usize, usize, &mut u8)) { - let row_min = self.slice.min.x.clamp(0, self.width as i32) as usize; - let row_max = self.slice.max.x.clamp(0, self.width as i32) as usize; + let x_min = self.slice.min.x.clamp(0, self.width as i32) as usize; + let x_max = self.slice.max.x.clamp(0, self.width as i32) as usize; let max_y = self.image.len() as i32; - - self.image.iter_mut().enumerate().collect::>() - [self.slice.min.y.clamp(0, max_y) as usize..self.slice.max.y.clamp(0, max_y) as usize] - .iter_mut() - .for_each(|(i, row)| { - row.iter_mut().enumerate().collect::>()[row_min..row_max] - .iter_mut() - .for_each(|(j, pixel)| { - f( - ((*i as i32 - self.slice.min.y) * (self.slice.max.x - self.slice.min.x) - + (*j as i32 - self.slice.min.x)) - as usize, - *i * self.width + *j, - pixel, - ); - }); - }); + let y_min = self.slice.min.y.clamp(0, max_y) as usize; + let y_max = self.slice.max.y.clamp(0, max_y) as usize; + + let slice_width = (self.slice.max.x - self.slice.min.x).max(0) as usize; + + for (row_index, row) in self.image[y_min..y_max].iter_mut().enumerate() { + let y = y_min + row_index; + for x in x_min..x_max { + let slice_index = ((y as i32 - self.slice.min.y) * slice_width as i32 + + (x as i32 - self.slice.min.x)) as usize; + let image_index = y * self.width + x; + let pixel = &mut row[x]; + f(slice_index, image_index, pixel); + } + } } pub(crate) fn contains_pixel(&self, position: IVec2) -> bool { From 15f4761c07782ee6a2f0bf139d9c3efbeb7431a1 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Tue, 18 Nov 2025 02:44:04 +0000 Subject: [PATCH 07/23] perf: reuse render buffer for pixel compositor --- src/filter.rs | 2 +- src/screen.rs | 809 ++++++++++++++++++++++++++++---------------------- 2 files changed, 448 insertions(+), 363 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index 12ef1b2..cd8b8b3 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -34,7 +34,7 @@ pub(crate) fn plug(app: &mut App) { )); // R-A workaround - Assets::insert( + let _ = Assets::insert( &mut app .init_asset::() .init_asset_loader::() diff --git a/src/screen.rs b/src/screen.rs index aa8fa40..7db34c7 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -2,7 +2,7 @@ // TODO Split out a module -use std::{collections::BTreeMap, iter::empty, marker::PhantomData}; +use std::{collections::BTreeMap, iter::empty, marker::PhantomData, sync::RwLock}; use bevy_asset::uuid_handle; #[cfg(feature = "headed")] @@ -22,8 +22,9 @@ use bevy_render::{ BindGroupEntries, BindGroupLayout, BindGroupLayoutEntries, CachedRenderPipelineId, ColorTargetState, ColorWrites, DynamicUniformBuffer, Extent3d, FragmentState, PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, RenderPipelineDescriptor, - ShaderStages, ShaderType, TexelCopyBufferLayout, TextureDimension, TextureFormat, - TextureSampleType, TextureViewDescriptor, TextureViewDimension, VertexState, + ShaderStages, ShaderType, TexelCopyBufferLayout, Texture, TextureDescriptor, + TextureDimension, TextureFormat, TextureSampleType, TextureUsages, TextureViewDescriptor, + TextureViewDimension, VertexState, binding_types::{texture_2d, uniform_buffer}, }, renderer::{RenderContext, RenderDevice, RenderQueue}, @@ -102,7 +103,9 @@ impl Plugin for Plug { fn finish(&self, _app: &mut App) { #[cfg(feature = "headed")] - _app.sub_app_mut(RenderApp).init_resource::(); + _app.sub_app_mut(RenderApp) + .init_resource::() + .init_resource::(); } } @@ -323,6 +326,85 @@ impl FromWorld for PxPipeline { } } +#[cfg(feature = "headed")] +#[derive(Resource)] +struct PxRenderBuffer { + inner: RwLock, +} + +#[cfg(feature = "headed")] +struct PxRenderBufferInner { + size: UVec2, + image: Option, + texture: Option, +} + +#[cfg(feature = "headed")] +impl Default for PxRenderBuffer { + fn default() -> Self { + Self { + inner: RwLock::new(PxRenderBufferInner { + size: UVec2::ZERO, + image: None, + texture: None, + }), + } + } +} + +#[cfg(feature = "headed")] +impl PxRenderBuffer { + fn ensure_size(&self, device: &RenderDevice, size: UVec2) { + let mut inner = self.inner.write().unwrap(); + if size == inner.size && inner.image.is_some() && inner.texture.is_some() { + return; + } + + inner.size = size; + + let descriptor = TextureDescriptor { + label: Some("px_present_texture"), + size: Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + dimension: TextureDimension::D2, + format: TextureFormat::R8Uint, + sample_count: 1, + mip_level_count: 1, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }; + + inner.texture = Some(device.create_texture(&descriptor)); + inner.image = Some(Image::new_fill( + descriptor.size, + descriptor.dimension, + &[0], + descriptor.format, + default(), + )); + } + + fn clear(&self) { + let mut inner = self.inner.write().unwrap(); + if let Some(image) = inner.image.as_mut() { + if let Some(data) = image.data.as_mut() { + data.fill(0); + } + } + } + + fn read_inner(&self) -> std::sync::RwLockReadGuard<'_, PxRenderBufferInner> { + self.inner.read().unwrap() + } + + fn write_inner(&self) -> std::sync::RwLockWriteGuard<'_, PxRenderBufferInner> { + self.inner.write().unwrap() + } +} + #[cfg(feature = "headed")] #[derive(RenderLabel, Hash, Eq, PartialEq, Clone, Debug)] struct PxRender; @@ -381,17 +463,10 @@ impl ViewNode for PxRenderNode { let &camera = world.resource::(); let screen = world.resource::(); - let mut image = Image::new_fill( - Extent3d { - width: screen.computed_size.x, - height: screen.computed_size.y, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &[0], - TextureFormat::R8Uint, - default(), - ); + let device = world.resource::(); + let render_buffer = world.resource::(); + render_buffer.ensure_size(device, screen.computed_size); + render_buffer.clear(); #[cfg(feature = "line")] let mut layer_contents = BTreeMap::< @@ -700,367 +775,373 @@ impl ViewNode for PxRenderNode { let typefaces = world.resource::>(); let filters = world.resource::>(); - let mut layer_image = PxImage::empty_from_image(&image); - let mut image_slice = PxImageSliceMut::from_image_mut(&mut image).unwrap(); - - #[allow(unused_variables)] - for ( - _, - ( - maps, - // image_to_sprites, - sprites, - texts, - clip_rects, - clip_lines, - clip_filters, - over_rects, - over_lines, - over_filters, - ), - ) in layer_contents.into_iter() { - layer_image.clear(); - let mut layer_slice = layer_image.slice_all_mut(); - - for (map, position, canvas, frame, map_filter) in maps { - let Some(tileset) = tilesets.get(&map.tileset) else { - continue; - }; - - let map_filter = map_filter.and_then(|map_filter| filters.get(&**map_filter)); - let size = map.tiles.size(); - - for x in 0..size.x { - for y in 0..size.y { - let pos = UVec2::new(x, y); - - let Some(tile) = map.tiles.get(pos) else { - continue; - }; - - let Ok((&PxTile { texture }, tile_filter)) = - self.tiles.get_manual(world, tile) - else { - continue; - }; - - let Some(tile) = tileset.tileset.get(texture as usize) else { - error!( - "tile texture index out of bounds: the len is {}, but the index is {texture}", - tileset.tileset.len() + let mut inner = render_buffer.write_inner(); + let image = inner.image.as_mut().unwrap(); + let mut layer_image = PxImage::empty_from_image(image); + let mut image_slice = PxImageSliceMut::from_image_mut(image).unwrap(); + + #[allow(unused_variables)] + for ( + _, + ( + maps, + // image_to_sprites, + sprites, + texts, + clip_rects, + clip_lines, + clip_filters, + over_rects, + over_lines, + over_filters, + ), + ) in layer_contents.into_iter() + { + layer_image.clear(); + let mut layer_slice = layer_image.slice_all_mut(); + + for (map, position, canvas, frame, map_filter) in maps { + let Some(tileset) = tilesets.get(&map.tileset) else { + continue; + }; + + let map_filter = map_filter.and_then(|map_filter| filters.get(&**map_filter)); + let size = map.tiles.size(); + + for x in 0..size.x { + for y in 0..size.y { + let pos = UVec2::new(x, y); + + let Some(tile) = map.tiles.get(pos) else { + continue; + }; + + let Ok((&PxTile { texture }, tile_filter)) = + self.tiles.get_manual(world, tile) + else { + continue; + }; + + let Some(tile) = tileset.tileset.get(texture as usize) else { + error!( + "tile texture index out of bounds: the len is {}, but the index is {texture}", + tileset.tileset.len() + ); + continue; + }; + + draw_spatial( + tile, + (), + &mut layer_slice, + (*position + pos.as_ivec2() * tileset.tile_size().as_ivec2()) + .into(), + PxAnchor::BottomLeft, + canvas, + frame.copied(), + [ + tile_filter.and_then(|tile_filter| filters.get(&**tile_filter)), + map_filter, + ] + .into_iter() + .flatten(), + camera, ); - continue; - }; - - draw_spatial( - tile, - (), - &mut layer_slice, - (*position + pos.as_ivec2() * tileset.tile_size().as_ivec2()).into(), - PxAnchor::BottomLeft, - canvas, - frame.copied(), - [ - tile_filter.and_then(|tile_filter| filters.get(&**tile_filter)), - map_filter, - ] - .into_iter() - .flatten(), - camera, - ); - } - } - } - - // I was trying to make `ImageToSprite` work without 1-frame lag, but this - // fundamentally needs GPU readback or something bc you can't just get image data - // from a `GpuImage`. I think those represent images that're actually on the GPU. So - // here's where I left off with that. I don't need `ImageToSprite` at the moment, so - // this will be left incomplete until I need it, if I ever do. - - // // TODO Use more helpers - // // TODO Feature gate - // // TODO Immediate function version - // for (image, position, anchor, canvas, filter) in image_to_sprites { - // // let palette = screen.palette - // // .colors - // // .iter() - // // .map(|&color| Oklaba::from(Srgba::from_u8_array_no_alpha(color)).to_vec3()) - // // .collect::>(); - - // let palette_tree = ImmutableKdTree::from( - // &screen - // .palette - // .iter() - // .map(|&color| color.into()) - // .collect::>()[..], - // ); - - // let dither = &image.dither; - // let Some(image) = images.get(&image.image) else { - // continue; - // }; - - // // TODO https://github.com/bevyengine/bevy/blob/v0.14.1/examples/app/headless_renderer.rs - // let size = image.size; - // let data = PxImage::empty(size); - - // let mut sprite = PxSprite { - // frame_size: data.area(), - // data, - // }; - - // let mut pixels = image - // .data - // .chunks_exact(4) - // .zip(sprite.data.iter_mut()) - // .enumerate() - // .collect::>(); - - // pixels.par_chunk_map_mut(ComputeTaskPool::get(), 20, |_, pixels| { - // use DitherAlgorithm::*; - // use ThresholdMap::*; - - // match *dither { - // None => dither_slice::( - // pixels, - // 0., - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X2_2, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X4_4, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X8_8, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X2_2, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X4_4, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X8_8, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // } - // }); - - // draw_spatial( - // &sprite, - // (), - // &mut layer_image, - // *position, - // *anchor, - // *canvas, - // None, - // filter.and_then(|filter| filters.get(filter)), - // camera, - // ); - // } - - for (sprite, position, anchor, canvas, frame, filter) in sprites { - let Some(sprite) = sprite_assets.get(&**sprite) else { - continue; - }; - - draw_spatial( - sprite, - (), - &mut layer_slice, - position, - anchor, - canvas, - frame.copied(), - filter.and_then(|filter| filters.get(&**filter)), - camera, - ); - } - - for (text, pos, alignment, canvas, frame, filter) in texts { - let Some(typeface) = typefaces.get(&text.typeface) else { - continue; - }; - - let line_break_count = text.line_breaks.len() as u32; - let mut size = uvec2( - 0, - (line_break_count + 1) * typeface.height + line_break_count, - ); - let mut x = 0; - let mut y = 0; - let mut chars = Vec::new(); - let mut line_break_index = 0; - - for (index, char) in text.value.chars().enumerate() { - if let Some(char) = typeface.characters.get(&char) { - if x != 0 { - x += 1; - } - - chars.push((x, y, char)); - x += char.data.size().x; - - if x > size.x { - size.x = x; } - } else if let Some(separator) = typeface.separators.get(&char) { - x += separator.width; - } else { - error!(r#"character "{char}" in text isn't in typeface"#); - } - - if text.line_breaks.get(line_break_index).copied() == Some(index as u32) { - line_break_index += 1; - y += typeface.height + 1; - x = 0; } } - let top_left = *pos - alignment.pos(size).as_ivec2() + ivec2(0, size.y as i32 - 1); + // I was trying to make `ImageToSprite` work without 1-frame lag, but this + // fundamentally needs GPU readback or something bc you can't just get image data + // from a `GpuImage`. I think those represent images that're actually on the GPU. So + // here's where I left off with that. I don't need `ImageToSprite` at the moment, so + // this will be left incomplete until I need it, if I ever do. + + // // TODO Use more helpers + // // TODO Feature gate + // // TODO Immediate function version + // for (image, position, anchor, canvas, filter) in image_to_sprites { + // // let palette = screen.palette + // // .colors + // // .iter() + // // .map(|&color| Oklaba::from(Srgba::from_u8_array_no_alpha(color)).to_vec3()) + // // .collect::>(); + + // let palette_tree = ImmutableKdTree::from( + // &screen + // .palette + // .iter() + // .map(|&color| color.into()) + // .collect::>()[..], + // ); + + // let dither = &image.dither; + // let Some(image) = images.get(&image.image) else { + // continue; + // }; + + // // TODO https://github.com/bevyengine/bevy/blob/v0.14.1/examples/app/headless_renderer.rs + // let size = image.size; + // let data = PxImage::empty(size); + + // let mut sprite = PxSprite { + // frame_size: data.area(), + // data, + // }; + + // let mut pixels = image + // .data + // .chunks_exact(4) + // .zip(sprite.data.iter_mut()) + // .enumerate() + // .collect::>(); + + // pixels.par_chunk_map_mut(ComputeTaskPool::get(), 20, |_, pixels| { + // use DitherAlgorithm::*; + // use ThresholdMap::*; + + // match *dither { + // None => dither_slice::( + // pixels, + // 0., + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Ordered, + // threshold, + // threshold_map: X2_2, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Ordered, + // threshold, + // threshold_map: X4_4, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Ordered, + // threshold, + // threshold_map: X8_8, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Pattern, + // threshold, + // threshold_map: X2_2, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Pattern, + // threshold, + // threshold_map: X4_4, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // Some(Dither { + // algorithm: Pattern, + // threshold, + // threshold_map: X8_8, + // }) => dither_slice::( + // pixels, + // threshold, + // size, + // &screen.palette_tree, + // &screen.palette, + // ), + // } + // }); + + // draw_spatial( + // &sprite, + // (), + // &mut layer_image, + // *position, + // *anchor, + // *canvas, + // None, + // filter.and_then(|filter| filters.get(filter)), + // camera, + // ); + // } + + for (sprite, position, anchor, canvas, frame, filter) in sprites { + let Some(sprite) = sprite_assets.get(&**sprite) else { + continue; + }; - for (x, y, char) in chars { draw_spatial( - char, + sprite, (), &mut layer_slice, - PxPosition(top_left + ivec2(x as i32, -(y as i32))), - PxAnchor::TopLeft, + position, + anchor, canvas, frame.copied(), filter.and_then(|filter| filters.get(&**filter)), camera, ); } - } - for (rect, filter, pos, anchor, canvas, frame, invert) in clip_rects { - if let Some(filter) = filters.get(&**filter) { - draw_spatial( - &(rect, filter), - invert, - &mut layer_slice, - pos, - anchor, - canvas, - frame.copied(), - empty(), - camera, + for (text, pos, alignment, canvas, frame, filter) in texts { + let Some(typeface) = typefaces.get(&text.typeface) else { + continue; + }; + + let line_break_count = text.line_breaks.len() as u32; + let mut size = uvec2( + 0, + (line_break_count + 1) * typeface.height + line_break_count, ); + let mut x = 0; + let mut y = 0; + let mut chars = Vec::new(); + let mut line_break_index = 0; + + for (index, char) in text.value.chars().enumerate() { + if let Some(char) = typeface.characters.get(&char) { + if x != 0 { + x += 1; + } + + chars.push((x, y, char)); + x += char.data.size().x; + + if x > size.x { + size.x = x; + } + } else if let Some(separator) = typeface.separators.get(&char) { + x += separator.width; + } else { + error!(r#"character "{char}" in text isn't in typeface"#); + } + + if text.line_breaks.get(line_break_index).copied() == Some(index as u32) { + line_break_index += 1; + y += typeface.height + 1; + x = 0; + } + } + + let top_left = + *pos - alignment.pos(size).as_ivec2() + ivec2(0, size.y as i32 - 1); + + for (x, y, char) in chars { + draw_spatial( + char, + (), + &mut layer_slice, + PxPosition(top_left + ivec2(x as i32, -(y as i32))), + PxAnchor::TopLeft, + canvas, + frame.copied(), + filter.and_then(|filter| filters.get(&**filter)), + camera, + ); + } } - } - // This is where I draw the line! /j - #[cfg(feature = "line")] - for (line, filter, canvas, frame, invert) in clip_lines { - if let Some(filter) = filters.get(&**filter) { - draw_line( - line, - filter, - invert, - &mut layer_slice, - canvas, - frame.copied(), - camera, - ); + for (rect, filter, pos, anchor, canvas, frame, invert) in clip_rects { + if let Some(filter) = filters.get(&**filter) { + draw_spatial( + &(rect, filter), + invert, + &mut layer_slice, + pos, + anchor, + canvas, + frame.copied(), + empty(), + camera, + ); + } } - } - for (filter, frame) in clip_filters { - if let Some(filter) = filters.get(&**filter) { - draw_filter(filter, frame.copied(), &mut layer_slice); + // This is where I draw the line! /j + #[cfg(feature = "line")] + for (line, filter, canvas, frame, invert) in clip_lines { + if let Some(filter) = filters.get(&**filter) { + draw_line( + line, + filter, + invert, + &mut layer_slice, + canvas, + frame.copied(), + camera, + ); + } } - } - image_slice.draw(&layer_image); + for (filter, frame) in clip_filters { + if let Some(filter) = filters.get(&**filter) { + draw_filter(filter, frame.copied(), &mut layer_slice); + } + } - for (rect, filter, pos, anchor, canvas, frame, invert) in over_rects { - if let Some(filter) = filters.get(&**filter) { - draw_spatial( - &(rect, filter), - invert, - &mut image_slice, - pos, - anchor, - canvas, - frame.copied(), - empty(), - camera, - ); + image_slice.draw(&layer_image); + + for (rect, filter, pos, anchor, canvas, frame, invert) in over_rects { + if let Some(filter) = filters.get(&**filter) { + draw_spatial( + &(rect, filter), + invert, + &mut image_slice, + pos, + anchor, + canvas, + frame.copied(), + empty(), + camera, + ); + } } - } - #[cfg(feature = "line")] - for (line, filter, canvas, frame, invert) in over_lines { - if let Some(filter) = filters.get(&**filter) { - draw_line( - line, - filter, - invert, - &mut image_slice, - canvas, - frame.copied(), - camera, - ); + #[cfg(feature = "line")] + for (line, filter, canvas, frame, invert) in over_lines { + if let Some(filter) = filters.get(&**filter) { + draw_line( + line, + filter, + invert, + &mut image_slice, + canvas, + frame.copied(), + camera, + ); + } } - } - for (filter, frame) in over_filters { - if let Some(filter) = filters.get(&**filter) { - draw_filter(filter, frame.copied(), &mut image_slice); + for (filter, frame) in over_filters { + if let Some(filter) = filters.get(&**filter) { + draw_filter(filter, frame.copied(), &mut image_slice); + } } } } @@ -1078,16 +1159,19 @@ impl ViewNode for PxRenderNode { CursorState::Left => left_click, CursorState::Right => right_click, }) - && let mut image = PxImageSliceMut::from_image_mut(&mut image).unwrap() - && let Some(pixel) = image.get_pixel_mut(IVec2::new( - cursor_pos.x as i32, - image.height() as i32 - 1 - cursor_pos.y as i32, - )) { - if let Some(new_pixel) = filter.get_pixel(IVec2::new(*pixel as i32, 0)) { - *pixel = new_pixel; - } else { - error!("`PxCursor` filter is the wrong size"); + let mut inner = render_buffer.write_inner(); + let image = inner.image.as_mut().unwrap(); + let mut cursor_image = PxImageSliceMut::from_image_mut(image).unwrap(); + if let Some(pixel) = cursor_image.get_pixel_mut(IVec2::new( + cursor_pos.x as i32, + cursor_image.height() as i32 - 1 - cursor_pos.y as i32, + )) { + if let Some(new_pixel) = filter.get_pixel(IVec2::new(*pixel as i32, 0)) { + *pixel = new_pixel; + } else { + error!("`PxCursor` filter is the wrong size"); + } } } @@ -1095,11 +1179,12 @@ impl ViewNode for PxRenderNode { return Ok(()); }; - let texture = render_context - .render_device() - .create_texture(&image.texture_descriptor); + let inner = render_buffer.read_inner(); + let texture = inner.texture.as_ref().unwrap(); + let image = inner.image.as_ref().unwrap(); + let image_descriptor = image.texture_descriptor.clone(); - let Ok(pixel_size) = image.texture_descriptor.format.pixel_size() else { + let Ok(pixel_size) = image_descriptor.format.pixel_size() else { return Ok(()); }; @@ -1111,12 +1196,12 @@ impl ViewNode for PxRenderNode { bytes_per_row: Some(image.width() * pixel_size as u32), rows_per_image: None, }, - image.texture_descriptor.size, + image_descriptor.size, ); let texture_view = texture.create_view(&TextureViewDescriptor { label: Some("px_texture_view"), - format: Some(image.texture_descriptor.format), + format: Some(image_descriptor.format), dimension: Some(TextureViewDimension::D2), ..default() }); From 0156a55d8f90e41fe65f63922f910eaddae7f6b6 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Mon, 17 Nov 2025 17:09:14 +0000 Subject: [PATCH 08/23] perf: optimize pixel iteration for rects and lines --- examples/rect.rs | 40 +++++++++++++ src/line.rs | 144 +++++++++++++++++++++++++++++++++++++++++++---- src/rect.rs | 110 ++++++++++++++++++++++++++++++++++-- 3 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 examples/rect.rs diff --git a/examples/rect.rs b/examples/rect.rs new file mode 100644 index 0000000..673df29 --- /dev/null +++ b/examples/rect.rs @@ -0,0 +1,40 @@ +// In this program, a rectangle filter is applied over a region + +use bevy::prelude::*; +use seldom_pixel::prelude::*; + +fn main() { + App::new() + .add_plugins(( + DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + resolution: UVec2::splat(512).into(), + ..default() + }), + ..default() + }), + PxPlugin::::new(UVec2::splat(32), "palette/palette_1.palette.png"), + )) + .insert_resource(ClearColor(Color::BLACK)) + .add_systems(Startup, init) + .run(); +} + +fn init(assets: Res, mut commands: Commands) { + commands.spawn(Camera2d); + + let mage = assets.load("sprite/mage.px_sprite.png"); + + commands.spawn((PxSprite(mage), PxPosition(IVec2::splat(16)))); + + // Apply a filter only within a rectangular region. + commands.spawn(( + PxRect(UVec2::new(12, 10)), + PxPosition(IVec2::new(10, 22)), + PxFilterLayers::single_over(Layer), + PxFilter(assets.load("filter/invert.px_filter.png")), + )); +} + +#[px_layer] +struct Layer; diff --git a/src/line.rs b/src/line.rs index f65e20c..f76e11d 100644 --- a/src/line.rs +++ b/src/line.rs @@ -55,24 +55,42 @@ impl Frames for (&PxLine, &PxFilterAsset) { _: impl Fn(u8) -> u8, ) { let (line, PxFilterAsset(filter)) = self; - let mut poses = HashSet::new(); + let slice_offset = image.offset(); + let image_width = image.image_width() as i32; + let image_height = image.image_height() as i32; - for (start, end) in line.iter().zip(line.iter().skip(1)) { - let start = *start + offset; - let end = *end + offset; + if invert { + let mut line_points = Vec::new(); + + for (segment_index, (start, end)) in line.iter().zip(line.iter().skip(1)).enumerate() { + let start = *start + offset; + let end = *end + offset; + + for (step, pos) in Bresenham::new(start.into(), end.into()).enumerate() { + if segment_index > 0 && step == 0 { + continue; + } - for pos in Bresenham::new(start.into(), end.into()) { - poses.insert(IVec2::from(pos)); + line_points.push(IVec2::from(pos)); + } } - } - let offset = image.offset(); + let mut originals = Vec::with_capacity(line_points.len()); - for x in 0..image.image_width() as i32 { - for y in 0..image.image_height() as i32 { - let pos = ivec2(x, y); + for world_pos in &line_points { + let pos = *world_pos + slice_offset; - if poses.contains(&(pos - offset)) != invert { + if pos.x < 0 || pos.y < 0 || pos.x >= image_width || pos.y >= image_height { + continue; + } + + let pixel = *image.image_pixel_mut(pos); + originals.push((pos, pixel)); + } + + for y in 0..image_height { + for x in 0..image_width { + let pos = ivec2(x, y); let pixel = image.image_pixel_mut(pos); *pixel = filter.pixel(ivec2( *pixel as i32, @@ -80,6 +98,39 @@ impl Frames for (&PxLine, &PxFilterAsset) { )); } } + + for (pos, pixel) in originals { + *image.image_pixel_mut(pos) = pixel; + } + } else { + let mut poses = HashSet::new(); + + for (segment_index, (start, end)) in line.iter().zip(line.iter().skip(1)).enumerate() { + let start = *start + offset; + let end = *end + offset; + + for (step, pos) in Bresenham::new(start.into(), end.into()).enumerate() { + if segment_index > 0 && step == 0 { + continue; + } + + poses.insert(IVec2::from(pos)); + } + } + + for world_pos in poses { + let pos = world_pos + slice_offset; + + if pos.x < 0 || pos.y < 0 || pos.x >= image_width || pos.y >= image_height { + continue; + } + + let pixel = image.image_pixel_mut(pos); + *pixel = filter.pixel(ivec2( + *pixel as i32, + frame(uvec2(pos.x as u32, pos.y as u32)) as i32, + )); + } } } } @@ -151,3 +202,72 @@ pub(crate) fn draw_line( [], ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{camera::PxCamera, filter::PxFilterAsset, image::PxImage}; + + fn filter_asset() -> PxFilterAsset { + PxFilterAsset(PxImage::new(vec![0, 2, 0, 0], 4)) + } + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + #[test] + fn line_draws_only_line_pixels() { + let mut image = PxImage::new(vec![1; 25], 5); + let mut slice = image.slice_all_mut(); + let filter = filter_asset(); + let line = PxLine(vec![IVec2::new(1, 1), IVec2::new(3, 1)]); + + draw_line( + &line, + &filter, + false, + &mut slice, + PxCanvas::Camera, + None, + PxCamera::default(), + ); + + let expected = vec![ + 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + ]; + + assert_eq!(pixels(&image), expected); + } + + #[test] + fn line_invert_draws_outside_only() { + let mut image = PxImage::new(vec![1; 25], 5); + let mut slice = image.slice_all_mut(); + let filter = filter_asset(); + let line = PxLine(vec![IVec2::new(1, 1), IVec2::new(3, 1)]); + + draw_line( + &line, + &filter, + true, + &mut slice, + PxCanvas::Camera, + None, + PxCamera::default(), + ); + + let expected = vec![ + 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + ]; + + assert_eq!(pixels(&image), expected); + } +} diff --git a/src/rect.rs b/src/rect.rs index 6bc667e..7af26ce 100644 --- a/src/rect.rs +++ b/src/rect.rs @@ -42,10 +42,50 @@ impl Frames for (PxRect, &PxFilterAsset) { ) { let (_, PxFilterAsset(filter)) = self; - for x in 0..image.image_width() as i32 { - for y in 0..image.image_height() as i32 { - let pos = ivec2(x, y); - if image.contains_pixel(pos) != invert { + if invert { + let image_width = image.image_width() as i32; + let image_height = image.image_height() as i32; + let rect_min = image.offset(); + let rect_max = rect_min + IVec2::new(image.width() as i32, image.height() as i32); + let x_min = rect_min.x.clamp(0, image_width); + let x_max = rect_max.x.clamp(0, image_width); + let y_min = rect_min.y.clamp(0, image_height); + let y_max = rect_max.y.clamp(0, image_height); + + for y in 0..y_min { + for x in 0..image_width { + let pos = ivec2(x, y); + let pixel = image.image_pixel_mut(pos); + *pixel = filter_fn(filter.pixel(ivec2( + *pixel as i32, + frame(uvec2(x as u32, y as u32)) as i32, + ))); + } + } + + for y in y_max..image_height { + for x in 0..image_width { + let pos = ivec2(x, y); + let pixel = image.image_pixel_mut(pos); + *pixel = filter_fn(filter.pixel(ivec2( + *pixel as i32, + frame(uvec2(x as u32, y as u32)) as i32, + ))); + } + } + + for y in y_min..y_max { + for x in 0..x_min { + let pos = ivec2(x, y); + let pixel = image.image_pixel_mut(pos); + *pixel = filter_fn(filter.pixel(ivec2( + *pixel as i32, + frame(uvec2(x as u32, y as u32)) as i32, + ))); + } + + for x in x_max..image_width { + let pos = ivec2(x, y); let pixel = image.image_pixel_mut(pos); *pixel = filter_fn(filter.pixel(ivec2( *pixel as i32, @@ -53,10 +93,72 @@ impl Frames for (PxRect, &PxFilterAsset) { ))); } } + } else { + let image_width = image.image_width(); + image.for_each_mut(|_, image_index, pixel| { + let x = (image_index % image_width) as u32; + let y = (image_index / image_width) as u32; + *pixel = filter_fn(filter.pixel(ivec2(*pixel as i32, frame(uvec2(x, y)) as i32))); + }); } } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{animation::draw_frame, filter::PxFilterAsset, image::PxImage}; + + fn filter_asset() -> PxFilterAsset { + PxFilterAsset(PxImage::new(vec![0, 2, 0, 0], 4)) + } + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + #[test] + fn rect_draws_inside_only() { + let mut image = PxImage::new(vec![1; 16], 4); + let mut slice = image.slice_all_mut(); + let mut rect_slice = slice.slice_mut(IRect { + min: ivec2(1, 1), + max: ivec2(3, 3), + }); + let rect = PxRect(UVec2::new(2, 2)); + let filter = filter_asset(); + + draw_frame(&(rect, &filter), false, &mut rect_slice, None, []); + + let expected = vec![1, 1, 1, 1, 1, 2, 2, 1, 1, 2, 2, 1, 1, 1, 1, 1]; + assert_eq!(pixels(&image), expected); + } + + #[test] + fn rect_invert_draws_outside_only() { + let mut image = PxImage::new(vec![1; 16], 4); + let mut slice = image.slice_all_mut(); + let mut rect_slice = slice.slice_mut(IRect { + min: ivec2(1, 1), + max: ivec2(3, 3), + }); + let rect = PxRect(UVec2::new(2, 2)); + let filter = filter_asset(); + + draw_frame(&(rect, &filter), true, &mut rect_slice, None, []); + + let expected = vec![2, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1, 2, 2, 2, 2, 2]; + assert_eq!(pixels(&image), expected); + } +} + impl Spatial for (PxRect, &PxFilterAsset) { fn frame_size(&self) -> UVec2 { *self.0 From f0a85a7efb24d5080b8cb935e7711b9f36883a51 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Mon, 22 Dec 2025 12:28:28 -0300 Subject: [PATCH 09/23] Add snapshot tests for sprite, filter, text and tilemap --- src/filter.rs | 29 +++++++++++++++ src/map.rs | 73 ++++++++++++++++++++++++++++++++++++++ src/sprite.rs | 42 ++++++++++++++++++++++ src/text.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+) diff --git a/src/filter.rs b/src/filter.rs index cd8b8b3..5364d97 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -296,6 +296,35 @@ pub(crate) type FilterComponents = ( Option<&'static PxFrame>, ); +#[cfg(test)] +mod tests { + use super::*; + use crate::{animation::draw_frame, image::PxImage}; + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + #[test] + fn filter_maps_palette_indices() { + let filter = PxFilterAsset(PxImage::new(vec![0, 2, 3, 1], 4)); + let mut image = PxImage::new(vec![1, 2, 1, 2], 2); + let mut slice = image.slice_all_mut(); + + draw_frame(&filter, (), &mut slice, None, []); + + let expected = vec![2, 3, 2, 3]; + assert_eq!(pixels(&image), expected); + } +} + #[cfg(feature = "headed")] fn extract_filters( filters: Extract< diff --git a/src/map.rs b/src/map.rs index a39114f..ade9698 100644 --- a/src/map.rs +++ b/src/map.rs @@ -238,6 +238,79 @@ impl Default for PxTiles { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{animation::draw_spatial, camera::PxCamera, image::PxImage, sprite::PxSpriteAsset}; + use bevy_ecs::entity::Entity; + use bevy_platform::collections::HashMap; + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + #[test] + fn map_draws_tiles_in_order() { + let tileset = PxTileset { + tileset: vec![ + PxSpriteAsset { + data: PxImage::new(vec![2], 1), + frame_size: 1, + }, + PxSpriteAsset { + data: PxImage::new(vec![3], 1), + frame_size: 1, + }, + ], + tile_size: UVec2::ONE, + max_frame_count: 1, + }; + + let mut tiles = PxTiles::new(UVec2::new(2, 1)); + let tile_a = Entity::from_raw_u32(1).unwrap(); + let tile_b = Entity::from_raw_u32(2).unwrap(); + tiles.set(Some(tile_a), UVec2::new(0, 0)); + tiles.set(Some(tile_b), UVec2::new(1, 0)); + + let mut tile_components = HashMap::new(); + tile_components.insert(tile_a, PxTile { texture: 0 }); + tile_components.insert(tile_b, PxTile { texture: 1 }); + + let mut image = PxImage::new(vec![1; 2], 2); + let mut slice = image.slice_all_mut(); + + for x in 0..2 { + let pos = UVec2::new(x, 0); + let Some(tile_entity) = tiles.get(pos) else { + continue; + }; + let tile = tile_components.get(&tile_entity).unwrap(); + let tile_asset = &tileset.tileset[tile.texture as usize]; + draw_spatial( + tile_asset, + (), + &mut slice, + PxPosition(pos.as_ivec2()), + PxAnchor::BottomLeft, + PxCanvas::Camera, + None, + [], + PxCamera::default(), + ); + } + + let expected = vec![2, 3]; + assert_eq!(pixels(&image), expected); + } +} + impl<'a> Spatial for (&'a PxTiles, &'a PxTileset) { fn frame_size(&self) -> UVec2 { let (tiles, tileset) = self; diff --git a/src/sprite.rs b/src/sprite.rs index 48c168e..16d1c65 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -179,6 +179,48 @@ impl AnimatedAssetComponent for PxSprite { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{animation::draw_spatial, camera::PxCamera, image::PxImage}; + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + #[test] + fn sprite_draws_nonzero_pixels() { + let sprite = PxSpriteAsset { + data: PxImage::new(vec![0, 2, 3, 0], 2), + frame_size: 4, + }; + let mut image = PxImage::new(vec![1; 4], 2); + let mut slice = image.slice_all_mut(); + + draw_spatial( + &sprite, + (), + &mut slice, + PxPosition(IVec2::ZERO), + PxAnchor::BottomLeft, + PxCanvas::Camera, + None, + [], + PxCamera::default(), + ); + + let expected = vec![1, 2, 3, 1]; + assert_eq!(pixels(&image), expected); + } +} + // /// Size of threshold map to use for dithering. The image is tiled with dithering according to this // /// map, so smaller sizes will have more visible repetition and worse color approximation, but // /// larger sizes are much, much slower with pattern dithering. diff --git a/src/text.rs b/src/text.rs index 2419eee..6b7d1df 100644 --- a/src/text.rs +++ b/src/text.rs @@ -219,6 +219,104 @@ impl AnimatedAssetComponent for PxText { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{animation::draw_spatial, camera::PxCamera, image::PxImage, sprite::PxSpriteAsset}; + + fn pixels(image: &PxImage) -> Vec { + let size = image.size(); + let mut out = Vec::with_capacity((size.x * size.y) as usize); + for y in 0..size.y as i32 { + for x in 0..size.x as i32 { + out.push(image.pixel(IVec2::new(x, y))); + } + } + out + } + + fn draw_text( + image: &mut PxImage, + text: &str, + typeface: &PxTypeface, + pos: PxPosition, + alignment: PxAnchor, + ) { + let mut slice = image.slice_all_mut(); + let line_break_count = 0_u32; + let mut size = uvec2( + 0, + (line_break_count + 1) * typeface.height + line_break_count, + ); + let mut x = 0; + let y = 0; + let mut chars = Vec::new(); + + for char in text.chars() { + if let Some(char) = typeface.characters.get(&char) { + if x != 0 { + x += 1; + } + + chars.push((x, y, char)); + x += char.data.size().x; + + if x > size.x { + size.x = x; + } + } else if let Some(separator) = typeface.separators.get(&char) { + x += separator.width; + } + } + + let top_left = *pos - alignment.pos(size).as_ivec2() + ivec2(0, size.y as i32 - 1); + + for (x, y, char) in chars { + draw_spatial( + char, + (), + &mut slice, + PxPosition(top_left + ivec2(x as i32, -(y as i32))), + PxAnchor::TopLeft, + PxCanvas::Camera, + None, + [], + PxCamera::default(), + ); + } + } + + #[test] + fn text_draws_characters_with_spacing() { + let mut characters = HashMap::new(); + characters.insert( + 'A', + PxSpriteAsset { + data: PxImage::new(vec![2], 1), + frame_size: 1, + }, + ); + let typeface = PxTypeface { + height: 1, + characters, + separators: HashMap::new(), + max_frame_count: 1, + }; + + let mut image = PxImage::new(vec![1; 12], 4); + draw_text( + &mut image, + "AA", + &typeface, + PxPosition(IVec2::new(0, 1)), + PxAnchor::BottomLeft, + ); + + let expected = vec![1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1]; + assert_eq!(pixels(&image), expected); + } +} + pub(crate) type TextComponents = ( &'static PxText, &'static PxPosition, From d5adc93cc46d1df255a1026dc8ca0f723ebe4f88 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Mon, 22 Dec 2025 11:53:00 -0300 Subject: [PATCH 10/23] Add some missing code documentation. --- src/animation.rs | 12 ++++++++++++ src/filter.rs | 13 ++++++------- src/image.rs | 7 ++++--- src/palette.rs | 3 +++ src/picking.rs | 1 + src/screen.rs | 15 ++++++++++----- src/ui.rs | 38 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index 55a7d30..64850ba 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -1,4 +1,8 @@ //! Animation +//! +//! This module defines frame selection and animation timing, but drawables still +//! consume a concrete `PxFrame` (selector + transition). Future refactors may +//! move frame production into a separate, composable system. use std::time::Duration; @@ -20,9 +24,12 @@ pub(crate) fn plug(app: &mut App) { ); } +/// Selects a frame by absolute index or normalized progress. #[derive(Clone, Copy)] pub enum PxFrameSelector { + /// Direct frame index (may be fractional for transitions). Index(f32), + /// Normalized progress from 0.0 to 1.0. Normalized(f32), } @@ -42,9 +49,12 @@ pub enum PxFrameTransition { Dither, } +/// Per-entity frame selection and transition settings. #[derive(Component, Default, Clone, Copy)] pub struct PxFrame { + /// Frame selection mode. pub selector: PxFrameSelector, + /// Frame interpolation mode. pub transition: PxFrameTransition, } @@ -237,6 +247,8 @@ pub(crate) fn draw_spatial<'a, A: Frames + Spatial>( filters: impl IntoIterator, camera: PxCamera, ) { + // Coordinate convention: image space has origin at top-left. + // World/camera positions are bottom-left, so Y is flipped here. let size = spatial.frame_size(); let position = *position - anchor.pos(size).as_ivec2(); let position = match canvas { diff --git a/src/filter.rs b/src/filter.rs index 5364d97..9cd15cf 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -23,6 +23,7 @@ use crate::{ prelude::*, }; +/// A built-in filter asset that leaves pixels unchanged. pub const TRANSPARENT_FILTER: Handle = uuid_handle!("798C57A4-A83C-5DD6-8FA6-1426E31A84CA"); @@ -263,13 +264,10 @@ fn insert_default_px_filter_layers(mut world: DeferredWorld, ctx: HookContext) { let insert_default_px_filter_layers = world .remove_resource::() .unwrap(); - if let Ok(mut entity) = world.get_entity_mut(ctx.entity) { - if let Some(default) = entity.get::() { - insert_default_px_filter_layers( - default.clip, - entity.remove::(), - ); - } + if let Ok(mut entity) = world.get_entity_mut(ctx.entity) + && let Some(default) = entity.get::() + { + insert_default_px_filter_layers(default.clip, entity.remove::()); } world.insert_resource(insert_default_px_filter_layers); }) @@ -287,6 +285,7 @@ impl Default for DefaultPxFilterLayers { } } +/// Marks that a filter should apply outside a shape rather than inside it. #[derive(Component, Default)] pub struct PxInvertMask; diff --git a/src/image.rs b/src/image.rs index 68526d1..d780519 100644 --- a/src/image.rs +++ b/src/image.rs @@ -91,7 +91,7 @@ impl PxImage { } #[expect(unused)] - pub(crate) fn slice_mut(&mut self, slice: IRect) -> PxImageSliceMut { + pub(crate) fn slice_mut(&mut self, slice: IRect) -> PxImageSliceMut<'_> { PxImageSliceMut { slice, image: self.image.chunks_exact_mut(self.width).collect(), @@ -99,7 +99,7 @@ impl PxImage { } } - pub(crate) fn slice_all_mut(&mut self) -> PxImageSliceMut { + pub(crate) fn slice_all_mut(&mut self) -> PxImageSliceMut<'_> { PxImageSliceMut { slice: IRect { min: IVec2::splat(0), @@ -206,6 +206,7 @@ impl<'a> PxImageSliceMut<'a> { /// First `usize` is the index in the slice. Second `usize` is the index in the image. pub(crate) fn for_each_mut(&mut self, f: impl Fn(usize, usize, &mut u8)) { + // Slice coordinates are in image space; `slice` tracks absolute bounds. let x_min = self.slice.min.x.clamp(0, self.width as i32) as usize; let x_max = self.slice.max.x.clamp(0, self.width as i32) as usize; let max_y = self.image.len() as i32; @@ -275,7 +276,7 @@ impl<'a> PxImageSliceMut<'a> { self.slice.min } - pub(crate) fn slice_mut(&mut self, slice: IRect) -> PxImageSliceMut { + pub(crate) fn slice_mut(&mut self, slice: IRect) -> PxImageSliceMut<'_> { PxImageSliceMut { image: self.image.iter_mut().map(|row| &mut **row).collect(), width: self.width, diff --git a/src/palette.rs b/src/palette.rs index b45ac54..5904837 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -1,4 +1,7 @@ //! Color palettes +//! +//! Asset loading uses a single global palette; runtime palette swaps only affect rendering. +//! This keeps assets palette-indexed but couples loaders to a shared, immutable palette. use std::{ error::Error, diff --git a/src/picking.rs b/src/picking.rs index f71ae78..fe9194c 100644 --- a/src/picking.rs +++ b/src/picking.rs @@ -26,6 +26,7 @@ fn pick( px_camera: Res, cameras: Query<(&Camera, Entity)>, ) { + // Note: picking is rectangle-based only; no per-pixel mask is consulted yet. let Some(cursor) = **cursor else { return; }; diff --git a/src/screen.rs b/src/screen.rs index 7db34c7..f9f3a10 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -1,4 +1,8 @@ //! Screen and rendering +//! +//! Data flow: gather render-world components by layer, draw into a CPU `PxImage`, +//! then upload to a reusable `R8Uint` texture and present via a fullscreen quad. +//! This is the single compositing path for sprites, text, tilemaps, rects, lines, and filters. // TODO Split out a module @@ -77,7 +81,7 @@ impl Plugin for Plug { // R-A workaround #[cfg(feature = "headed")] - Assets::insert( + let _ = Assets::insert( &mut app .add_systems(PostUpdate, resize_screen) .world_mut() @@ -389,10 +393,10 @@ impl PxRenderBuffer { fn clear(&self) { let mut inner = self.inner.write().unwrap(); - if let Some(image) = inner.image.as_mut() { - if let Some(data) = image.data.as_mut() { - data.fill(0); - } + if let Some(image) = inner.image.as_mut() + && let Some(data) = image.data.as_mut() + { + data.fill(0); } } @@ -460,6 +464,7 @@ impl ViewNode for PxRenderNode { target: &ViewTarget, world: &'w World, ) -> Result<(), NodeRunError> { + // Compose each layer into a CPU buffer, then blit to the GPU texture once per frame. let &camera = world.resource::(); let screen = world.resource::(); diff --git a/src/ui.rs b/src/ui.rs index e25c47d..20e316d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -59,14 +59,17 @@ pub(crate) fn plug(app: &mut App) { // TODO Work on this naming +/// Marks the root entity of a UI tree. #[derive(Component)] #[require(PxCanvas, DefaultLayer)] pub struct PxUiRoot; +/// Sets a minimum size for a UI node. #[derive(Component, Deref, DerefMut, Default, Reflect)] #[cfg_attr(feature = "headed", require(Visibility))] pub struct PxMinSize(pub UVec2); +/// Adds pixel margin around a UI node. #[derive(Component, Deref, DerefMut, Reflect)] #[cfg_attr(feature = "headed", require(Visibility))] pub struct PxMargin(pub u32); @@ -77,34 +80,48 @@ impl Default for PxMargin { } } +/// Per-child layout options for [`PxRow`]. #[derive(Component, Default, Clone)] pub struct PxRowSlot { + /// If true, the slot expands to fill available space. pub stretch: bool, } +/// Row/column layout container for UI children. #[derive(Component, Default, Clone, Reflect)] #[cfg_attr(feature = "headed", require(Visibility))] pub struct PxRow { + /// If true, lay out children vertically; otherwise horizontally. pub vertical: bool, + /// Space between children in pixels. pub space_between: u32, } +/// Row sizing config used by [`PxGrid`]. #[derive(Default, Clone, Reflect)] pub struct PxGridRow { + /// If true, the row expands to fill available space. pub stretch: bool, } +/// Row/column definitions for [`PxGrid`]. #[derive(Default, Clone, Reflect)] pub struct PxGridRows { + /// Row definitions. pub rows: Vec, + /// Space between rows/columns in pixels. pub space_between: u32, } +/// Grid layout container for UI children. #[derive(Component, Clone)] #[cfg_attr(feature = "headed", require(Visibility))] pub struct PxGrid { + /// Number of columns in the grid. pub width: u32, + /// Row sizing rules. pub rows: PxGridRows, + /// Column sizing rules. pub columns: PxGridRows, } @@ -118,15 +135,20 @@ impl Default for PxGrid { } } +/// Stack layout container; children overlap in insertion order. #[derive(Component, Clone, Reflect)] #[cfg_attr(feature = "headed", require(Visibility))] pub struct PxStack; +/// Scroll container that masks and offsets child content. #[derive(Component, Default, Clone, Copy, Reflect)] #[require(PxInvertMask, PxRect)] pub struct PxScroll { + /// If true, scroll horizontally; otherwise vertically. pub horizontal: bool, + /// Current scroll offset in pixels. pub scroll: u32, + /// Maximum scroll offset in pixels. pub max_scroll: u32, } @@ -143,10 +165,12 @@ fn scroll(mut scrolls: Query<&mut PxScroll>, mut wheels: MessageReader, String>, + /// Last displayed value when unfocused. pub cached_text: String, } @@ -191,9 +216,12 @@ fn update_key_field_focus( *prev_focus = focus; } +/// Emitted when a [`PxKeyField`] captures a key press. #[derive(EntityEvent)] pub struct PxKeyFieldUpdate { + /// Target field entity. pub entity: Entity, + /// Captured key. pub key: KeyCode, } @@ -252,9 +280,12 @@ fn update_key_fields( focus.clear(); } +/// Caret blink state for text fields. #[derive(Reflect)] pub struct PxCaret { + /// Whether the caret is currently visible. pub state: bool, + /// Blink timer. pub timer: Timer, } @@ -267,11 +298,15 @@ impl Default for PxCaret { } } +/// Editable text field with an optional blinking caret. #[derive(Component, Reflect)] #[require(PxText)] pub struct PxTextField { + /// Cached text without the caret character. pub cached_text: String, + /// Character used as the caret. pub caret_char: char, + /// Active caret state if focused. pub caret: Option, } @@ -326,9 +361,12 @@ fn caret_blink(mut fields: Query<(&mut PxTextField, &mut PxText)>, time: Res Date: Sun, 16 Nov 2025 17:25:36 +0000 Subject: [PATCH 11/23] ARCHITECTURE_REVIEW.md --- ARCHITECTURE_REVIEW.md | 543 +++++++++++++++++++++++++++++++++++++++++ GOALS.md | 27 ++ 2 files changed, 570 insertions(+) create mode 100644 ARCHITECTURE_REVIEW.md create mode 100644 GOALS.md diff --git a/ARCHITECTURE_REVIEW.md b/ARCHITECTURE_REVIEW.md new file mode 100644 index 0000000..06b840d --- /dev/null +++ b/ARCHITECTURE_REVIEW.md @@ -0,0 +1,543 @@ +# `seldom_pixel` Architecture Review & Implementation Plan + +_Last updated: 2025-12-22_ + +This document captures an architectural review of `seldom_pixel` plus a concrete implementation plan, prioritizing easy, high-value improvements. + +--- + +## 1. High-Level Intent + +- Bevy plugin for limited-palette 2D pixel art games. +- Renders into a logical pixel buffer (`ScreenSize`) using 8-bit palette indices, then uploads as an `R8Uint` texture. +- A WGSL shader (`src/screen.wgsl`) maps indices to RGB via a uniform palette. +- Features: + - Sprites (`sprite.rs`) + - Tilemaps (`map.rs`) + - Text / typefaces (`text.rs`) + - Filters (`filter.rs`) + - UI layout (`ui.rs`) + - Particles (`particle.rs`, feature-gated) + - Lines (`line.rs`, feature-gated) + - Cursor and camera (`cursor.rs`, `camera.rs`) + +`PxLayer` (user type, usually via `#[px_layer]`) drives z-ordering and layer semantics across rendering, filters, and picking. + +--- + +## 2. Project Direction (2025-12) + +These are the goals driving the next architectural changes and should guide refactors. + +1. **Decouple frame generation from sprites.** + - Frames should be produced by a separate, composable module. + - Sprites should consume frames, not embed animation logic. +2. **Make animation optional and fully controllable.** + - Animation should be an opt-in system that can be replaced or driven externally. + - Users should be able to control frame selection directly (e.g., manual index changes or linear-algebra time mapping). +3. **Enable pixel-perfect picking at the current frame.** + - Picking must be able to test mouse hits against the actual rendered pixel of the current frame, not just a rectangle. + - The architecture should remain open to supporting future masking or per-pixel collision logic. + +--- + +## 3. Key Architectural Decisions + +### 3.1 Plugin composition + +- `PxPlugin` (`src/lib.rs`) wires together: +- `animation`, `blink`, `camera`, `cursor`, `palette`, `picking`, `position`, + `screen`, `filter`, `line`, `map`, `rect`, `sprite`, `text`, `ui`. +- Each module exposes a `plug` function; they are domain-focused but tightly integrated. + +### 3.2 Headed vs headless + +- `headed` feature gates window, render graph, picking, and input integration. +- Core components / resources exist without `headed`, so logic can run headless. + +### 3.3 CPU-side renderer + +- `src/screen.rs`’s `PxRenderNode`: + - Queries render-world ECS for maps, tiles, sprites, texts, rects, lines, filters. + - Groups them by layer into a `BTreeMap`. + - Draws each layer into a CPU `PxImage` (palette indices) and then uploads that to a GPU `R8Uint` texture. + - A full-screen quad (`screen.wgsl`) turns indices into colors via a 256-entry palette uniform. + +### 3.4 Palette-centric asset pipeline + +- `Palette` asset (`palette.rs`) describes the allowed colors; top-left pixel is background, colors are packed from the rest of the image. +- A global static palette (`ASSET_PALETTE`) is initialized once at startup. +- Asset loaders for sprites (`sprite.rs`), tilesets (`map.rs`), filters (`filter.rs`), typefaces (`text.rs`) call `asset_palette().await` and convert RGBA textures into `PxImage` palette indices at load time. + +### 3.5 Animation abstraction + +- `Frames` trait (`animation.rs`) describes “things with frames backed by a `PxImage`”. +- `AnimatedAssetComponent` ties ECS components to assets and exposes `max_frame_count`. +- `PxAnimation`, `PxFrameSelector`, `PxFrameTransition`: + - Control which frame is used (by index or normalized progress). + - Optional ordered dithering to blend between frames. +- `draw_frame` and `draw_spatial` centralize “draw this animated asset into a `PxImageSliceMut` at a position/anchor/canvas, with filters”. +- Today, animation is effectively embedded in sprites via these traits and component bindings, which makes it harder to compose or override animation behavior from outside. + +### 3.6 World vs camera space + +- `PxCanvas` (`camera.rs`) distinguishes: + - `World`: positions relative to world, offset by `PxCamera`. + - `Camera`: positions relative to the camera (HUD/UI). +- `draw_spatial` applies `PxCamera` for world objects and keeps camera-space sprites fixed. + +### 3.7 UI as layout over pixel primitives + +- `ui.rs` defines layout structures: + - Containers: `PxRow`, `PxGrid`, `PxStack`, `PxScroll`. + - Decoration: `PxMinSize`, `PxMargin`. + - Root marker: `PxUiRoot`. +- `calc_min_size` and `layout_inner` compute per-frame layout in pixel space against `Screen::computed_size`. +- UI is built from the same primitives as the renderer: `PxRect`, `PxText`, `PxSprite`, `PxFilterLayers`. + +### 3.8 Filters as palette-to-palette maps + +- `PxFilterAsset` wraps a `PxImage` mapping `[palette_index, frame] -> palette_index`. +- `PxFilter` attaches a filter to a single entity. +- `PxFilterLayers` lets a filter apply to: + - A single layer (`Single { layer, clip }`) + - A range of layers (`Range(RangeInclusive)`) + - A list of layers (`Many(Vec)`) +- `clip` vs `over` distinguishes “pre-layer only entity pixels” vs “post-layer including background”. + +### 3.9 Picking integration + +- `picking.rs` integrates with `bevy_picking`: + - Uses `PxRect` + `PxFilterLayers` as hit regions. + - Computes per-layer depths with a `BTreeMap` rather than a fixed z. +- Current picking is rectangle-based and does not inspect actual sprite pixels or current animation frames. + +--- + +## 4. Potential Problems / Code Smells + +### 4.1 `init_screen` never marks itself initialized + +**Location:** `src/screen.rs` + +```rust +fn init_screen( + mut initialized: Local, + // ... +) { + if *initialized { + return; + } + + let Some(palette) = palettes.get(&**palette) else { + return; + }; + + // set screen.palette ... + + *initialized = false; +} +``` + +- `initialized` starts as `false`; once a palette is present, this system keeps running every frame. +- Intended behavior appears to be “run once on successful palette load”, but we never set it to `true`. +- Results: unnecessary palette copy per frame and redundant overlap with `update_screen_palette`. + +### 4.2 `PxImage::trim_right` can shrink width to zero + +**Location:** `src/image.rs` + +- `trim_right` trims trailing all-zero columns by: + - Testing the last column for zero. + - Removing it from each row. + - Decrementing `self.width`. +- If the entire image is all zero, `self.width` can reach `0` while the loop still calls `self.height()` (`len / width`), causing division by zero. +- Likely only triggered by fully transparent/misconfigured assets, but still a correctness bug. + +### 4.3 Palette size > 255 not enforced + +**Location:** `src/palette.rs` (`Palette::new`) + +- Assumes “up to 255 colors” (index 0 is special, rest are color entries). +- `indices` map uses `i as u8` for color index, without checking how many colors are in the palette. +- More than 255 colors overflows silently and wraps indices modulo 256 → very confusing visual results. + +### 4.4 `Palette::new` uses `unwrap` inside loader + +**Location:** `src/palette.rs` + +- `image.convert(TextureFormat::Rgba8UnormSrgb).unwrap()` will panic if conversion fails. +- The loader already returns `Result`; this should be reported as an error instead. + +### 4.5 Global static `ASSET_PALETTE` + +**Location:** `src/palette.rs` + +- Uses `static mut ASSET_PALETTE`, `AtomicBool`, and `Event` to coordinate palette initialization. +- The current code is carefully guarded, but the pattern: + - Is non-idiomatic in Bevy (compared to resource-based approaches). + - Is easy to get wrong if extended (unsafe invariants). + +### 4.6 `PxImageSliceMut::for_each_mut` allocates on every call + +**Location:** `src/image.rs` + +```rust +self.image.iter_mut().enumerate().collect::>()[..] + .iter_mut() + .for_each(|(i, row)| { + row.iter_mut().enumerate().collect::>()[..] + .iter_mut() + .for_each(|(j, pixel)| { + f(slice_idx, image_idx, pixel); + }); + }); +``` + +- Each call allocates: + - A `Vec` of rows with their indices. + - For each row, another `Vec` of columns and pixels. +- This is a hot path used to draw sprites, tilemaps, text, filters, etc. The allocations are unnecessary and expensive. + +### 4.7 `PxRect` and `PxLine` draw over entire images + +**Locations:** `src/rect.rs`, `src/line.rs` + +- `PxRect`’s `draw` loops over `0..image.image_width()` × `0..image.image_height()`, then checks `contains_pixel` and `invert` to decide whether to apply the filter. +- `PxLine`: + - Builds a `HashSet` of all Bresenham positions. + - Loops over the entire image, checking membership. +- These approaches scale poorly with screen size and number of rects/lines. + +### 4.8 Minor API / ergonomics issues + +- `update_screen_palette` takes two `PaletteHandle` resources (`palette_handle`, `palette`) and uses them differently: confusing naming. +- `update_key_fields` (`ui.rs`) uses a slightly odd pattern to pick a key event; not wrong, but non-obvious. +- `PxTiles::pos` depends on `tile_poses` map, which can retain stale entries if tiles are despawned directly. + +--- + +## 5. Easy Wins: Correctness & Performance + +These are low-risk, localized changes that are good starting points. + +### 5.1 Fix `init_screen` initialization logic + +**Change:** + +- In `init_screen`, set `*initialized = true` after successfully updating `screen.palette`. +- Keep `update_screen_palette` as the mechanism for responding to palette changes. + +**Benefits:** + +- Avoids unnecessary palette copying every frame. +- Clarifies the intended “run once on initialization” semantics. + +--- + +### 5.2 Harden `PxImage::trim_right` + +**Change:** + +- Add guards to ensure: + - If `self.image.is_empty()` or `self.width == 0`, return immediately. + - If trimming would make `self.width` become `0`, stop trimming. +- Optionally add a small unit test (once a test harness exists) for: + - Fully transparent images. + - Images with a non-empty last column. + +**Benefits:** + +- Eliminates a potential division-by-zero crash on malformed assets. +- Makes trimming behavior explicit and safe. + +--- + +### 5.3 Enforce palette size limit and avoid `unwrap` + +**Changes:** + +1. In `Palette::new`: + - Replace `.convert(...).unwrap()` with an error mapping, e.g.: + + ```rust + let image = image + .convert(TextureFormat::Rgba8UnormSrgb) + .ok_or("could not convert palette image to Rgba8UnormSrgb")?; + ``` + +2. After building `colors`, enforce `colors.len() <= 256`: + - If exceeded, return an error like `"palette contains more than 255 colors (max 255)"`. + +**Benefits:** + +- Avoids panics in the asset loader. +- Gives clear feedback when a palette image is misconfigured. + +--- + +### 5.4 Implement `PxImageSliceMut::for_each_mut` without allocations + +**Change (conceptual):** + +- Re-implement `for_each_mut` as: + - Precompute: + - `row_min`, `row_max` from `slice.min.y` / `slice.max.y`. + - `col_min`, `col_max` from `slice.min.x` / `slice.max.x`. + - Nested loops: + + ```rust + let slice_width = self.slice.width(); + for (row_index, row) in self.image[row_min..row_max].iter_mut().enumerate() { + let y = row_index + row_min; + for x in col_min..col_max { + let slice_i = + (y - self.slice.min.y) * slice_width + (x - self.slice.min.x); + let image_i = y * self.width + x; + let pixel = &mut row[x]; + f(slice_i as usize, image_i as usize, pixel); + } + } + ``` + +- No `Vec` allocations; everything is index arithmetic. + +**Benefits:** + +- Removes per-call allocations from hot paths. +- Likely a noticeable improvement at higher resolutions or entity counts. + +--- + +### 5.5 Restrict `PxRect` and `PxLine` drawing to affected regions + +**Rect (`PxRect` in `src/rect.rs`):** + +- Compute rect bounds in image space once (using the existing position/anchor and slice offsets). +- Intersect the rect with slice bounds, and iterate only over that intersection, applying filter to image pixels inside / outside as per `invert`. + +**Line (`PxLine` in `src/line.rs`):** + +- Instead of: + - Building a `HashSet` of all Bresenham points, then + - Scanning the full image, +- Use only the Bresenham output: + - For each point, map to image coordinates. + - Check whether it lies in the slice; if so, apply filter to that pixel. + - For `invert`, you may still need some complementary logic, but you can avoid scanning the entire image. + +**Benefits:** + +- Complexity becomes proportional to object size, not full screen size. +- Particularly important for larger logical screens or many UI elements. + +--- + +## 6. Baseline Architectural Improvements + +These are more structural and can be tackled after (or alongside) the easy wins. + +### 6.1 Modularize `screen.rs` and `ui.rs` + +**Goal:** Reduce file size and clarify responsibilities. + +**Directions:** + +- `screen`: + - `screen/pipeline.rs` – render graph, `PxPipeline`, `PxUniform`, `PxUniformBuffer`, node registration. + - `screen/node.rs` – `PxRenderNode`, queries, layer aggregation. + - `screen/draw.rs` – per-type drawing (maps, sprites, text, rects, lines, filters, cursor). +- `ui`: + - `ui/layout.rs` – `calc_min_size`, `layout_inner`, `layout`. + - `ui/input.rs` – `PxKeyField`, `PxTextField`, and their update systems. + - `ui/widgets.rs` – `PxRow`, `PxGrid`, `PxStack`, `PxScroll`, etc. + +**Benefits:** + +- Easier navigation and reasoning. +- Clearer extension points for new drawables and UI patterns. + +--- + +### 6.2 Clarify palette lifecycle and ownership + +**Goal:** Reduce reliance on globals and make palette use explicit. + +**Potential future refactor:** + +- Store “asset palette” as a normal resource and/or asset, referenced by handle. +- Pass palette information into loaders via settings or context rather than `asset_palette()` global. +- Keep `Screen::palette` as the runtime palette used by the shader, with `update_screen_palette` as the only mutator. + +**Benefits:** + +- Safer and easier to test. +- Opens the door for multiple palettes, palette blending, etc. + +--- + +### 6.3 Sharpen layering / filtering semantics + +**Goal:** Make it easier to understand and reuse `PxFilterLayers`. + +**Ideas:** + +- Provide helper constructors for common patterns: + - “Filter exactly this layer, clipped”. + - “Overlay filter over these layers, including background”. + - “Filter all layers in a UI group”. +- Consider separating “visual filter layers” from “picking layers” if they diverge further. + +**Benefits:** + +- Reduces boilerplate and potential mistakes in specifying layer ranges. +- Makes behavior more discoverable. + +--- + +## 7. Longer-Term Performance Directions + +### 7.1 Reuse CPU and GPU buffers + +**Idea:** + +- Introduce a `PxRenderBuffer` resource containing: + - CPU `PxImage` backing storage sized to `Screen::computed_size`. + - GPU `Texture` reused across frames. +- On screen resize: + - Reallocate both buffers. +- In `PxRenderNode`: + - Clear the existing buffer each frame. + - Upload new data into the existing texture (no `create_texture` per frame). + +**Benefit:** + +- Less allocation / texture churn at high frame rates. + +### 7.2 Explore GPU-centric palette rendering + +**Idea:** + +- Keep assets palette-indexed but: + - Upload sprites/tiles/typefaces as `R8Uint` textures. + - Draw them directly into a render target using instancing / batching, with palette lookup and filters in shaders. +- Use CPU compositing only when necessary (advanced spatial filters, special effects). + +**Benefit:** + +- Better scalability for large scenes and resolutions. +- Retains palette-driven art style while leveraging GPU strengths. + +--- + +## 8. Directional Architecture Changes (Aligned with Project Goals) + +These changes align directly with the 2025-12 project goals and should be considered alongside the existing roadmap. + +### 8.1 Frame generation as a standalone module + +**Idea:** + +- Introduce a small set of reusable components and traits: + - `PxFrameSource` (asset or runtime source of frames). + - `PxFrameView` (current frame index + optional metadata like size or anchor). + - `PxFrameControl` (explicit or computed frame selection). +- Sprites and other drawables should depend only on `PxFrameView`, not on animation traits. + +**Benefit:** + +- Frames become composable; animation logic can be swapped or driven externally. + +### 8.2 Animation as a separate, optional system + +**Idea:** + +- Move animation into a module that updates `PxFrameControl` based on: + - Manual steps (events, state machines). + - Time-based functions (linear mapping, easing, or custom math). + - Deterministic stepping (fixed tick rate independent of frame rate). +- The animation system should not be required for rendering. + +**Benefit:** + +- Users gain precise control of frame progression and can opt out entirely. + +### 8.3 Pixel-perfect picking for current frame + +**Idea:** + +- Extend picking to support: + - A `PxHitMask` source (derived from the current `PxFrameView`). + - Querying a pixel at cursor position to test transparency or a specific index. +- Keep the existing rectangle picking for coarse/fast checks, but allow pixel-perfect refinement when needed. + +**Benefit:** + +- Enables precise mouse interaction and future collision logic based on pixels. + +--- + +## 9. Implementation Strategy (Prioritized) + +### Phase 1 – Quick, Safe Wins + +1. **Fix `init_screen` flag** + - Set `*initialized = true` after palette is first applied. + - Confirm palette still updates via `update_screen_palette`. + +2. **Harden `PxImage::trim_right`** + - Add width/empty checks. + - Ensure no division-by-zero when trimming fully transparent images. + +3. **Improve `Palette::new` robustness** + - Replace `unwrap` on `convert`. + - Enforce `<= 255` colors and emit a clear error otherwise. + +4. **Optimize `PxImageSliceMut::for_each_mut`** + - Remove all `Vec` allocations; use index arithmetic loops. + +5. **Constrain `PxRect` drawing** + - Limit iteration to intersecting region of rect and slice. + +6. **Constrain `PxLine` drawing** + - Draw only along Bresenham path (plus any required inverted region handling) without scanning full image. + +### Phase 2 – Structural Clean-up + +7. **Refactor `screen.rs` into submodules** + - Keep public API stable; this is internal re-organization. + +8. **Refactor `ui.rs` into layout/input/widgets modules** + - Simplify and clarify key/text field flows. + +### Phase 3 – Palette & Layers + +9. **Redesign palette lifecycle** + - Replace global `ASSET_PALETTE` with resource-centric approach. + - Adjust loaders to use explicit palette context. + +10. **Enhance `PxFilterLayers` ergonomics** + - Add helpers for common patterns and clearer docs. + +### Phase 4 – Advanced Optimization + +11. **Add `PxRenderBuffer` for buffer reuse** + - Reuse CPU/GPU buffers across frames. + +12. **Prototype GPU-centric palette pipeline** + - Keep current CPU path as fallback; experiment behind a feature flag. + +### Phase 5 – Frame / Animation / Picking Goals + +13. **Introduce frame-view components** + - Add `PxFrameView` and `PxFrameControl` to decouple frame selection from sprites. + +14. **Extract animation to a separate module** + - Provide default systems but keep animation opt-in. + +15. **Add pixel-perfect picking** + - Use current frame data to test mouse hits at pixel level. + +--- + +This plan starts with the smallest, safest changes (Phase 1), which you can implement and ship incrementally, and then moves toward deeper refactors and optimizations as needed. diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 0000000..8c50406 --- /dev/null +++ b/GOALS.md @@ -0,0 +1,27 @@ +# seldom_pixel Goals + +Last updated: 2025-12-22 + +## Product Goals + +- Make frame generation a standalone, composable concern. +- Keep animation optional and fully controllable (manual or math-driven). +- Support pixel-perfect picking against the current frame. + +## Design Goals + +- Separate data (frames) from behavior (animation) cleanly. +- Keep rendering predictable: drawables consume a "current frame" view. +- Preserve headless capability and maintain palette-indexed assets. + +## Non-Goals (for now) + +- A fully GPU-only rendering path. +- Overhauling the public API in a breaking way. +- Removing the existing rectangle-based picking. + +## Open Questions + +- Should pixel-perfect picking use raw frame data, post-filter data, or both? +- How should frame selection be expressed: component, resource, or trait object? +- What is the simplest public API that still allows manual control? From 99013dbeb0376a10f0ce01bdd597e1b60355f0c9 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Mon, 22 Dec 2025 18:56:00 -0300 Subject: [PATCH 12/23] repo: Add prototools and rustup for locking down toolchain versions. Add push lefthook git hook. --- .prototools | 4 ++++ lefthook.yml | 12 ++++++++++++ rust-toolchain.toml | 3 +++ 3 files changed, 19 insertions(+) create mode 100644 .prototools create mode 100644 lefthook.yml create mode 100644 rust-toolchain.toml diff --git a/.prototools b/.prototools new file mode 100644 index 0000000..36d6482 --- /dev/null +++ b/.prototools @@ -0,0 +1,4 @@ +lefthook = "2.0.2" + +[plugins.tools] +lefthook = "https://raw.githubusercontent.com/ageha734/proto-plugins/refs/heads/master/toml/lefthook.toml" diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..3797f35 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,12 @@ +pre-commit: + parallel: true + commands: + rustfmt-check: + run: cargo fmt --all -- --check + cargo-clippy: + run: cargo clippy + +pre-push: + commands: + tests: + run: cargo test --lib diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] From 397a96dfefa5bd9685baff36870c263ec627bcd5 Mon Sep 17 00:00:00 2001 From: Marco Toniut Date: Tue, 23 Dec 2025 01:12:27 -0300 Subject: [PATCH 13/23] Refactor screen and UI into submodules --- src/screen.rs | 1043 +-------------------------------- src/screen/draw.rs | 367 ++++++++++++ src/screen/node.rs | 412 +++++++++++++ src/screen/pipeline.rs | 187 ++++++ src/ui.rs | 1265 +--------------------------------------- src/ui/input.rs | 282 +++++++++ src/ui/layout.rs | 862 +++++++++++++++++++++++++++ src/ui/widgets.rs | 96 +++ 8 files changed, 2235 insertions(+), 2279 deletions(-) create mode 100644 src/screen/draw.rs create mode 100644 src/screen/node.rs create mode 100644 src/screen/pipeline.rs create mode 100644 src/ui/input.rs create mode 100644 src/ui/layout.rs create mode 100644 src/ui/widgets.rs diff --git a/src/screen.rs b/src/screen.rs index f9f3a10..daa57e7 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -4,55 +4,34 @@ //! then upload to a reusable `R8Uint` texture and present via a fullscreen quad. //! This is the single compositing path for sprites, text, tilemaps, rects, lines, and filters. -// TODO Split out a module +mod draw; +mod node; +mod pipeline; -use std::{collections::BTreeMap, iter::empty, marker::PhantomData, sync::RwLock}; +use std::marker::PhantomData; use bevy_asset::uuid_handle; #[cfg(feature = "headed")] use bevy_core_pipeline::core_2d::graph::{Core2d, Node2d}; -use bevy_derive::{Deref, DerefMut}; -use bevy_image::TextureFormatPixelInfo; -use bevy_math::{ivec2, uvec2}; +use bevy_math::UVec2; #[cfg(feature = "headed")] use bevy_render::{ Render, RenderApp, RenderSystems, extract_resource::{ExtractResource, ExtractResourcePlugin}, - render_asset::RenderAssets, - render_graph::{ - NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, - }, - render_resource::{ - BindGroupEntries, BindGroupLayout, BindGroupLayoutEntries, CachedRenderPipelineId, - ColorTargetState, ColorWrites, DynamicUniformBuffer, Extent3d, FragmentState, - PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, RenderPipelineDescriptor, - ShaderStages, ShaderType, TexelCopyBufferLayout, Texture, TextureDescriptor, - TextureDimension, TextureFormat, TextureSampleType, TextureUsages, TextureViewDescriptor, - TextureViewDimension, VertexState, - binding_types::{texture_2d, uniform_buffer}, - }, - renderer::{RenderContext, RenderDevice, RenderQueue}, - view::ViewTarget, + render_graph::{RenderGraphExt, RenderLabel, ViewNodeRunner}, }; #[cfg(feature = "headed")] use bevy_window::{PrimaryWindow, WindowResized}; -#[cfg(feature = "line")] -use crate::line::{LineComponents, draw_line}; use crate::{ - animation::draw_spatial, - cursor::{CursorState, PxCursorPosition}, - filter::{FilterComponents, draw_filter}, - image::{PxImage, PxImageSliceMut}, - map::{MapComponents, PxTile, TileComponents}, palette::{Palette, PaletteHandle}, position::PxLayer, prelude::*, - rect::RectComponents, - sprite::SpriteComponents, - text::TextComponents, }; +use node::PxRenderNode; +use pipeline::{PxPipeline, PxRenderBuffer, PxUniformBuffer, prepare_uniform}; + #[cfg(feature = "headed")] const SCREEN_SHADER_HANDLE: Handle = uuid_handle!("48CE4F2C-8B78-5954-08A8-461F62E10E84"); @@ -240,1014 +219,10 @@ fn resize_screen(mut window_resized: MessageReader, mut screen: R } } -#[cfg(feature = "headed")] -#[derive(ShaderType)] -struct PxUniform { - palette: [Vec3; 256], - fit_factor: Vec2, -} - -#[cfg(feature = "headed")] -#[derive(Resource, Deref, DerefMut, Default)] -struct PxUniformBuffer(DynamicUniformBuffer); - -#[cfg(feature = "headed")] -fn prepare_uniform( - mut buffer: ResMut, - screen: Res, - device: Res, - queue: Res, -) { - let Some(mut writer) = buffer.get_writer(1, &device, &queue) else { - return; - }; - - let aspect_ratio_ratio = - screen.computed_size.x as f32 / screen.computed_size.y as f32 / screen.window_aspect_ratio; - writer.write(&PxUniform { - palette: screen.palette, - fit_factor: if aspect_ratio_ratio > 1. { - Vec2::new(1., 1. / aspect_ratio_ratio) - } else { - Vec2::new(aspect_ratio_ratio, 1.) - }, - }); -} - -#[cfg(feature = "headed")] -#[derive(Resource)] -struct PxPipeline { - layout: BindGroupLayout, - id: CachedRenderPipelineId, -} - -#[cfg(feature = "headed")] -impl FromWorld for PxPipeline { - fn from_world(world: &mut World) -> Self { - let render_device = world.resource::(); - - let layout = render_device.create_bind_group_layout( - "px_bind_group_layout", - &BindGroupLayoutEntries::sequential( - ShaderStages::FRAGMENT, - ( - texture_2d(TextureSampleType::Uint), - uniform_buffer::(false).visibility(ShaderStages::VERTEX_FRAGMENT), - ), - ), - ); - - Self { - id: world.resource_mut::().queue_render_pipeline( - RenderPipelineDescriptor { - label: Some("px_pipeline".into()), - layout: vec![layout.clone()], - vertex: VertexState { - shader: SCREEN_SHADER_HANDLE, - shader_defs: Vec::new(), - entry_point: Some("vertex".into()), - buffers: Vec::new(), - }, - fragment: Some(FragmentState { - shader: SCREEN_SHADER_HANDLE, - shader_defs: Vec::new(), - entry_point: Some("fragment".into()), - targets: vec![Some(ColorTargetState { - format: TextureFormat::bevy_default(), - blend: None, - write_mask: ColorWrites::ALL, - })], - }), - primitive: default(), - depth_stencil: None, - multisample: default(), - push_constant_ranges: Vec::new(), - zero_initialize_workgroup_memory: true, - }, - ), - layout, - } - } -} - -#[cfg(feature = "headed")] -#[derive(Resource)] -struct PxRenderBuffer { - inner: RwLock, -} - -#[cfg(feature = "headed")] -struct PxRenderBufferInner { - size: UVec2, - image: Option, - texture: Option, -} - -#[cfg(feature = "headed")] -impl Default for PxRenderBuffer { - fn default() -> Self { - Self { - inner: RwLock::new(PxRenderBufferInner { - size: UVec2::ZERO, - image: None, - texture: None, - }), - } - } -} - -#[cfg(feature = "headed")] -impl PxRenderBuffer { - fn ensure_size(&self, device: &RenderDevice, size: UVec2) { - let mut inner = self.inner.write().unwrap(); - if size == inner.size && inner.image.is_some() && inner.texture.is_some() { - return; - } - - inner.size = size; - - let descriptor = TextureDescriptor { - label: Some("px_present_texture"), - size: Extent3d { - width: size.x, - height: size.y, - depth_or_array_layers: 1, - }, - dimension: TextureDimension::D2, - format: TextureFormat::R8Uint, - sample_count: 1, - mip_level_count: 1, - usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, - view_formats: &[], - }; - - inner.texture = Some(device.create_texture(&descriptor)); - inner.image = Some(Image::new_fill( - descriptor.size, - descriptor.dimension, - &[0], - descriptor.format, - default(), - )); - } - - fn clear(&self) { - let mut inner = self.inner.write().unwrap(); - if let Some(image) = inner.image.as_mut() - && let Some(data) = image.data.as_mut() - { - data.fill(0); - } - } - - fn read_inner(&self) -> std::sync::RwLockReadGuard<'_, PxRenderBufferInner> { - self.inner.read().unwrap() - } - - fn write_inner(&self) -> std::sync::RwLockWriteGuard<'_, PxRenderBufferInner> { - self.inner.write().unwrap() - } -} - #[cfg(feature = "headed")] #[derive(RenderLabel, Hash, Eq, PartialEq, Clone, Debug)] struct PxRender; -struct PxRenderNode { - maps: QueryState>, - tiles: QueryState, - // image_to_sprites: QueryState>, - sprites: QueryState>, - texts: QueryState>, - rects: QueryState>, - #[cfg(feature = "line")] - lines: QueryState>, - filters: QueryState, Without>, -} - -impl FromWorld for PxRenderNode { - fn from_world(world: &mut World) -> Self { - Self { - maps: world.query(), - tiles: world.query(), - // image_to_sprites: world.query(), - sprites: world.query(), - texts: world.query(), - rects: world.query(), - #[cfg(feature = "line")] - lines: world.query(), - filters: world.query_filtered(), - } - } -} - -#[cfg(feature = "headed")] -impl ViewNode for PxRenderNode { - type ViewQuery = &'static ViewTarget; - - fn update(&mut self, world: &mut World) { - self.maps.update_archetypes(world); - self.tiles.update_archetypes(world); - // self.image_to_sprites.update_archetypes(world); - self.sprites.update_archetypes(world); - self.texts.update_archetypes(world); - self.rects.update_archetypes(world); - #[cfg(feature = "line")] - self.lines.update_archetypes(world); - self.filters.update_archetypes(world); - } - - fn run<'w>( - &self, - _: &mut RenderGraphContext, - render_context: &mut RenderContext<'w>, - target: &ViewTarget, - world: &'w World, - ) -> Result<(), NodeRunError> { - // Compose each layer into a CPU buffer, then blit to the GPU texture once per frame. - let &camera = world.resource::(); - let screen = world.resource::(); - - let device = world.resource::(); - let render_buffer = world.resource::(); - render_buffer.ensure_size(device, screen.computed_size); - render_buffer.clear(); - - #[cfg(feature = "line")] - let mut layer_contents = BTreeMap::< - _, - ( - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - ), - >::default(); - #[cfg(not(feature = "line"))] - let mut layer_contents = BTreeMap::< - _, - ( - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - (), - Vec<_>, - Vec<_>, - (), - Vec<_>, - ), - >::default(); - - for (map, &pos, layer, &canvas, animation, filter) in self.maps.iter_manual(world) { - let map = (map, pos, canvas, animation, filter); - - if let Some((maps, _, _, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { - maps.push(map); - } else { - BTreeMap::insert( - &mut layer_contents, - layer.clone(), - ( - vec![map], - Vec::new(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - ), - ); - } - } - - // for (image, position, anchor, layer, canvas, filter) in - // self.image_to_sprites.iter_manual(world) - // { - // if let Some((_, image_to_sprites, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { - // image_to_sprites.push((image, position, anchor, canvas, filter)); - // } else { - // layer_contents.insert( - // layer.clone(), - // ( - // default(), - // vec![(image, position, anchor, canvas, filter)], - // default(), - // default(), - // default(), - // default(), - // default(), - // default(), - // ), - // ); - // } - // } - - for (sprite, &position, &anchor, layer, &canvas, animation, filter) in - self.sprites.iter_manual(world) - { - let sprite = (sprite, position, anchor, canvas, animation, filter); - - if let Some((_, sprites, _, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { - sprites.push(sprite); - } else { - BTreeMap::insert( - &mut layer_contents, - layer.clone(), - ( - Vec::new(), - vec![sprite], - Vec::new(), - Vec::new(), - default(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - ), - ); - } - } - - for (text, &pos, &alignment, layer, &canvas, animation, filter) in - self.texts.iter_manual(world) - { - let text = (text, pos, alignment, canvas, animation, filter); - - if let Some((_, _, texts, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { - texts.push(text); - } else { - BTreeMap::insert( - &mut layer_contents, - layer.clone(), - ( - Vec::new(), - Vec::new(), - vec![text], - Vec::new(), - default(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - ), - ); - } - } - - for (&rect, filter, layers, &pos, &anchor, &canvas, animation, invert) in - self.rects.iter_manual(world) - { - for (layer, clip) in match layers { - PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], - // TODO Need to do this after all layers have been extracted - PxFilterLayers::Range(range) => layer_contents - .keys() - .filter(|layer| range.contains(layer)) - .map(|layer| (layer.clone(), true)) - .collect(), - PxFilterLayers::Many(layers) => { - layers.iter().map(|layer| (layer.clone(), true)).collect() - } - } - .into_iter() - { - let rect = (rect, filter, pos, anchor, canvas, animation, invert); - - if let Some((_, _, _, clip_rects, _, _, over_rects, _, _)) = - layer_contents.get_mut(&layer) - { - if clip { clip_rects } else { over_rects }.push(rect); - } else { - let rects = vec![rect]; - - BTreeMap::insert( - &mut layer_contents, - layer, - if clip { - ( - Vec::new(), - Vec::new(), - Vec::new(), - rects, - default(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - ) - } else { - ( - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - rects, - default(), - Vec::new(), - ) - }, - ); - } - } - } - - #[cfg(feature = "line")] - for (line, filter, layers, &canvas, animation, invert) in self.lines.iter_manual(world) { - let line = (line, filter, canvas, animation, invert); - - for (layer, clip) in match layers { - PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], - PxFilterLayers::Range(range) => layer_contents - .keys() - .filter(|layer| range.contains(layer)) - .map(|layer| (layer.clone(), true)) - .collect(), - PxFilterLayers::Many(layers) => { - layers.iter().map(|layer| (layer.clone(), true)).collect() - } - } - .into_iter() - { - if let Some((_, _, _, _, clip_lines, _, _, over_lines, _)) = - layer_contents.get_mut(&layer) - { - if clip { clip_lines } else { over_lines }.push(line); - } else { - let lines = vec![line]; - - BTreeMap::insert( - &mut layer_contents, - layer, - if clip { - ( - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - lines, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ) - } else { - ( - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - lines, - Vec::new(), - ) - }, - ); - } - } - } - - for (filter, layers, animation) in self.filters.iter_manual(world) { - let filter = (filter, animation); - - for (layer, clip) in match layers { - PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], - PxFilterLayers::Range(range) => layer_contents - .keys() - .filter(|layer| range.contains(layer)) - .map(|layer| (layer.clone(), true)) - .collect(), - PxFilterLayers::Many(layers) => { - layers.iter().map(|layer| (layer.clone(), true)).collect() - } - } - .into_iter() - { - if let Some((_, _, _, _, _, clip_filters, _, _, over_filters)) = - layer_contents.get_mut(&layer) - { - if clip { clip_filters } else { over_filters }.push(filter); - } else { - let filters = vec![filter]; - - BTreeMap::insert( - &mut layer_contents, - layer, - if clip { - ( - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - default(), - filters, - Vec::new(), - default(), - Vec::new(), - ) - } else { - ( - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - default(), - Vec::new(), - Vec::new(), - default(), - filters, - ) - }, - ); - } - } - } - - let tilesets = world.resource::>(); - // let images = world.resource::>(); - let sprite_assets = world.resource::>(); - let typefaces = world.resource::>(); - let filters = world.resource::>(); - - { - let mut inner = render_buffer.write_inner(); - let image = inner.image.as_mut().unwrap(); - let mut layer_image = PxImage::empty_from_image(image); - let mut image_slice = PxImageSliceMut::from_image_mut(image).unwrap(); - - #[allow(unused_variables)] - for ( - _, - ( - maps, - // image_to_sprites, - sprites, - texts, - clip_rects, - clip_lines, - clip_filters, - over_rects, - over_lines, - over_filters, - ), - ) in layer_contents.into_iter() - { - layer_image.clear(); - let mut layer_slice = layer_image.slice_all_mut(); - - for (map, position, canvas, frame, map_filter) in maps { - let Some(tileset) = tilesets.get(&map.tileset) else { - continue; - }; - - let map_filter = map_filter.and_then(|map_filter| filters.get(&**map_filter)); - let size = map.tiles.size(); - - for x in 0..size.x { - for y in 0..size.y { - let pos = UVec2::new(x, y); - - let Some(tile) = map.tiles.get(pos) else { - continue; - }; - - let Ok((&PxTile { texture }, tile_filter)) = - self.tiles.get_manual(world, tile) - else { - continue; - }; - - let Some(tile) = tileset.tileset.get(texture as usize) else { - error!( - "tile texture index out of bounds: the len is {}, but the index is {texture}", - tileset.tileset.len() - ); - continue; - }; - - draw_spatial( - tile, - (), - &mut layer_slice, - (*position + pos.as_ivec2() * tileset.tile_size().as_ivec2()) - .into(), - PxAnchor::BottomLeft, - canvas, - frame.copied(), - [ - tile_filter.and_then(|tile_filter| filters.get(&**tile_filter)), - map_filter, - ] - .into_iter() - .flatten(), - camera, - ); - } - } - } - - // I was trying to make `ImageToSprite` work without 1-frame lag, but this - // fundamentally needs GPU readback or something bc you can't just get image data - // from a `GpuImage`. I think those represent images that're actually on the GPU. So - // here's where I left off with that. I don't need `ImageToSprite` at the moment, so - // this will be left incomplete until I need it, if I ever do. - - // // TODO Use more helpers - // // TODO Feature gate - // // TODO Immediate function version - // for (image, position, anchor, canvas, filter) in image_to_sprites { - // // let palette = screen.palette - // // .colors - // // .iter() - // // .map(|&color| Oklaba::from(Srgba::from_u8_array_no_alpha(color)).to_vec3()) - // // .collect::>(); - - // let palette_tree = ImmutableKdTree::from( - // &screen - // .palette - // .iter() - // .map(|&color| color.into()) - // .collect::>()[..], - // ); - - // let dither = &image.dither; - // let Some(image) = images.get(&image.image) else { - // continue; - // }; - - // // TODO https://github.com/bevyengine/bevy/blob/v0.14.1/examples/app/headless_renderer.rs - // let size = image.size; - // let data = PxImage::empty(size); - - // let mut sprite = PxSprite { - // frame_size: data.area(), - // data, - // }; - - // let mut pixels = image - // .data - // .chunks_exact(4) - // .zip(sprite.data.iter_mut()) - // .enumerate() - // .collect::>(); - - // pixels.par_chunk_map_mut(ComputeTaskPool::get(), 20, |_, pixels| { - // use DitherAlgorithm::*; - // use ThresholdMap::*; - - // match *dither { - // None => dither_slice::( - // pixels, - // 0., - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X2_2, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X4_4, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Ordered, - // threshold, - // threshold_map: X8_8, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X2_2, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X4_4, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // Some(Dither { - // algorithm: Pattern, - // threshold, - // threshold_map: X8_8, - // }) => dither_slice::( - // pixels, - // threshold, - // size, - // &screen.palette_tree, - // &screen.palette, - // ), - // } - // }); - - // draw_spatial( - // &sprite, - // (), - // &mut layer_image, - // *position, - // *anchor, - // *canvas, - // None, - // filter.and_then(|filter| filters.get(filter)), - // camera, - // ); - // } - - for (sprite, position, anchor, canvas, frame, filter) in sprites { - let Some(sprite) = sprite_assets.get(&**sprite) else { - continue; - }; - - draw_spatial( - sprite, - (), - &mut layer_slice, - position, - anchor, - canvas, - frame.copied(), - filter.and_then(|filter| filters.get(&**filter)), - camera, - ); - } - - for (text, pos, alignment, canvas, frame, filter) in texts { - let Some(typeface) = typefaces.get(&text.typeface) else { - continue; - }; - - let line_break_count = text.line_breaks.len() as u32; - let mut size = uvec2( - 0, - (line_break_count + 1) * typeface.height + line_break_count, - ); - let mut x = 0; - let mut y = 0; - let mut chars = Vec::new(); - let mut line_break_index = 0; - - for (index, char) in text.value.chars().enumerate() { - if let Some(char) = typeface.characters.get(&char) { - if x != 0 { - x += 1; - } - - chars.push((x, y, char)); - x += char.data.size().x; - - if x > size.x { - size.x = x; - } - } else if let Some(separator) = typeface.separators.get(&char) { - x += separator.width; - } else { - error!(r#"character "{char}" in text isn't in typeface"#); - } - - if text.line_breaks.get(line_break_index).copied() == Some(index as u32) { - line_break_index += 1; - y += typeface.height + 1; - x = 0; - } - } - - let top_left = - *pos - alignment.pos(size).as_ivec2() + ivec2(0, size.y as i32 - 1); - - for (x, y, char) in chars { - draw_spatial( - char, - (), - &mut layer_slice, - PxPosition(top_left + ivec2(x as i32, -(y as i32))), - PxAnchor::TopLeft, - canvas, - frame.copied(), - filter.and_then(|filter| filters.get(&**filter)), - camera, - ); - } - } - - for (rect, filter, pos, anchor, canvas, frame, invert) in clip_rects { - if let Some(filter) = filters.get(&**filter) { - draw_spatial( - &(rect, filter), - invert, - &mut layer_slice, - pos, - anchor, - canvas, - frame.copied(), - empty(), - camera, - ); - } - } - - // This is where I draw the line! /j - #[cfg(feature = "line")] - for (line, filter, canvas, frame, invert) in clip_lines { - if let Some(filter) = filters.get(&**filter) { - draw_line( - line, - filter, - invert, - &mut layer_slice, - canvas, - frame.copied(), - camera, - ); - } - } - - for (filter, frame) in clip_filters { - if let Some(filter) = filters.get(&**filter) { - draw_filter(filter, frame.copied(), &mut layer_slice); - } - } - - image_slice.draw(&layer_image); - - for (rect, filter, pos, anchor, canvas, frame, invert) in over_rects { - if let Some(filter) = filters.get(&**filter) { - draw_spatial( - &(rect, filter), - invert, - &mut image_slice, - pos, - anchor, - canvas, - frame.copied(), - empty(), - camera, - ); - } - } - - #[cfg(feature = "line")] - for (line, filter, canvas, frame, invert) in over_lines { - if let Some(filter) = filters.get(&**filter) { - draw_line( - line, - filter, - invert, - &mut image_slice, - canvas, - frame.copied(), - camera, - ); - } - } - - for (filter, frame) in over_filters { - if let Some(filter) = filters.get(&**filter) { - draw_filter(filter, frame.copied(), &mut image_slice); - } - } - } - } - - let cursor = world.resource::(); - - if let PxCursor::Filter { - idle, - left_click, - right_click, - } = world.resource() - && let Some(cursor_pos) = **world.resource::() - && let Some(PxFilterAsset(filter)) = filters.get(match cursor { - CursorState::Idle => idle, - CursorState::Left => left_click, - CursorState::Right => right_click, - }) - { - let mut inner = render_buffer.write_inner(); - let image = inner.image.as_mut().unwrap(); - let mut cursor_image = PxImageSliceMut::from_image_mut(image).unwrap(); - if let Some(pixel) = cursor_image.get_pixel_mut(IVec2::new( - cursor_pos.x as i32, - cursor_image.height() as i32 - 1 - cursor_pos.y as i32, - )) { - if let Some(new_pixel) = filter.get_pixel(IVec2::new(*pixel as i32, 0)) { - *pixel = new_pixel; - } else { - error!("`PxCursor` filter is the wrong size"); - } - } - } - - let Some(uniform_binding) = world.resource::().binding() else { - return Ok(()); - }; - - let inner = render_buffer.read_inner(); - let texture = inner.texture.as_ref().unwrap(); - let image = inner.image.as_ref().unwrap(); - let image_descriptor = image.texture_descriptor.clone(); - - let Ok(pixel_size) = image_descriptor.format.pixel_size() else { - return Ok(()); - }; - - world.resource::().write_texture( - texture.as_image_copy(), - image.data.as_ref().unwrap(), - TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(image.width() * pixel_size as u32), - rows_per_image: None, - }, - image_descriptor.size, - ); - - let texture_view = texture.create_view(&TextureViewDescriptor { - label: Some("px_texture_view"), - format: Some(image_descriptor.format), - dimension: Some(TextureViewDimension::D2), - ..default() - }); - - let px_pipeline = world.resource::(); - let Some(pipeline) = world - .resource::() - .get_render_pipeline(px_pipeline.id) - else { - return Ok(()); - }; - - let post_process = target.post_process_write(); - - let bind_group = render_context.render_device().create_bind_group( - "px_bind_group", - &px_pipeline.layout, - &BindGroupEntries::sequential((&texture_view, uniform_binding.clone())), - ); - - let mut render_pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { - label: Some("px_pass"), - color_attachments: &[Some(RenderPassColorAttachment { - view: post_process.destination, - depth_slice: None, - resolve_target: None, - ops: default(), - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - - render_pass.set_render_pipeline(pipeline); - render_pass.set_bind_group(0, &bind_group, &[]); - render_pass.draw(0..6, 0..1); - - Ok(()) - } -} - fn update_screen_palette( mut waiting_for_load: Local, palette_handle: Res, diff --git a/src/screen/draw.rs b/src/screen/draw.rs new file mode 100644 index 0000000..570b76a --- /dev/null +++ b/src/screen/draw.rs @@ -0,0 +1,367 @@ +use std::collections::BTreeMap; + +#[cfg(feature = "headed")] +use bevy_ecs::{query::QueryState, world::World}; +#[cfg(feature = "headed")] +use bevy_render::render_asset::RenderAssets; + +#[cfg(feature = "line")] +use crate::line::draw_line; +use crate::{ + animation::draw_spatial, + cursor::{CursorState, PxCursorPosition}, + filter::{PxFilterAsset, draw_filter}, + image::{PxImage, PxImageSliceMut}, + map::{PxTile, PxTileset}, + prelude::*, + sprite::PxSpriteAsset, + text::PxTypeface, +}; + +use super::pipeline::PxRenderBuffer; +use crate::map::TileComponents; + +pub(crate) type MapEntry<'a> = ( + &'a PxMap, + PxPosition, + PxCanvas, + Option<&'a PxFrame>, + Option<&'a PxFilter>, +); + +pub(crate) type SpriteEntry<'a> = ( + &'a PxSprite, + PxPosition, + PxAnchor, + PxCanvas, + Option<&'a PxFrame>, + Option<&'a PxFilter>, +); + +pub(crate) type TextEntry<'a> = ( + &'a PxText, + PxPosition, + PxAnchor, + PxCanvas, + Option<&'a PxFrame>, + Option<&'a PxFilter>, +); + +pub(crate) type RectEntry<'a> = ( + PxRect, + &'a PxFilter, + PxPosition, + PxAnchor, + PxCanvas, + Option<&'a PxFrame>, + bool, +); + +#[cfg(feature = "line")] +pub(crate) type LineEntry<'a> = ( + &'a PxLine, + &'a PxFilter, + PxCanvas, + Option<&'a PxFrame>, + bool, +); + +pub(crate) type FilterEntry<'a> = (&'a PxFilter, Option<&'a PxFrame>); + +#[cfg(feature = "line")] +pub(crate) type LayerContents<'a> = ( + Vec>, + Vec>, + Vec>, + Vec>, + Vec>, + Vec>, + Vec>, + Vec>, + Vec>, +); + +#[cfg(not(feature = "line"))] +pub(crate) type LayerContents<'a> = ( + Vec>, + Vec>, + Vec>, + Vec>, + (), + Vec>, + Vec>, + (), + Vec>, +); + +pub(crate) type LayerContentsMap<'a, L> = BTreeMap>; + +#[cfg(feature = "headed")] +pub(crate) fn draw_layers<'w, L: PxLayer>( + world: &'w World, + render_buffer: &PxRenderBuffer, + camera: PxCamera, + layer_contents: LayerContentsMap<'w, L>, + tiles: &QueryState, +) { + let tilesets = world.resource::>(); + let sprite_assets = world.resource::>(); + let typefaces = world.resource::>(); + let filters = world.resource::>(); + + { + let mut inner = render_buffer.write_inner(); + let image = inner.image.as_mut().unwrap(); + let mut layer_image = PxImage::empty_from_image(image); + let mut image_slice = PxImageSliceMut::from_image_mut(image).unwrap(); + + #[allow(unused_variables)] + for ( + _, + ( + maps, + sprites, + texts, + clip_rects, + clip_lines, + clip_filters, + over_rects, + over_lines, + over_filters, + ), + ) in layer_contents.into_iter() + { + layer_image.clear(); + let mut layer_slice = layer_image.slice_all_mut(); + + for (map, position, canvas, frame, map_filter) in maps { + let Some(tileset) = tilesets.get(&map.tileset) else { + continue; + }; + + let map_filter = map_filter.and_then(|map_filter| filters.get(&**map_filter)); + let size = map.tiles.size(); + + for x in 0..size.x { + for y in 0..size.y { + let pos = UVec2::new(x, y); + + let Some(tile) = map.tiles.get(pos) else { + continue; + }; + + let Ok((&PxTile { texture }, tile_filter)) = tiles.get_manual(world, tile) + else { + continue; + }; + + let Some(tile) = tileset.tileset.get(texture as usize) else { + error!( + "tile texture index out of bounds: the len is {}, but the index is {texture}", + tileset.tileset.len() + ); + continue; + }; + + draw_spatial( + tile, + (), + &mut layer_slice, + (*position + pos.as_ivec2() * tileset.tile_size().as_ivec2()).into(), + PxAnchor::BottomLeft, + canvas, + frame.copied(), + [ + tile_filter.and_then(|tile_filter| filters.get(&**tile_filter)), + map_filter, + ] + .into_iter() + .flatten(), + camera, + ); + } + } + } + + for (sprite, position, anchor, canvas, frame, filter) in sprites { + let Some(sprite) = sprite_assets.get(&**sprite) else { + continue; + }; + + draw_spatial( + sprite, + (), + &mut layer_slice, + position, + anchor, + canvas, + frame.copied(), + filter.and_then(|filter| filters.get(&**filter)), + camera, + ); + } + + for (text, pos, alignment, canvas, frame, filter) in texts { + let Some(typeface) = typefaces.get(&text.typeface) else { + continue; + }; + + let line_break_count = text.line_breaks.len() as u32; + let mut size = uvec2( + 0, + (line_break_count + 1) * typeface.height + line_break_count, + ); + let mut x = 0; + let mut y = 0; + let mut chars = Vec::new(); + let mut line_break_index = 0; + + for (index, char) in text.value.chars().enumerate() { + if let Some(char) = typeface.characters.get(&char) { + if x != 0 { + x += 1; + } + + chars.push((x, y, char)); + x += char.data.size().x; + + if x > size.x { + size.x = x; + } + } else if let Some(separator) = typeface.separators.get(&char) { + x += separator.width; + } else { + error!(r#"character "{char}" in text isn't in typeface"#); + } + + if text.line_breaks.get(line_break_index).copied() == Some(index as u32) { + line_break_index += 1; + y += typeface.height + 1; + x = 0; + } + } + + let top_left = *pos - alignment.pos(size).as_ivec2() + ivec2(0, size.y as i32 - 1); + + for (x, y, char) in chars { + draw_spatial( + char, + (), + &mut layer_slice, + PxPosition(top_left + ivec2(x as i32, -(y as i32))), + PxAnchor::TopLeft, + canvas, + frame.copied(), + filter.and_then(|filter| filters.get(&**filter)), + camera, + ); + } + } + + for (rect, filter, pos, anchor, canvas, frame, invert) in clip_rects { + if let Some(filter) = filters.get(&**filter) { + draw_spatial( + &(rect, filter), + invert, + &mut layer_slice, + pos, + anchor, + canvas, + frame.copied(), + std::iter::empty(), + camera, + ); + } + } + + #[cfg(feature = "line")] + for (line, filter, canvas, frame, invert) in clip_lines { + if let Some(filter) = filters.get(&**filter) { + draw_line( + line, + filter, + invert, + &mut layer_slice, + canvas, + frame.copied(), + camera, + ); + } + } + + for (filter, frame) in clip_filters { + if let Some(filter) = filters.get(&**filter) { + draw_filter(filter, frame.copied(), &mut layer_slice); + } + } + + image_slice.draw(&layer_image); + + for (rect, filter, pos, anchor, canvas, frame, invert) in over_rects { + if let Some(filter) = filters.get(&**filter) { + draw_spatial( + &(rect, filter), + invert, + &mut image_slice, + pos, + anchor, + canvas, + frame.copied(), + std::iter::empty(), + camera, + ); + } + } + + #[cfg(feature = "line")] + for (line, filter, canvas, frame, invert) in over_lines { + if let Some(filter) = filters.get(&**filter) { + draw_line( + line, + filter, + invert, + &mut image_slice, + canvas, + frame.copied(), + camera, + ); + } + } + + for (filter, frame) in over_filters { + if let Some(filter) = filters.get(&**filter) { + draw_filter(filter, frame.copied(), &mut image_slice); + } + } + } + } + + let cursor = world.resource::(); + + if let PxCursor::Filter { + idle, + left_click, + right_click, + } = world.resource() + && let Some(cursor_pos) = **world.resource::() + && let Some(PxFilterAsset(filter)) = filters.get(match cursor { + CursorState::Idle => idle, + CursorState::Left => left_click, + CursorState::Right => right_click, + }) + { + let mut inner = render_buffer.write_inner(); + let image = inner.image.as_mut().unwrap(); + let mut cursor_image = PxImageSliceMut::from_image_mut(image).unwrap(); + if let Some(pixel) = cursor_image.get_pixel_mut(IVec2::new( + cursor_pos.x as i32, + cursor_image.height() as i32 - 1 - cursor_pos.y as i32, + )) { + if let Some(new_pixel) = filter.get_pixel(IVec2::new(*pixel as i32, 0)) { + *pixel = new_pixel; + } else { + error!("`PxCursor` filter is the wrong size"); + } + } + } +} diff --git a/src/screen/node.rs b/src/screen/node.rs new file mode 100644 index 0000000..4ac4059 --- /dev/null +++ b/src/screen/node.rs @@ -0,0 +1,412 @@ +use std::collections::BTreeMap; + +use bevy_ecs::query::QueryState; +use bevy_image::TextureFormatPixelInfo; +#[cfg(feature = "headed")] +use bevy_render::{ + render_graph::{NodeRunError, RenderGraphContext, ViewNode}, + render_resource::{ + BindGroupEntries, PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, + TexelCopyBufferLayout, TextureViewDescriptor, TextureViewDimension, + }, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::ViewTarget, +}; + +#[cfg(feature = "line")] +use crate::line::LineComponents; +use crate::{ + filter::FilterComponents, + map::{MapComponents, TileComponents}, + position::PxLayer, + prelude::*, + rect::RectComponents, + sprite::SpriteComponents, + text::TextComponents, +}; + +use super::{ + Screen, + draw::{self, LayerContentsMap}, + pipeline::{PxPipeline, PxRenderBuffer, PxUniformBuffer}, +}; + +pub(crate) struct PxRenderNode { + maps: QueryState>, + tiles: QueryState, + // image_to_sprites: QueryState>, + sprites: QueryState>, + texts: QueryState>, + rects: QueryState>, + #[cfg(feature = "line")] + lines: QueryState>, + filters: QueryState, Without>, +} + +impl FromWorld for PxRenderNode { + fn from_world(world: &mut World) -> Self { + Self { + maps: world.query(), + tiles: world.query(), + // image_to_sprites: world.query(), + sprites: world.query(), + texts: world.query(), + rects: world.query(), + #[cfg(feature = "line")] + lines: world.query(), + filters: world.query_filtered(), + } + } +} + +#[cfg(feature = "headed")] +impl ViewNode for PxRenderNode { + type ViewQuery = &'static ViewTarget; + + fn update(&mut self, world: &mut World) { + self.maps.update_archetypes(world); + self.tiles.update_archetypes(world); + // self.image_to_sprites.update_archetypes(world); + self.sprites.update_archetypes(world); + self.texts.update_archetypes(world); + self.rects.update_archetypes(world); + #[cfg(feature = "line")] + self.lines.update_archetypes(world); + self.filters.update_archetypes(world); + } + + fn run<'w>( + &self, + _: &mut RenderGraphContext, + render_context: &mut RenderContext<'w>, + target: &ViewTarget, + world: &'w World, + ) -> Result<(), NodeRunError> { + // Compose each layer into a CPU buffer, then blit to the GPU texture once per frame. + let &camera = world.resource::(); + let screen = world.resource::(); + + let device = world.resource::(); + let render_buffer = world.resource::(); + render_buffer.ensure_size(device, screen.computed_size); + render_buffer.clear(); + + let mut layer_contents: LayerContentsMap<'w, L> = BTreeMap::default(); + + for (map, &pos, layer, &canvas, animation, filter) in self.maps.iter_manual(world) { + let map = (map, pos, canvas, animation, filter); + + if let Some((maps, _, _, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { + maps.push(map); + } else { + BTreeMap::insert( + &mut layer_contents, + layer.clone(), + ( + vec![map], + Vec::new(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + ), + ); + } + } + + for (sprite, &position, &anchor, layer, &canvas, animation, filter) in + self.sprites.iter_manual(world) + { + let sprite = (sprite, position, anchor, canvas, animation, filter); + + if let Some((_, sprites, _, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { + sprites.push(sprite); + } else { + BTreeMap::insert( + &mut layer_contents, + layer.clone(), + ( + Vec::new(), + vec![sprite], + Vec::new(), + Vec::new(), + default(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + ), + ); + } + } + + for (text, &pos, &alignment, layer, &canvas, animation, filter) in + self.texts.iter_manual(world) + { + let text = (text, pos, alignment, canvas, animation, filter); + + if let Some((_, _, texts, _, _, _, _, _, _)) = layer_contents.get_mut(layer) { + texts.push(text); + } else { + BTreeMap::insert( + &mut layer_contents, + layer.clone(), + ( + Vec::new(), + Vec::new(), + vec![text], + Vec::new(), + default(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + ), + ); + } + } + + for (&rect, filter, layers, &pos, &anchor, &canvas, animation, invert) in + self.rects.iter_manual(world) + { + for (layer, clip) in match layers { + PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], + // TODO Need to do this after all layers have been extracted + PxFilterLayers::Range(range) => layer_contents + .keys() + .filter(|layer| range.contains(layer)) + .map(|layer| (layer.clone(), true)) + .collect(), + PxFilterLayers::Many(layers) => { + layers.iter().map(|layer| (layer.clone(), true)).collect() + } + } + .into_iter() + { + let rect = (rect, filter, pos, anchor, canvas, animation, invert); + + if let Some((_, _, _, clip_rects, _, _, over_rects, _, _)) = + layer_contents.get_mut(&layer) + { + if clip { clip_rects } else { over_rects }.push(rect); + } else { + let rects = vec![rect]; + + BTreeMap::insert( + &mut layer_contents, + layer, + if clip { + ( + Vec::new(), + Vec::new(), + Vec::new(), + rects, + default(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + ) + } else { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + rects, + default(), + Vec::new(), + ) + }, + ); + } + } + } + + #[cfg(feature = "line")] + for (line, filter, layers, &canvas, animation, invert) in self.lines.iter_manual(world) { + let line = (line, filter, canvas, animation, invert); + + for (layer, clip) in match layers { + PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], + PxFilterLayers::Range(range) => layer_contents + .keys() + .filter(|layer| range.contains(layer)) + .map(|layer| (layer.clone(), true)) + .collect(), + PxFilterLayers::Many(layers) => { + layers.iter().map(|layer| (layer.clone(), true)).collect() + } + } + .into_iter() + { + if let Some((_, _, _, _, clip_lines, _, _, over_lines, _)) = + layer_contents.get_mut(&layer) + { + if clip { clip_lines } else { over_lines }.push(line); + } else { + let lines = vec![line]; + + BTreeMap::insert( + &mut layer_contents, + layer, + if clip { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + lines, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + } else { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + lines, + Vec::new(), + ) + }, + ); + } + } + } + + for (filter, layers, animation) in self.filters.iter_manual(world) { + let filter = (filter, animation); + + for (layer, clip) in match layers { + PxFilterLayers::Single { layer, clip } => vec![(layer.clone(), *clip)], + PxFilterLayers::Range(range) => layer_contents + .keys() + .filter(|layer| range.contains(layer)) + .map(|layer| (layer.clone(), true)) + .collect(), + PxFilterLayers::Many(layers) => { + layers.iter().map(|layer| (layer.clone(), true)).collect() + } + } + .into_iter() + { + if let Some((_, _, _, _, _, clip_filters, _, _, over_filters)) = + layer_contents.get_mut(&layer) + { + if clip { clip_filters } else { over_filters }.push(filter); + } else { + let filters = vec![filter]; + + BTreeMap::insert( + &mut layer_contents, + layer, + if clip { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + default(), + filters, + Vec::new(), + default(), + Vec::new(), + ) + } else { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + default(), + Vec::new(), + Vec::new(), + default(), + filters, + ) + }, + ); + } + } + } + + draw::draw_layers(world, render_buffer, camera, layer_contents, &self.tiles); + + let Some(uniform_binding) = world.resource::().binding() else { + return Ok(()); + }; + + let inner = render_buffer.read_inner(); + let texture = inner.texture.as_ref().unwrap(); + let image = inner.image.as_ref().unwrap(); + let image_descriptor = image.texture_descriptor.clone(); + + let Ok(pixel_size) = image_descriptor.format.pixel_size() else { + return Ok(()); + }; + + world.resource::().write_texture( + texture.as_image_copy(), + image.data.as_ref().unwrap(), + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.width() * pixel_size as u32), + rows_per_image: None, + }, + image_descriptor.size, + ); + + let texture_view = texture.create_view(&TextureViewDescriptor { + label: Some("px_texture_view"), + format: Some(image_descriptor.format), + dimension: Some(TextureViewDimension::D2), + ..default() + }); + + let px_pipeline = world.resource::(); + let Some(pipeline) = world + .resource::() + .get_render_pipeline(px_pipeline.id) + else { + return Ok(()); + }; + + let post_process = target.post_process_write(); + + let bind_group = render_context.render_device().create_bind_group( + "px_bind_group", + &px_pipeline.layout, + &BindGroupEntries::sequential((&texture_view, uniform_binding.clone())), + ); + + let mut render_pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("px_pass"), + color_attachments: &[Some(RenderPassColorAttachment { + view: post_process.destination, + depth_slice: None, + resolve_target: None, + ops: default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + render_pass.set_render_pipeline(pipeline); + render_pass.set_bind_group(0, &bind_group, &[]); + render_pass.draw(0..6, 0..1); + + Ok(()) + } +} diff --git a/src/screen/pipeline.rs b/src/screen/pipeline.rs new file mode 100644 index 0000000..465aa7c --- /dev/null +++ b/src/screen/pipeline.rs @@ -0,0 +1,187 @@ +use std::sync::RwLock; + +use bevy_derive::{Deref, DerefMut}; +#[cfg(feature = "headed")] +use bevy_render::{ + render_resource::{ + BindGroupLayout, BindGroupLayoutEntries, CachedRenderPipelineId, ColorTargetState, + ColorWrites, DynamicUniformBuffer, Extent3d, FragmentState, PipelineCache, + RenderPipelineDescriptor, ShaderStages, ShaderType, Texture, TextureDescriptor, + TextureDimension, TextureFormat, TextureSampleType, TextureUsages, VertexState, + binding_types::{texture_2d, uniform_buffer}, + }, + renderer::{RenderDevice, RenderQueue}, +}; + +use crate::prelude::*; + +use super::{SCREEN_SHADER_HANDLE, Screen}; + +#[cfg(feature = "headed")] +#[derive(ShaderType)] +pub(crate) struct PxUniform { + pub(crate) palette: [Vec3; 256], + pub(crate) fit_factor: Vec2, +} + +#[cfg(feature = "headed")] +#[derive(Resource, Deref, DerefMut, Default)] +pub(crate) struct PxUniformBuffer(DynamicUniformBuffer); + +#[cfg(feature = "headed")] +pub(crate) fn prepare_uniform( + mut buffer: ResMut, + screen: Res, + device: Res, + queue: Res, +) { + let Some(mut writer) = buffer.get_writer(1, &device, &queue) else { + return; + }; + + let aspect_ratio_ratio = + screen.computed_size.x as f32 / screen.computed_size.y as f32 / screen.window_aspect_ratio; + writer.write(&PxUniform { + palette: screen.palette, + fit_factor: if aspect_ratio_ratio > 1. { + Vec2::new(1., 1. / aspect_ratio_ratio) + } else { + Vec2::new(aspect_ratio_ratio, 1.) + }, + }); +} + +#[cfg(feature = "headed")] +#[derive(Resource)] +pub(crate) struct PxPipeline { + pub(crate) layout: BindGroupLayout, + pub(crate) id: CachedRenderPipelineId, +} + +#[cfg(feature = "headed")] +impl FromWorld for PxPipeline { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + + let layout = render_device.create_bind_group_layout( + "px_bind_group_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Uint), + uniform_buffer::(false).visibility(ShaderStages::VERTEX_FRAGMENT), + ), + ), + ); + + Self { + id: world.resource_mut::().queue_render_pipeline( + RenderPipelineDescriptor { + label: Some("px_pipeline".into()), + layout: vec![layout.clone()], + vertex: VertexState { + shader: SCREEN_SHADER_HANDLE, + shader_defs: Vec::new(), + entry_point: Some("vertex".into()), + buffers: Vec::new(), + }, + fragment: Some(FragmentState { + shader: SCREEN_SHADER_HANDLE, + shader_defs: Vec::new(), + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format: TextureFormat::bevy_default(), + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + primitive: default(), + depth_stencil: None, + multisample: default(), + push_constant_ranges: Vec::new(), + zero_initialize_workgroup_memory: true, + }, + ), + layout, + } + } +} + +#[cfg(feature = "headed")] +#[derive(Resource)] +pub(crate) struct PxRenderBuffer { + inner: RwLock, +} + +#[cfg(feature = "headed")] +pub(crate) struct PxRenderBufferInner { + pub(crate) size: UVec2, + pub(crate) image: Option, + pub(crate) texture: Option, +} + +#[cfg(feature = "headed")] +impl Default for PxRenderBuffer { + fn default() -> Self { + Self { + inner: RwLock::new(PxRenderBufferInner { + size: UVec2::ZERO, + image: None, + texture: None, + }), + } + } +} + +#[cfg(feature = "headed")] +impl PxRenderBuffer { + pub(crate) fn ensure_size(&self, device: &RenderDevice, size: UVec2) { + let mut inner = self.inner.write().unwrap(); + if size == inner.size && inner.image.is_some() && inner.texture.is_some() { + return; + } + + inner.size = size; + + let descriptor = TextureDescriptor { + label: Some("px_present_texture"), + size: Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + dimension: TextureDimension::D2, + format: TextureFormat::R8Uint, + sample_count: 1, + mip_level_count: 1, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }; + + inner.texture = Some(device.create_texture(&descriptor)); + inner.image = Some(Image::new_fill( + descriptor.size, + descriptor.dimension, + &[0], + descriptor.format, + default(), + )); + } + + pub(crate) fn clear(&self) { + let mut inner = self.inner.write().unwrap(); + if let Some(image) = inner.image.as_mut() + && let Some(data) = image.data.as_mut() + { + data.fill(0); + } + } + + pub(crate) fn read_inner(&self) -> std::sync::RwLockReadGuard<'_, PxRenderBufferInner> { + self.inner.read().unwrap() + } + + pub(crate) fn write_inner(&self) -> std::sync::RwLockWriteGuard<'_, PxRenderBufferInner> { + self.inner.write().unwrap() + } +} diff --git a/src/ui.rs b/src/ui.rs index 20e316d..bbc4588 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,30 +7,21 @@ //! //! For more information, browse this module and see the `ui` example. -// TODO UI example -// TODO Feature parity between widgets -// TODO Split into modules +mod input; +mod layout; +mod widgets; -use std::time::Duration; - -use bevy_derive::{Deref, DerefMut}; -use bevy_ecs::system::SystemId; #[cfg(feature = "headed")] -use bevy_input::{ - ButtonState, InputSystems, - keyboard::{Key, KeyboardInput, NativeKey}, - mouse::MouseWheel, -}; +use bevy_input::InputSystems; #[cfg(feature = "headed")] use bevy_input_focus::InputFocus; -use bevy_math::{ivec2, uvec2}; -use crate::{ - blink::Blink, - position::{DefaultLayer, Spatial}, - prelude::*, - screen::Screen, - set::PxSet, +use crate::{prelude::*, set::PxSet}; + +pub use input::{PxCaret, PxKeyField, PxKeyFieldUpdate, PxTextField, PxTextFieldUpdate}; +pub use widgets::{ + PxGrid, PxGridRow, PxGridRows, PxMargin, PxMinSize, PxRow, PxRowSlot, PxScroll, PxStack, + PxUiRoot, }; pub(crate) fn plug(app: &mut App) { @@ -38,1242 +29,26 @@ pub(crate) fn plug(app: &mut App) { app.add_systems( PreUpdate, ( - (update_key_fields, update_text_fields).run_if(resource_exists::), - scroll, + (input::update_key_fields, input::update_text_fields) + .run_if(resource_exists::), + input::scroll, ) .after(InputSystems), ) .add_systems( PostUpdate, ( - update_key_field_focus, - update_text_field_focus.before(caret_blink), + input::update_key_field_focus, + input::update_text_field_focus.before(input::caret_blink), ) .run_if(resource_exists::), ); app.add_systems( PostUpdate, - (caret_blink, layout::.before(PxSet::Picking)).chain(), + ( + input::caret_blink, + layout::layout::.before(PxSet::Picking), + ) + .chain(), ); } - -// TODO Work on this naming - -/// Marks the root entity of a UI tree. -#[derive(Component)] -#[require(PxCanvas, DefaultLayer)] -pub struct PxUiRoot; - -/// Sets a minimum size for a UI node. -#[derive(Component, Deref, DerefMut, Default, Reflect)] -#[cfg_attr(feature = "headed", require(Visibility))] -pub struct PxMinSize(pub UVec2); - -/// Adds pixel margin around a UI node. -#[derive(Component, Deref, DerefMut, Reflect)] -#[cfg_attr(feature = "headed", require(Visibility))] -pub struct PxMargin(pub u32); - -impl Default for PxMargin { - fn default() -> Self { - Self(1) - } -} - -/// Per-child layout options for [`PxRow`]. -#[derive(Component, Default, Clone)] -pub struct PxRowSlot { - /// If true, the slot expands to fill available space. - pub stretch: bool, -} - -/// Row/column layout container for UI children. -#[derive(Component, Default, Clone, Reflect)] -#[cfg_attr(feature = "headed", require(Visibility))] -pub struct PxRow { - /// If true, lay out children vertically; otherwise horizontally. - pub vertical: bool, - /// Space between children in pixels. - pub space_between: u32, -} - -/// Row sizing config used by [`PxGrid`]. -#[derive(Default, Clone, Reflect)] -pub struct PxGridRow { - /// If true, the row expands to fill available space. - pub stretch: bool, -} - -/// Row/column definitions for [`PxGrid`]. -#[derive(Default, Clone, Reflect)] -pub struct PxGridRows { - /// Row definitions. - pub rows: Vec, - /// Space between rows/columns in pixels. - pub space_between: u32, -} - -/// Grid layout container for UI children. -#[derive(Component, Clone)] -#[cfg_attr(feature = "headed", require(Visibility))] -pub struct PxGrid { - /// Number of columns in the grid. - pub width: u32, - /// Row sizing rules. - pub rows: PxGridRows, - /// Column sizing rules. - pub columns: PxGridRows, -} - -impl Default for PxGrid { - fn default() -> Self { - Self { - width: 2, - rows: default(), - columns: default(), - } - } -} - -/// Stack layout container; children overlap in insertion order. -#[derive(Component, Clone, Reflect)] -#[cfg_attr(feature = "headed", require(Visibility))] -pub struct PxStack; - -/// Scroll container that masks and offsets child content. -#[derive(Component, Default, Clone, Copy, Reflect)] -#[require(PxInvertMask, PxRect)] -pub struct PxScroll { - /// If true, scroll horizontally; otherwise vertically. - pub horizontal: bool, - /// Current scroll offset in pixels. - pub scroll: u32, - /// Maximum scroll offset in pixels. - pub max_scroll: u32, -} - -// TODO Should be modular -#[cfg(feature = "headed")] -fn scroll(mut scrolls: Query<&mut PxScroll>, mut wheels: MessageReader) { - for wheel in wheels.read() { - for mut scroll in &mut scrolls { - scroll.scroll = scroll - .scroll - .saturating_add_signed(-wheel.y as i32) - .min(scroll.max_scroll); - } - } -} - -/// Field that captures a single key and renders its label. -#[derive(Component, Reflect)] -#[require(PxText)] -#[reflect(from_reflect = false)] -pub struct PxKeyField { - /// Placeholder/caret character when focused. - pub caret: char, - /// System that creates the text label - /// - /// Ideally, this would accept a Bevy `Key`, but there doesn't seem to be a way to convert a - /// winit `PhysicalKey` to a winit `Key`, so it wouldn't be possible to run this when building - /// the UI (ie in `PxUiBuilder::dyn_insert_into`) or update all the text if the keyboard layout - /// changes. - #[reflect(ignore)] - pub key_to_str: SystemId, String>, - /// Last displayed value when unfocused. - pub cached_text: String, -} - -#[cfg(feature = "headed")] -fn update_key_field_focus( - mut prev_focus: Local>, - mut fields: Query<(&PxKeyField, &mut PxText, &mut Visibility, Entity)>, - focus: Res, - mut cmd: Commands, -) { - let focus = focus.get(); - - if *prev_focus == focus { - return; - } - - if let Some(prev_focus) = *prev_focus - && let Ok((field, mut text, mut visibility, id)) = fields.get_mut(prev_focus) - { - text.value = field.cached_text.clone(); - *visibility = Visibility::Inherited; - cmd.entity(id).remove::(); - } - - if let Some(focus) = focus - && let Ok((field, mut text, _, id)) = fields.get_mut(focus) - { - text.value = field.caret.to_string(); - cmd.entity(id) - .try_insert(Blink::new(Duration::from_millis(500))); - } - - *prev_focus = focus; -} - -/// Emitted when a [`PxKeyField`] captures a key press. -#[derive(EntityEvent)] -pub struct PxKeyFieldUpdate { - /// Target field entity. - pub entity: Entity, - /// Captured key. - pub key: KeyCode, -} - -// TODO Should be modular -#[cfg(feature = "headed")] -fn update_key_fields( - mut fields: Query>, - mut focus: ResMut, - mut keys: MessageReader, - mut cmd: Commands, -) { - let mut keys = keys.read(); - let key = keys.find(|key| matches!(key.state, ButtonState::Pressed)); - keys.last(); - let Some(key) = key else { - return; - }; - - let Some(focus_id) = focus.get() else { - return; - }; - - let Ok(field_id) = fields.get_mut(focus_id) else { - return; - }; - - let key = key.key_code; - - cmd.queue(move |world: &mut World| { - let Some(field) = world.get::(field_id) else { - return; - }; - - let key = match world.run_system_with(field.key_to_str, key) { - Ok(key) => key, - Err(err) => { - error!("couldn't get text for pressed key for key field: {err}"); - return; - } - }; - - if let Some(mut field) = world.get_mut::(field_id) { - field.cached_text = key.clone(); - } - - if let Some(mut text) = world.get_mut::(field_id) { - text.value = key; - }; - }); - - cmd.trigger(PxKeyFieldUpdate { - entity: field_id, - key, - }); - - focus.clear(); -} - -/// Caret blink state for text fields. -#[derive(Reflect)] -pub struct PxCaret { - /// Whether the caret is currently visible. - pub state: bool, - /// Blink timer. - pub timer: Timer, -} - -impl Default for PxCaret { - fn default() -> Self { - Self { - state: true, - timer: Timer::new(Duration::from_millis(500), TimerMode::Repeating), - } - } -} - -/// Editable text field with an optional blinking caret. -#[derive(Component, Reflect)] -#[require(PxText)] -pub struct PxTextField { - /// Cached text without the caret character. - pub cached_text: String, - /// Character used as the caret. - pub caret_char: char, - /// Active caret state if focused. - pub caret: Option, -} - -#[cfg(feature = "headed")] -fn update_text_field_focus( - mut prev_focus: Local>, - mut fields: Query<(&mut PxTextField, &mut PxText)>, - focus: Res, -) { - let focus = focus.get(); - - if *prev_focus == focus { - return; - } - - if let Some(prev_focus) = *prev_focus - && let Ok((mut field, mut text)) = fields.get_mut(prev_focus) - { - text.value = field.cached_text.clone(); - field.caret = None; - } - - if let Some(focus) = focus - && let Ok((mut field, mut text)) = fields.get_mut(focus) - { - field.cached_text = text.value.clone(); - text.value += &field.caret_char.to_string(); - field.caret = Some(default()); - } - - *prev_focus = focus; -} - -fn caret_blink(mut fields: Query<(&mut PxTextField, &mut PxText)>, time: Res