diff --git a/src/lib.rs b/src/lib.rs index bfd86f766..e804248e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::fmt::{self, Debug, Formatter}; use std::hash::{self, BuildHasher, Hash}; use std::ops::{Add, AddAssign, Deref, DerefMut, Div, Neg}; -use std::panic::UnwindSafe; +use std::panic::{RefUnwindSafe, UnwindSafe}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use ahash::AHasher; @@ -19,6 +19,8 @@ use bytemuck::{Pod, Zeroable}; pub use cosmic_text; use figures::units::UPx; use figures::{Angle, Fraction, FromComponents, Point, Rect, Size, UPx2D}; +#[cfg(feature = "image")] +pub use image; use intentional::Assert; use sealed::ShapeSource as _; use wgpu::util::DeviceExt; @@ -1190,7 +1192,7 @@ pub struct Texture { size: Size, format: wgpu::TextureFormat, usage: wgpu::TextureUsages, - loadable: Mutex>, + loadable: Mutex>>, data: OnceLock, } @@ -1202,11 +1204,7 @@ struct TextureInstance { } impl UnwindSafe for TextureInstance {} - -#[derive(Debug)] -struct LoadableTexture { - data: Vec, -} +impl RefUnwindSafe for TextureInstance {} impl Texture { pub(crate) fn new_generic( @@ -1317,7 +1315,7 @@ impl Texture { size, format, usage, - loadable: Mutex::new(Some(LoadableTexture { data })), + loadable: Mutex::new(Some(data)), data: OnceLock::new(), } } @@ -1332,7 +1330,7 @@ impl Texture { Self::new_with_data( graphics, Size::upx(image.width(), image.height()), - wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, wgpu::TextureUsages::TEXTURE_BINDING, image.as_raw(), ) @@ -1345,7 +1343,7 @@ impl Texture { let image = image.into_rgba8(); Self::lazy_from_data( Size::upx(image.width(), image.height()), - wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, wgpu::TextureUsages::TEXTURE_BINDING, image.into_raw(), ) @@ -1418,30 +1416,24 @@ impl Texture { .map_or_else(PoisonError::into_inner, |g| g) .take() .assert("loadable present when OnceLock is uninitilized"); - loadable.load(self, graphics) + self.load(&loadable, graphics) }) } - fn wgpu(&self, graphics: &impl KludgineGraphics) -> &wgpu::Texture { - &self.instance(graphics).wgpu - } -} - -impl LoadableTexture { - fn load(self, texture: &Texture, graphics: &impl sealed::KludgineGraphics) -> TextureInstance { + fn load(&self, data: &[u8], graphics: &impl sealed::KludgineGraphics) -> TextureInstance { let wgpu = graphics.device().create_texture_with_data( graphics.queue(), &wgpu::TextureDescriptor { label: None, - size: texture.size.into(), + size: self.size.into(), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: texture.format, - usage: texture.usage, + format: self.format, + usage: self.usage, view_formats: &[], }, - &self.data, + data, ); let view = wgpu.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = Arc::new(pipeline::bind_group( @@ -1457,6 +1449,24 @@ impl LoadableTexture { bind_group, } } + + fn wgpu(&self, graphics: &impl KludgineGraphics) -> &wgpu::Texture { + &self.instance(graphics).wgpu + } +} + +/// Loads a texture's bytes into the executable. +/// +/// This macro takes a single parameter, which is forwarded along to +/// [`include_bytes!`]. The bytes that are loaded are then parsed using +/// [`image::load_from_memory`] and loaded using [`Texture::lazy_from_image`]. +#[cfg(feature = "image")] +#[macro_export] +macro_rules! include_texture { + ($path:expr) => { + $crate::image::load_from_memory(std::include_bytes!($path)) + .map($crate::Texture::lazy_from_image) + }; } /// The origin of a prepared graphic. diff --git a/src/sprite.rs b/src/sprite.rs index 38ba9398f..349b5903e 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -2,7 +2,7 @@ use std::collections::{hash_map, HashMap}; use std::fmt::{Debug, Display}; use std::hash::Hash; use std::iter::IntoIterator; -use std::ops::Deref; +use std::ops::{Deref, Div}; use std::sync::Arc; use std::time::Duration; @@ -11,8 +11,11 @@ use figures::{Point, Rect, Size}; use intentional::{Assert, Cast}; use justjson::Value; -use crate::sealed::TextureSource; -use crate::{SharedTexture, TextureRegion}; +use crate::pipeline::Vertex; +use crate::sealed::{self, TextureSource as _}; +use crate::{ + CollectedTexture, Graphics, PreparedGraphic, SharedTexture, TextureRegion, TextureSource, +}; /// Includes an [Aseprite](https://www.aseprite.org/) sprite sheet and Json /// export. For more information, see [`Sprite::load_aseprite_json`]. This macro @@ -21,12 +24,14 @@ use crate::{SharedTexture, TextureRegion}; #[macro_export] macro_rules! include_aseprite_sprite { ($path:expr) => {{ - $crate::include_texture!(concat!($path, ".png")).and_then(|texture| { - $crate::sprite::Sprite::load_aseprite_json( - include_str!(concat!($path, ".json")), - &texture, - ) - }) + $crate::include_texture!(concat!($path, ".png")) + .map_err($crate::sprite::SpriteParseError::from) + .and_then(|texture| { + $crate::sprite::Sprite::load_aseprite_json( + include_str!(concat!($path, ".json")), + &$crate::SharedTexture::from(texture), + ) + }) }}; } @@ -59,6 +64,7 @@ enum AnimationDirection { } /// An error occurred parsing a [`Sprite`]. +#[derive(Debug)] pub enum SpriteParseError { /// The `meta` field is missing or invalid. Meta, @@ -82,6 +88,9 @@ pub enum SpriteParseError { }, /// Invalid JSON. Json(justjson::Error), + /// An image parsing error. + #[cfg(feature = "image")] + Image(image::ImageError), } impl SpriteParseError { @@ -100,6 +109,13 @@ impl SpriteParseError { } } +#[cfg(feature = "image")] +impl From for SpriteParseError { + fn from(value: image::ImageError) -> Self { + Self::Image(value) + } +} + /// An error parsing a single frame in a sprite animation. #[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum FrameParseError { @@ -121,6 +137,7 @@ pub enum FrameParseError { } /// An error parsing a `frameTags` entry. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum FrameTagError { /// The direction field is missing. DirectionMissing, @@ -306,10 +323,10 @@ impl Sprite { ) .cast(); - let source = TextureRegion { + let source = SpriteSource::Region(TextureRegion { region, texture: texture.clone(), - }; + }); frames.insert( frame_number, @@ -429,7 +446,7 @@ impl Sprite { pub fn get_frame( &mut self, elapsed: Option, - ) -> Result { + ) -> Result { if let Some(elapsed) = elapsed { self.elapsed_since_frame_change += elapsed; @@ -455,7 +472,7 @@ impl Sprite { /// Returns an error the current animation tag does not match any defined /// animation. #[inline] - pub fn current_frame(&self) -> Result { + pub fn current_frame(&self) -> Result { self.with_current_frame(|frame| frame.source.clone()) } @@ -593,7 +610,7 @@ impl SpriteAnimation { #[derive(Debug, Clone)] pub struct SpriteFrame { /// The source to render. - pub source: TextureRegion, + pub source: SpriteSource, /// The length the frame should be displayed. `None` will act as an infinite /// duration. pub duration: Option, @@ -602,9 +619,9 @@ pub struct SpriteFrame { impl SpriteFrame { /// Creates a new frame with `source` and no duration. #[must_use] - pub const fn new(source: TextureRegion) -> Self { + pub fn new(source: impl Into) -> Self { Self { - source, + source: source.into(), duration: None, } } @@ -665,15 +682,15 @@ where /// /// Panics if a tile isn't found. #[must_use] - pub fn sprites>(&self, iterator: I) -> Vec { + pub fn sprites>(&self, iterator: I) -> Vec { iterator .into_iter() .map(|tile| { let location = self.data.sprites.get(&tile).unwrap(); - TextureRegion { + SpriteSource::Region(TextureRegion { region: *location, texture: self.texture.clone(), - } + }) }) .collect() } @@ -692,10 +709,10 @@ where let location = self.data.sprites.get(&tile).expect("missing sprite"); ( tile, - TextureRegion { + SpriteSource::Region(TextureRegion { region: *location, texture: self.texture.clone(), - }, + }), ) }) .collect::>(); @@ -739,10 +756,10 @@ where .map(|(tile, location)| { ( tile.clone(), - TextureRegion { + SpriteSource::Region(TextureRegion { region: *location, texture: self.texture.clone(), - }, + }), ) }) .collect(), @@ -754,11 +771,13 @@ impl SpriteCollection for SpriteSheet where T: Debug + Send + Sync + Eq + Hash, { - fn sprite(&self, tile: &T) -> Option { + fn sprite(&self, tile: &T) -> Option { let location = self.data.sprites.get(tile); - location.map(|location| TextureRegion { - region: *location, - texture: self.texture.clone(), + location.map(|location| { + SpriteSource::Region(TextureRegion { + region: *location, + texture: self.texture.clone(), + }) }) } } @@ -766,7 +785,7 @@ where /// A collection of [`SpriteSource`]s. #[derive(Debug, Clone)] pub struct SpriteMap { - sprites: HashMap, + sprites: HashMap, } impl Default for SpriteMap { @@ -783,7 +802,7 @@ where { /// Creates a new collection with `sprites`. #[must_use] - pub fn new(sprites: HashMap) -> Self { + pub fn new(sprites: HashMap) -> Self { Self { sprites } } @@ -823,22 +842,89 @@ where } impl Deref for SpriteMap { - type Target = HashMap; + type Target = HashMap; - fn deref(&self) -> &HashMap { + fn deref(&self) -> &HashMap { &self.sprites } } impl IntoIterator for SpriteMap { - type IntoIter = hash_map::IntoIter; - type Item = (T, TextureRegion); + type IntoIter = hash_map::IntoIter; + type Item = (T, SpriteSource); fn into_iter(self) -> Self::IntoIter { self.sprites.into_iter() } } +/// A region of a texture that is used as frame in a sprite animation. +#[derive(Debug, Clone)] +pub enum SpriteSource { + /// The sprite's source is a [`TextureRegion`]. + Region(TextureRegion), + /// The sprite's source is a [`CollectedTexture`]. + Collected(CollectedTexture), +} + +impl SpriteSource { + /// Returns a [`PreparedGraphic`] that renders this texture at `dest`. + pub fn prepare(&self, dest: Rect, graphics: &Graphics<'_>) -> PreparedGraphic + where + Unit: figures::Unit + Div, + Vertex: bytemuck::Pod, + { + match self { + SpriteSource::Region(texture) => texture.prepare(dest, graphics), + SpriteSource::Collected(texture) => texture.prepare(dest, graphics), + } + } +} + +impl TextureSource for SpriteSource {} + +impl sealed::TextureSource for SpriteSource { + fn id(&self) -> crate::sealed::TextureId { + match self { + SpriteSource::Region(texture) => texture.id(), + SpriteSource::Collected(texture) => texture.id(), + } + } + + fn is_mask(&self) -> bool { + match self { + SpriteSource::Region(texture) => texture.is_mask(), + SpriteSource::Collected(texture) => texture.is_mask(), + } + } + + fn bind_group(&self, graphics: &impl crate::sealed::KludgineGraphics) -> Arc { + match self { + SpriteSource::Region(texture) => texture.bind_group(graphics), + SpriteSource::Collected(texture) => texture.bind_group(graphics), + } + } + + fn default_rect(&self) -> Rect { + match self { + SpriteSource::Region(texture) => texture.default_rect(), + SpriteSource::Collected(texture) => texture.default_rect(), + } + } +} + +impl From for SpriteSource { + fn from(texture: TextureRegion) -> Self { + Self::Region(texture) + } +} + +impl From for SpriteSource { + fn from(texture: CollectedTexture) -> Self { + Self::Collected(texture) + } +} + /// A collection of sprites. pub trait SpriteCollection where @@ -846,7 +932,7 @@ where { /// Returns the sprite referred to by `tile`. #[must_use] - fn sprite(&self, tile: &T) -> Option; + fn sprite(&self, tile: &T) -> Option; /// Returns all of the requested `tiles`. /// @@ -854,7 +940,7 @@ where /// /// Panics if a tile is not found. #[must_use] - fn sprites(&self, tiles: &[T]) -> Vec { + fn sprites(&self, tiles: &[T]) -> Vec { tiles .iter() .map(|t| self.sprite(t).unwrap()) @@ -866,7 +952,7 @@ impl SpriteCollection for SpriteMap where T: Send + Sync + Eq + Hash, { - fn sprite(&self, tile: &T) -> Option { + fn sprite(&self, tile: &T) -> Option { self.sprites.get(tile).cloned() } } diff --git a/src/text.rs b/src/text.rs index ebd49d402..b03a2c416 100644 --- a/src/text.rs +++ b/src/text.rs @@ -166,7 +166,7 @@ impl TextSystem { ), color_text_atlas: TextureCollection::new_generic( Size::new(512, 512).cast(), - wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, graphics, ), swash_cache: cosmic_text::SwashCache::new(), diff --git a/src/tilemap.rs b/src/tilemap.rs index e39e8e460..739cc16a2 100644 --- a/src/tilemap.rs +++ b/src/tilemap.rs @@ -13,7 +13,8 @@ use crate::figures::{IntoSigned, Point, Rect, Size}; use crate::render::Renderer; use crate::shapes::Shape; use crate::sprite::Sprite; -use crate::{AnyTexture, Assert, Color}; +use crate::text::Text; +use crate::{AnyTexture, Assert, Color, DrawableExt}; pub const TILE_SIZE: Px = Px::new(32); @@ -39,11 +40,12 @@ pub fn draw( zoom: f32, elapsed: Duration, graphics: &mut Renderer<'_, '_>, -) { +) -> Option { let effective_zoom = graphics.scale().into_f32() * zoom; + let mut remaining_until_next_frame = None; - let offset = focus.world_coordinate(layers); - let offset = Point::new(offset.x * effective_zoom, offset.y * effective_zoom); + let world_coordinate = focus.world_coordinate(layers); + let offset = world_coordinate * effective_zoom; let visible_size = graphics.clip_rect().size.into_signed(); let visible_region = Rect::new(offset - visible_size / 2, visible_size); @@ -55,17 +57,21 @@ pub fn draw( top_left, bottom_right, tile_size, + origin: Point::from(visible_size) / 2 - world_coordinate, visible_rect: visible_region, zoom, elapsed, renderer: graphics, }; for index in 0.. { - let Some(layer) = layers.layer(index) else { + let Some(layer) = layers.layer_mut(index) else { break; }; - layer.render(&mut context); + remaining_until_next_frame = + minimum_duration(remaining_until_next_frame, layer.render(&mut context)); } + + remaining_until_next_frame } pub struct TileOffset { @@ -79,7 +85,7 @@ fn first_tile(pos: Point, tile_size: Px) -> TileOffset { let (offset, floored) = if pos < 0 { // Remainder is negative here. let offset = tile_size + remainder; - let floored = pos - offset; + let floored = pos - tile_size - offset; (-offset, floored) } else { @@ -115,14 +121,19 @@ fn last_tile(pos: Point, tile_size: Px) -> TileOffset { } pub trait Layers: Debug + UnwindSafe + Send + 'static { - fn layer(&mut self, index: usize) -> Option<&mut dyn Layer>; + fn layer(&self, index: usize) -> Option<&dyn Layer>; + fn layer_mut(&mut self, index: usize) -> Option<&mut dyn Layer>; } impl Layers for T where T: Layer, { - fn layer(&mut self, index: usize) -> Option<&mut dyn Layer> { + fn layer(&self, index: usize) -> Option<&dyn Layer> { + (index == 0).then_some(self) + } + + fn layer_mut(&mut self, index: usize) -> Option<&mut dyn Layer> { (index == 0).then_some(self) } } @@ -132,7 +143,14 @@ macro_rules! impl_layers_for_tuples { impl<$($type),+> Layers for ($($type),+) where $( $type: Debug + UnwindSafe + Send + Layer + 'static ),+ { - fn layer(&mut self, index: usize) -> Option<&mut dyn Layer> { + fn layer(&self, index: usize) -> Option<&dyn Layer> { + match index { + $($index => Some(&self.$index),)+ + _ => None, + } + } + + fn layer_mut(&mut self, index: usize) -> Option<&mut dyn Layer> { match index { $($index => Some(&mut self.$index),)+ _ => None, @@ -148,6 +166,7 @@ pub struct LayerContext<'render, 'ctx, 'pass> { top_left: TileOffset, bottom_right: TileOffset, tile_size: Px, + origin: Point, visible_rect: Rect, zoom: f32, elapsed: Duration, @@ -170,6 +189,11 @@ impl LayerContext<'_, '_, '_> { self.tile_size } + #[must_use] + pub const fn origin(&self) -> Point { + self.origin + } + #[must_use] pub const fn visible_rect(&self) -> Rect { self.visible_rect @@ -201,7 +225,7 @@ impl<'ctx, 'pass> DerefMut for LayerContext<'_, 'ctx, 'pass> { } pub trait Layer: Debug + UnwindSafe + Send + 'static { - fn render(&mut self, context: &mut LayerContext<'_, '_, '_>); + fn render(&mut self, context: &mut LayerContext<'_, '_, '_>) -> Option; fn find_object(&self, _object: ObjectId) -> Option> { None @@ -238,30 +262,23 @@ fn isize_to_i32(value: isize) -> i32 { } impl Layer for Tiles { - fn render(&mut self, context: &mut LayerContext<'_, '_, '_>) { + fn render(&mut self, context: &mut LayerContext<'_, '_, '_>) -> Option { let (Ok(right), Ok(bottom)) = ( usize::try_from(context.bottom_right().index.x), usize::try_from(context.bottom_right().index.y), ) else { - return; + return None; }; + let mut remaining_until_next_frame = None; let (x, left) = if let Ok(left) = usize::try_from(context.top_left().index.x) { - ( - context.top_left().tile_offset.x - + context.tile_size() * isize_to_i32(context.top_left().index.x), - left, - ) + (context.top_left().tile_offset.x, left) } else { let tile_offset = context.tile_size() * isize_to_i32(-context.top_left().index.x); (context.top_left().tile_offset.x + tile_offset, 0) }; let (mut y, top) = if let Ok(top) = usize::try_from(context.top_left().index.y) { - ( - context.top_left().tile_offset.y - + context.tile_size() * isize_to_i32(context.top_left().index.y), - top, - ) + (context.top_left().tile_offset.y, top) } else { let tile_offset = context.tile_size() * i32::try_from(-context.top_left().index.y).expect("offset out of range"); @@ -278,7 +295,8 @@ impl Layer for Tiles { let tile_rect = Rect::new(Point::new(x, y), Size::squared(context.tile_size())); match &mut self.tiles[y_index * self.width + x_index] { TileKind::Texture(texture) => { - // TODO aspect-fit rather than fill. + // TODO support other scaling options like + // aspect-fit rather than fill. context.draw_texture(texture, tile_rect); } TileKind::Color(color) => { @@ -286,17 +304,38 @@ impl Layer for Tiles { } TileKind::Sprite(sprite) => { if let Ok(frame) = sprite.get_frame(Some(context.elapsed())) { + remaining_until_next_frame = minimum_duration( + remaining_until_next_frame, + sprite.remaining_frame_duration().ok().flatten(), + ); context.draw_texture(&frame, tile_rect); } else { // TODO show a broken image? } } }; + context.draw_text( + Text::new(&format!("{x_index},{y_index}"), Color::WHITE) + .translate_by(tile_rect.origin), + ); x += context.tile_size(); } y += context.tile_size(); } } + + remaining_until_next_frame + } +} + +fn minimum_duration( + min_duration: Option, + duration: Option, +) -> Option { + match (min_duration, duration) { + (Some(min_remaining), Some(remaining)) if remaining < min_remaining => Some(remaining), + (None, remaining) => remaining, + (min_remaining, _) => min_remaining, } } @@ -390,15 +429,15 @@ impl Layer for ObjectLayer where O: Object, { - fn render(&mut self, context: &mut LayerContext<'_, '_, '_>) { + fn render(&mut self, context: &mut LayerContext<'_, '_, '_>) -> Option { + let mut min_duration = None; for obj in &self.objects { - let center = Point::new( - obj.position().x * context.zoom(), - obj.position().y * context.zoom(), - ) - context.visible_rect().origin; + let center = context.origin + obj.position(); - obj.render(center, context.zoom(), context); + min_duration = + minimum_duration(min_duration, obj.render(center, context.zoom(), context)); } + min_duration } fn find_object(&self, object: ObjectId) -> Option> { @@ -425,7 +464,7 @@ impl TileMapFocus { // Get the world coordinate of the selected focus. // Zoom in / out etc. will not change the world coordinate. // TB: 2023-11-14 - pub fn world_coordinate(self, layers: &mut impl Layers) -> Point { + pub fn world_coordinate(self, layers: &impl Layers) -> Point { match self { TileMapFocus::Point(focus) => focus, TileMapFocus::Object { layer, id } => layers @@ -445,5 +484,10 @@ impl Default for TileMapFocus { pub trait Object: Debug + UnwindSafe + Send + 'static { fn position(&self) -> Point; - fn render(&self, center: Point, zoom: f32, context: &mut Renderer<'_, '_>); + fn render( + &self, + center: Point, + zoom: f32, + context: &mut Renderer<'_, '_>, + ) -> Option; }