diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index cafd305..178d26d 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -132,6 +132,66 @@ impl PyBlendMode { const OP_MAX: u8 = 4; } +/// Configures how an image is sampled when drawn. +/// +/// Controls texture filtering and edge wrapping behavior. +/// +/// - `filter` — `Sampler.LINEAR` (smooth) or `Sampler.NEAREST` (pixelated). +/// - `wrap` — `Sampler.CLAMP` (default), `Sampler.REPEAT`, or `Sampler.MIRROR`. +/// Use `wrap_x`/`wrap_y` to set each axis independently. +#[pyclass] +#[derive(Clone)] +pub struct Sampler { + pub(crate) filter: u8, + pub(crate) wrap_x: u8, + pub(crate) wrap_y: u8, +} + +#[pymethods] +impl Sampler { + #[new] + #[pyo3(signature = (*, filter=0, wrap=0, wrap_x=None, wrap_y=None))] + fn new(filter: u8, wrap: u8, wrap_x: Option, wrap_y: Option) -> Self { + Self { + filter, + wrap_x: wrap_x.unwrap_or(wrap), + wrap_y: wrap_y.unwrap_or(wrap), + } + } + + fn __repr__(&self) -> String { + let filter_name = match self.filter { + 0 => "LINEAR", + 1 => "NEAREST", + _ => "?", + }; + let wrap_name = |v: u8| match v { + 0 => "CLAMP", + 1 => "REPEAT", + 2 => "MIRROR", + _ => "?", + }; + format!( + "Sampler(filter={}, wrap_x={}, wrap_y={})", + filter_name, + wrap_name(self.wrap_x), + wrap_name(self.wrap_y) + ) + } + + #[classattr] + const LINEAR: u8 = 0; + #[classattr] + const NEAREST: u8 = 1; + + #[classattr] + const CLAMP: u8 = 0; + #[classattr] + const REPEAT: u8 = 1; + #[classattr] + const MIRROR: u8 = 2; +} + pub use crate::surface::Surface; #[pyclass] @@ -168,10 +228,46 @@ pub struct Image { pub(crate) entity: Entity, } +pub(crate) struct ImageRef { + pub entity: Entity, +} + +impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { + type Error = PyErr; + + fn extract(ob: pyo3::Borrowed<'a, 'py, PyAny>) -> PyResult { + if let Ok(img) = ob.extract::>() { + return Ok(ImageRef { entity: img.entity }); + } + #[cfg(feature = "video")] + if let Ok(vid) = ob.extract::>() { + return Ok(ImageRef { + entity: vid.image_entity()?, + }); + } + #[cfg(feature = "webcam")] + if let Ok(cam) = ob.extract::>() { + return Ok(ImageRef { + entity: cam.image_entity()?, + }); + } + Err(pyo3::exceptions::PyTypeError::new_err( + "expected an Image, Video, or Webcam", + )) + } +} + +#[pymethods] impl Image { - #[expect(dead_code)] // it's only used by webcam atm - pub(crate) fn from_entity(entity: Entity) -> Self { - Self { entity } + /// Applies a `Sampler` to this image, controlling filtering and wrapping. + /// + /// ```python + /// s = Sampler(filter=Sampler.NEAREST, wrap=Sampler.REPEAT) + /// img.sampler(s) + /// ``` + fn sampler(&self, sampler: &Sampler) -> PyResult<()> { + image_set_sampler(self.entity, sampler.filter, sampler.wrap_x, sampler.wrap_y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } } @@ -785,13 +881,89 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn image(&self, file: &str) -> PyResult { + /// Loads an image from a file and returns an Image object. + /// + /// The path is relative to the sketch's assets directory. + pub fn load_image(&self, file: &str) -> PyResult { match image_load(file) { Ok(image) => Ok(Image { entity: image }), Err(e) => Err(PyRuntimeError::new_err(format!("{e}"))), } } + /// Draws an image to the screen. + /// + /// Optional `d_width` and `d_height` resize the image on screen. If omitted, + /// the image's original dimensions are used. + /// + /// Optional `sx`, `sy`, `s_width`, and `s_height` define a sub-region + /// of the source image to draw, specified in pixels. + /// + /// Affected by `image_mode()`, `tint()`, and the current transform. + #[pyo3(signature = (source, dx, dy, d_width=None, d_height=None, sx=None, sy=None, s_width=None, s_height=None))] + pub fn image( + &self, + source: ImageRef, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + ) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Image { + entity: source.entity, + dx, + dy, + d_width, + d_height, + sx, + sy, + s_width, + s_height, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Sets a tint color applied when drawing images. + /// + /// Accepts the same color arguments as `fill()`. The tint is multiplied + /// with the image's pixel colors. Use `no_tint()` to remove. + #[pyo3(signature = (*args))] + pub fn tint(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; + graphics_record_command(self.entity, DrawCommand::Tint(color)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Removes the current tint color so images draw without color modification. + pub fn no_tint(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::NoTint) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Changes how image position arguments are interpreted. + /// + /// - `CORNER` (default) — `dx`, `dy` is the top-left corner. + /// - `CORNERS` — `dx`, `dy` and `d_width`, `d_height` are opposite corners. + /// - `CENTER` — `dx`, `dy` is the center of the image. + pub fn image_mode(&self, mode: u8) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::ImageMode(processing::prelude::ShapeMode::from(mode)), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn create_image(&self, width: u32, height: u32) -> PyResult { let size = Extent3d { width, @@ -831,6 +1003,21 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn rotate_x(&self, angle: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::RotateX { angle }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn rotate_y(&self, angle: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::RotateY { angle }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn rotate_z(&self, angle: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::RotateZ { angle }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn draw_box(&self, width: f32, height: f32, depth: f32) -> PyResult<()> { graphics_record_command( self.entity, diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 7902979..f2d7beb 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -26,7 +26,8 @@ mod time; mod webcam; use graphics::{ - Geometry, Graphics, Image, Light, PyBlendMode, Topology, get_graphics, get_graphics_mut, + Geometry, Graphics, Image, Light, PyBlendMode, Sampler, Topology, get_graphics, + get_graphics_mut, }; use material::Material; @@ -340,6 +341,8 @@ mod mewnala { #[pymodule_export] use super::PyBlendMode; #[pymodule_export] + use super::Sampler; + #[pymodule_export] use super::Shader; #[pymodule_export] use super::Topology; @@ -623,6 +626,10 @@ mod mewnala { mod math { use super::*; + #[pymodule_export] + use crate::math::PyAffine2; + #[pymodule_export] + use crate::math::PyMat2; #[pymodule_export] use crate::math::PyQuat; #[pymodule_export] @@ -1236,6 +1243,24 @@ mod mewnala { graphics!(module).rotate(angle) } + #[pyfunction] + #[pyo3(pass_module)] + fn rotate_x(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_x(angle) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn rotate_y(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_y(angle) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn rotate_z(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_z(angle) + } + #[pyfunction(name = "box")] #[pyo3(pass_module)] fn draw_box(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult<()> { @@ -1354,12 +1379,61 @@ mod mewnala { graphics!(module).rect(x, y, w, h, tl, tr, br, bl) } + /// Loads an image from a file and returns an Image object. #[pyfunction] #[pyo3(pass_module, signature = (image_file))] - fn image(module: &Bound<'_, PyModule>, image_file: &str) -> PyResult { + fn load_image(module: &Bound<'_, PyModule>, image_file: &str) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.image(image_file) + graphics.load_image(image_file) + } + + /// Draws an image to the screen. + /// + /// Optional `d_width`/`d_height` resize on screen; defaults to the image's + /// original dimensions. Optional `sx`/`sy`/`s_width`/`s_height` select a + /// sub-region of the source image in pixels. + #[pyfunction] + #[pyo3(pass_module, signature = (source, dx, dy, d_width=None, d_height=None, sx=None, sy=None, s_width=None, s_height=None))] + #[allow(clippy::too_many_arguments)] + fn image( + module: &Bound<'_, PyModule>, + source: graphics::ImageRef, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + ) -> PyResult<()> { + graphics!(module).image(source, dx, dy, d_width, d_height, sx, sy, s_width, s_height) + } + + /// Sets a tint color applied when drawing images. + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn tint(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).tint(args) + } + + /// Removes the current tint so images draw without color modification. + #[pyfunction] + #[pyo3(pass_module)] + fn no_tint(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).no_tint() + } + + /// Changes how image position arguments are interpreted. + /// + /// - `CORNER` (default) — `dx`, `dy` is the top-left corner. + /// - `CENTER` — `dx`, `dy` is the center. + /// - `CORNERS` — `dx`, `dy` and `d_width`, `d_height` are opposite corners. + #[pyfunction] + #[pyo3(pass_module)] + fn image_mode(module: &Bound<'_, PyModule>, mode: u8) -> PyResult<()> { + graphics!(module).image_mode(mode) } #[pyfunction] diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 757d2d9..a811851 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -3,6 +3,7 @@ use processing::prelude::*; use pyo3::types::PyDict; use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use crate::graphics::ImageRef; use crate::math::{PyVec2, PyVec3, PyVec4}; use crate::shader::Shader; @@ -12,6 +13,9 @@ pub struct Material { } fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(img_ref) = value.extract::() { + return Ok(material::MaterialValue::Texture(img_ref.entity)); + } if let Ok(v) = value.extract::() { return Ok(material::MaterialValue::Float(v)); } diff --git a/crates/processing_pyo3/src/math.rs b/crates/processing_pyo3/src/math.rs index 2abde01..85c9ad4 100644 --- a/crates/processing_pyo3/src/math.rs +++ b/crates/processing_pyo3/src/math.rs @@ -1,6 +1,6 @@ use std::hash::{Hash, Hasher}; -use bevy::math::{EulerRot, Quat, Vec2, Vec3, Vec4}; +use bevy::math::{Affine2, EulerRot, Mat2, Quat, Vec2, Vec3, Vec4}; use pyo3::{ exceptions::{PyAttributeError, PyTypeError}, prelude::*, @@ -771,6 +771,174 @@ impl PyVecIter { } } +#[pyclass(name = "Mat2", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyMat2(pub(crate) Mat2); + +impl From for PyMat2 { + fn from(m: Mat2) -> Self { + Self(m) + } +} + +#[pymethods] +impl PyMat2 { + #[new] + #[pyo3(signature = (*args))] + pub fn py_new(args: &Bound<'_, PyTuple>) -> PyResult { + match args.len() { + 0 => Ok(Self(Mat2::IDENTITY)), + 4 => { + let m00: f32 = args.get_item(0)?.extract()?; + let m01: f32 = args.get_item(1)?.extract()?; + let m10: f32 = args.get_item(2)?.extract()?; + let m11: f32 = args.get_item(3)?.extract()?; + Ok(Self(Mat2::from_cols( + Vec2::new(m00, m01), + Vec2::new(m10, m11), + ))) + } + _ => Err(PyTypeError::new_err("Mat2 takes 0 or 4 arguments")), + } + } + + #[staticmethod] + fn from_scale(scale: PyVec2) -> Self { + Self(Mat2::from_diagonal(scale.0)) + } + + #[staticmethod] + fn from_angle(angle: f32) -> Self { + Self(Mat2::from_angle(angle)) + } + + fn __mul__(&self, rhs: &Bound<'_, PyAny>) -> PyResult> { + let py = rhs.py(); + if let Ok(other) = rhs.extract::>() { + return Ok(PyMat2(self.0 * other.0) + .into_pyobject(py)? + .into_any() + .unbind()); + } + if let Ok(v) = rhs.extract::>() { + return Ok(PyVec2(self.0 * v.0).into_pyobject(py)?.into_any().unbind()); + } + Err(PyTypeError::new_err( + "unsupported operand type(s) for *: 'Mat2'", + )) + } + + fn determinant(&self) -> f32 { + self.0.determinant() + } + + fn inverse(&self) -> Self { + Self(self.0.inverse()) + } + + fn transpose(&self) -> Self { + Self(self.0.transpose()) + } + + fn __repr__(&self) -> String { + let c = self.0.to_cols_array(); + format!("Mat2({}, {}, {}, {})", c[0], c[1], c[2], c[3]) + } + + fn __str__(&self) -> String { + self.__repr__() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for &c in self.0.to_cols_array().iter() { + hash_f32(c, &mut hasher); + } + std::hash::Hasher::finish(&hasher) + } +} + +#[pyclass(name = "Affine2", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyAffine2(pub(crate) Affine2); + +impl From for PyAffine2 { + fn from(a: Affine2) -> Self { + Self(a) + } +} + +#[pymethods] +impl PyAffine2 { + #[new] + #[pyo3(signature = (matrix=None, translation=None))] + pub fn py_new(matrix: Option>, translation: Option>) -> Self { + Self(Affine2 { + matrix2: matrix.map(|m| m.0).unwrap_or(Mat2::IDENTITY), + translation: translation.map(|t| t.0).unwrap_or(Vec2::ZERO), + }) + } + + #[staticmethod] + fn from_scale(scale: PyVec2) -> Self { + Self(Affine2::from_scale(scale.0)) + } + + #[staticmethod] + fn from_scale_angle_translation(scale: PyVec2, angle: f32, translation: PyVec2) -> Self { + Self(Affine2::from_scale_angle_translation( + scale.0, + angle, + translation.0, + )) + } + + #[getter] + fn matrix(&self) -> PyMat2 { + PyMat2(self.0.matrix2) + } + + #[getter] + fn translation(&self) -> PyVec2 { + PyVec2(self.0.translation) + } + + fn inverse(&self) -> Self { + Self(self.0.inverse()) + } + + fn __repr__(&self) -> String { + let m = self.0.matrix2.to_cols_array(); + let t = self.0.translation; + format!( + "Affine2(Mat2({}, {}, {}, {}), Vec2({}, {}))", + m[0], m[1], m[2], m[3], t.x, t.y + ) + } + + fn __str__(&self) -> String { + self.__repr__() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for &c in self.0.matrix2.to_cols_array().iter() { + hash_f32(c, &mut hasher); + } + hash_f32(self.0.translation.x, &mut hasher); + hash_f32(self.0.translation.y, &mut hasher); + std::hash::Hasher::finish(&hasher) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index 9a4d558..af30f5e 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -9,6 +9,7 @@ use bevy::{ io::{AssetSourceId, embedded::GetAssetServer}, }, ecs::system::RunSystemOnce, + image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}, prelude::*, render::{ RenderApp, @@ -466,6 +467,50 @@ pub fn create_readback_buffer( })) } +pub fn set_sampler( + In((entity, filter, wrap_x, wrap_y)): In<(Entity, u8, u8, u8)>, + p_images: Query<&Image>, + mut images: ResMut>, +) -> Result<()> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + + let mut image = images + .get_mut(&p_image.handle) + .ok_or(ProcessingError::ImageNotFound)?; + + let filter_mode = match filter { + 0 => ImageFilterMode::Linear, + 1 => ImageFilterMode::Nearest, + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "unknown filter mode: {filter}" + ))); + } + }; + + let addr_mode = |v: u8| match v { + 0 => Ok(ImageAddressMode::ClampToEdge), + 1 => Ok(ImageAddressMode::Repeat), + 2 => Ok(ImageAddressMode::MirrorRepeat), + _ => Err(ProcessingError::InvalidArgument(format!( + "unknown wrap mode: {v}" + ))), + }; + + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + mag_filter: filter_mode, + min_filter: filter_mode, + mipmap_filter: filter_mode, + address_mode_u: addr_mode(wrap_x)?, + address_mode_v: addr_mode(wrap_y)?, + ..Default::default() + }); + + Ok(()) +} + pub fn gpu_image(app: &mut App, entity: Entity) -> Result<&GpuImage> { let handle = app .world() diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 8d70629..3a0e0ee 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -933,6 +933,15 @@ pub fn image_update_region( }) } +/// Set the sampler for an image (filter mode + wrap modes). +pub fn image_set_sampler(entity: Entity, filter: u8, wrap_x: u8, wrap_y: u8) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(image::set_sampler, (entity, filter, wrap_x, wrap_y)) + .unwrap() + }) +} + /// Destroy an existing image and free its resources. pub fn image_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index be48836..d49427e 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -71,9 +71,21 @@ pub fn create_pbr( pub fn set_property( In((entity, name, value)): In<(Entity, String, MaterialValue)>, material_handles: Query<&UntypedMaterial>, + images: Query<&crate::image::Image>, mut extended_materials: ResMut>>, mut custom_materials: ResMut>, ) -> error::Result<()> { + let texture_handle = match &value { + MaterialValue::Texture(img_entity) => Some( + images + .get(*img_entity) + .map_err(|_| ProcessingError::ImageNotFound)? + .handle + .clone(), + ), + _ => None, + }; + let untyped = material_handles .get(entity) .map_err(|_| ProcessingError::MaterialNotFound)?; @@ -86,7 +98,7 @@ pub fn set_property( let mut extended = extended_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; - return pbr::set_property(&mut extended.base, &name, &value); + return pbr::set_property(&mut extended.base, &name, &value, texture_handle); } if let Ok(handle) = untyped.0.clone().try_typed::() { diff --git a/crates/processing_render/src/material/pbr.rs b/crates/processing_render/src/material/pbr.rs index df3df23..55613ac 100644 --- a/crates/processing_render/src/material/pbr.rs +++ b/crates/processing_render/src/material/pbr.rs @@ -8,6 +8,7 @@ pub fn set_property( material: &mut StandardMaterial, name: &str, value: &MaterialValue, + texture_handle: Option>, ) -> Result<()> { match name { "base_color" | "color" => { @@ -87,6 +88,14 @@ pub fn set_property( } }; } + "base_color_texture" | "texture" => { + let Some(handle) = texture_handle else { + return Err(ProcessingError::InvalidArgument(format!( + "'{name}' expects Texture, got {value:?}" + ))); + }; + material.base_color_texture = Some(handle); + } _ => { return Err(ProcessingError::UnknownMaterialProperty(name.to_string())); } diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 94c79fc..911f71e 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -307,6 +307,20 @@ pub enum DrawCommand { Metallic(f32), Emissive(Color), Unlit, + Tint(Color), + NoTint, + ImageMode(ShapeMode), + Image { + entity: Entity, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + }, RectMode(ShapeMode), EllipseMode(ShapeMode), Rect { @@ -416,6 +430,15 @@ pub enum DrawCommand { Rotate { angle: f32, }, + RotateX { + angle: f32, + }, + RotateY { + angle: f32, + }, + RotateZ { + angle: f32, + }, Scale(Vec2), ShearX { angle: f32, diff --git a/crates/processing_render/src/render/material.rs b/crates/processing_render/src/render/material.rs index 3775f92..1583739 100644 --- a/crates/processing_render/src/render/material.rs +++ b/crates/processing_render/src/render/material.rs @@ -1,3 +1,4 @@ +use bevy::math::Affine2; use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; use bevy::render::render_resource::BlendState; @@ -11,11 +12,12 @@ pub struct UntypedMaterial(pub UntypedHandle); pub type ProcessingExtendedMaterial = ExtendedMaterial; -#[derive(Clone, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, PartialEq, Debug)] pub enum MaterialKey { Color { transparent: bool, background_image: Option>, + uv_transform: Affine2, blend_state: Option, }, Pbr { @@ -23,6 +25,8 @@ pub enum MaterialKey { roughness: u8, metallic: u8, emissive: [u8; 4], + base_color_texture: Option>, + uv_transform: Affine2, blend_state: Option, }, Custom { @@ -31,7 +35,68 @@ pub enum MaterialKey { }, } +pub struct PbrFields { + pub albedo: [u8; 4], + pub roughness: u8, + pub metallic: u8, + pub emissive: [u8; 4], + pub base_color_texture: Option>, + pub uv_transform: Affine2, + pub blend_state: Option, +} + +impl Default for PbrFields { + fn default() -> Self { + Self { + albedo: [255, 255, 255, 255], + roughness: 128, + metallic: 0, + emissive: [0, 0, 0, 0], + base_color_texture: None, + uv_transform: Affine2::IDENTITY, + blend_state: None, + } + } +} + +impl From for MaterialKey { + fn from(f: PbrFields) -> Self { + MaterialKey::Pbr { + albedo: f.albedo, + roughness: f.roughness, + metallic: f.metallic, + emissive: f.emissive, + base_color_texture: f.base_color_texture, + uv_transform: f.uv_transform, + blend_state: f.blend_state, + } + } +} + impl MaterialKey { + pub fn as_pbr(&self) -> PbrFields { + match self { + MaterialKey::Pbr { + albedo, + roughness, + metallic, + emissive, + base_color_texture, + uv_transform, + blend_state, + } => PbrFields { + albedo: *albedo, + roughness: *roughness, + metallic: *metallic, + emissive: *emissive, + base_color_texture: base_color_texture.clone(), + uv_transform: *uv_transform, + blend_state: *blend_state, + }, + _ => PbrFields::default(), + } + } + pub fn blend_state(&self) -> Option { match self { MaterialKey::Color { blend_state, .. } => *blend_state, @@ -45,12 +110,14 @@ impl MaterialKey { MaterialKey::Color { transparent, background_image, + uv_transform, blend_state, } => StandardMaterial { base_color: Color::WHITE, unlit: true, cull_mode: None, base_color_texture: background_image.clone(), + uv_transform: *uv_transform, alpha_mode: if blend_state.is_some() || *transparent { AlphaMode::Blend } else { @@ -63,6 +130,8 @@ impl MaterialKey { roughness, metallic, emissive, + base_color_texture, + uv_transform, .. } => { let base_color = Color::srgba( @@ -83,6 +152,8 @@ impl MaterialKey { emissive[2] as f32 / 255.0, emissive[3] as f32 / 255.0, ), + base_color_texture: base_color_texture.clone(), + uv_transform: *uv_transform, ..default() } } diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 4f73922..85b54d3 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -7,7 +7,7 @@ pub mod transform; use bevy::{ camera::visibility::RenderLayers, ecs::system::SystemParam, - math::{Affine3A, Mat4, Vec4}, + math::{Affine2, Affine3A, Mat4, Vec4}, prelude::*, render::render_resource::BlendState, }; @@ -80,6 +80,8 @@ pub struct RenderState { pub material_key: MaterialKey, pub blend_state: Option, pub transform: TransformStack, + pub tint_color: Option, + pub image_mode: ShapeMode, pub rect_mode: ShapeMode, pub ellipse_mode: ShapeMode, pub shape_builder: Option, @@ -95,9 +97,12 @@ impl RenderState { material_key: MaterialKey::Color { transparent: false, background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: None, }, blend_state: None, + tint_color: None, + image_mode: ShapeMode::Corner, transform: TransformStack::new(), rect_mode: ShapeMode::Corner, ellipse_mode: ShapeMode::Center, @@ -113,9 +118,12 @@ impl RenderState { self.material_key = MaterialKey::Color { transparent: false, background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: None, }; self.blend_state = None; + self.tint_color = None; + self.image_mode = ShapeMode::Corner; self.transform = TransformStack::new(); self.rect_mode = ShapeMode::Corner; self.ellipse_mode = ShapeMode::Center; @@ -192,79 +200,28 @@ pub fn flush_draw_commands( state.stroke_config.line_join = join; } DrawCommand::Roughness(r) => { - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - metallic, - emissive, - .. - } => MaterialKey::Pbr { - albedo, - roughness: (r * 255.0) as u8, - metallic, - emissive, - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: (r * 255.0) as u8, - metallic: 0, - emissive: [0, 0, 0, 0], - blend_state: None, - }, - }; + let mut pbr = state.material_key.as_pbr(); + pbr.roughness = (r * 255.0) as u8; + pbr.blend_state = None; + state.material_key = pbr.into(); } DrawCommand::Metallic(m) => { - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - roughness, - emissive, - .. - } => MaterialKey::Pbr { - albedo, - roughness, - metallic: (m * 255.0) as u8, - emissive, - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: 128, - metallic: (m * 255.0) as u8, - emissive: [0, 0, 0, 0], - blend_state: None, - }, - }; + let mut pbr = state.material_key.as_pbr(); + pbr.metallic = (m * 255.0) as u8; + pbr.blend_state = None; + state.material_key = pbr.into(); } DrawCommand::Emissive(color) => { - let [r, g, b, a] = color.to_srgba().to_u8_array(); - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - roughness, - metallic, - .. - } => MaterialKey::Pbr { - albedo, - roughness, - metallic, - emissive: [r, g, b, a], - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: 128, - metallic: 0, - emissive: [r, g, b, a], - blend_state: None, - }, - }; + let mut pbr = state.material_key.as_pbr(); + pbr.emissive = color.to_srgba().to_u8_array(); + pbr.blend_state = None; + state.material_key = pbr.into(); } DrawCommand::Unlit => { state.material_key = MaterialKey::Color { transparent: state.fill_is_transparent(), background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: None, }; } @@ -788,6 +745,82 @@ pub fn flush_draw_commands( } } } + DrawCommand::Tint(color) => { + state.tint_color = Some(color); + } + DrawCommand::NoTint => { + state.tint_color = None; + } + DrawCommand::ImageMode(mode) => { + state.image_mode = mode; + } + DrawCommand::Image { + entity, + dx, + dy, + d_width, + d_height, + sx, + sy, + s_width, + s_height, + } => { + let Some(p_image) = p_images.get(entity).ok() else { + warn!("Could not find PImage for entity {:?}", entity); + continue; + }; + + let img_w = p_image.size.width as f32; + let img_h = p_image.size.height as f32; + let dw = d_width.unwrap_or(img_w); + let dh = d_height.unwrap_or(img_h); + let (x, y, w, h) = apply_shape_mode(state.image_mode, dx, dy, dw, dh); + + let uv_xform = match (sx, sy, s_width, s_height) { + (Some(sx), Some(sy), Some(sw), Some(sh)) => { + Affine2::from_scale_angle_translation( + Vec2::new(sw / img_w, sh / img_h), + 0.0, + Vec2::new(sx / img_w, sy / img_h), + ) + } + _ => Affine2::IDENTITY, + }; + + let tint = state.tint_color.unwrap_or(Color::WHITE); + let material_key = MaterialKey::Color { + transparent: tint.alpha() < 1.0, + background_image: Some(p_image.handle.clone()), + uv_transform: uv_xform, + blend_state: state.blend_state, + }; + let stroke_config = state.stroke_config; + + flush_batch(&mut res, &mut batch, &p_material_handles); + start_batch( + &mut res, + &mut batch, + &state, + material_key, + &p_material_handles, + ); + + if let Some(ref mut mesh) = batch.current_mesh { + rect( + mesh, + x, + y, + w, + h, + [0.0; 4], + tint, + TessellationMode::Fill, + &stroke_config, + ); + } + + flush_batch(&mut res, &mut batch, &p_material_handles); + } DrawCommand::BackgroundColor(color) => { flush_batch(&mut res, &mut batch, &p_material_handles); @@ -797,6 +830,7 @@ pub fn flush_draw_commands( let material_key = MaterialKey::Color { transparent: color.alpha() < 1.0, background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: Some(BlendState::REPLACE), }; let material_handle = material_key.to_material(&mut res.materials); @@ -825,6 +859,7 @@ pub fn flush_draw_commands( let material_key = MaterialKey::Color { transparent: false, background_image: Some(p_image.handle.clone()), + uv_transform: Affine2::IDENTITY, blend_state: Some(BlendState::REPLACE), }; let material_handle = material_key.to_material(&mut res.materials); @@ -844,6 +879,9 @@ pub fn flush_draw_commands( DrawCommand::ResetMatrix => state.transform.reset(), DrawCommand::Translate(v) => state.transform.translate(v.x, v.y), DrawCommand::Rotate { angle } => state.transform.rotate(angle), + DrawCommand::RotateX { angle } => state.transform.rotate_x(angle), + DrawCommand::RotateY { angle } => state.transform.rotate_y(angle), + DrawCommand::RotateZ { angle } => state.transform.rotate_z(angle), DrawCommand::Scale(v) => state.transform.scale(v.x, v.y), DrawCommand::ShearX { angle } => state.transform.shear_x(angle), DrawCommand::ShearY { angle } => state.transform.shear_y(angle), @@ -1141,26 +1179,20 @@ fn material_key_with_color( ) -> MaterialKey { match key { MaterialKey::Color { - background_image, .. + background_image, + uv_transform, + .. } => MaterialKey::Color { transparent: color.alpha() < 1.0, background_image: background_image.clone(), + uv_transform: *uv_transform, blend_state, }, - MaterialKey::Pbr { - roughness, - metallic, - emissive, - .. - } => { - let [r, g, b, a] = color.to_srgba().to_u8_array(); - MaterialKey::Pbr { - albedo: [r, g, b, a], - roughness: *roughness, - metallic: *metallic, - emissive: *emissive, - blend_state, - } + MaterialKey::Pbr { .. } => { + let mut pbr = key.as_pbr(); + pbr.albedo = color.to_srgba().to_u8_array(); + pbr.blend_state = blend_state; + pbr.into() } MaterialKey::Custom { entity, .. } => MaterialKey::Custom { entity: *entity,