diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index b5ec441..998c886 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -124,23 +124,6 @@ fn normalize_src_rect(entity: Entity, rect: Option<[f32; 4]>) -> PyResult<[f32; } } -/// Resolve a composite source (`Image`, `Webcam`, or `Graphics`) to a -/// texture-bearing entity, plus the graphics entity that must be flushed first -/// when the source is a graphics (so its latest content is what gets sampled). -/// A `Graphics` source uses its render-target surface entity, which carries an -/// `Image` component for offscreen graphics. -fn resolve_composite_source(src: &Bound<'_, PyAny>) -> PyResult<(Entity, Option)> { - if let Ok(img) = src.extract::() { - return Ok((img.entity, None)); - } - if let Ok(g) = src.extract::>() { - return Ok((g.surface.entity, Some(g.entity))); - } - Err(PyValueError::new_err( - "composite source must be an Image, Webcam, or Graphics", - )) -} - /// Run the built-in composite filter: blend the `src` texture into the `dst` /// graphics target using `mode`, with normalized src/dst rects. This is the /// shader-based composite pass shared by `copy`/`blend`/`mask`. @@ -446,6 +429,36 @@ impl Light { let v = extract_vec3(args)?; transform_look_at(self.entity, v).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + + #[pyo3(signature = (*args))] + pub fn translate(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let v = extract_vec3(args)?; + transform_translate(self.entity, v).map_err(rt_err) + } + + pub fn rotate_x(&self, angle: f32) -> PyResult<()> { + transform_rotate_x(self.entity, angle).map_err(rt_err) + } + + pub fn rotate_y(&self, angle: f32) -> PyResult<()> { + transform_rotate_y(self.entity, angle).map_err(rt_err) + } + + pub fn rotate_z(&self, angle: f32) -> PyResult<()> { + transform_rotate_z(self.entity, angle).map_err(rt_err) + } + + /// Rotate by `angle` radians around `axis`. + #[pyo3(signature = (angle, *axis))] + pub fn rotate_axis(&self, angle: f32, axis: &Bound<'_, PyTuple>) -> PyResult<()> { + let axis = extract_vec3(axis)?; + transform_rotate_axis(self.entity, angle, axis).map_err(rt_err) + } + + /// Back to the origin with no rotation. + pub fn reset(&self) -> PyResult<()> { + transform_reset(self.entity).map_err(rt_err) + } } impl Light { @@ -565,8 +578,22 @@ impl Image { } } +/// Anything that can be sampled as an image: an `Image`, a `Webcam`, or an +/// offscreen `Graphics`. pub(crate) struct ImageRef { - pub entity: Entity, + entity: Entity, + graphics: Option, +} + +impl ImageRef { + /// The entity carrying the image. A `Graphics` source with pending draws is + /// flushed first, so what it has drawn so far is what gets sampled. + pub(crate) fn texture(&self) -> PyResult { + if let Some(graphics) = self.graphics { + graphics_flush_pending(graphics).map_err(rt_err)?; + } + Ok(self.entity) + } } impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { @@ -574,7 +601,10 @@ impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { fn extract(ob: pyo3::Borrowed<'a, 'py, PyAny>) -> PyResult { if let Ok(img) = ob.extract::>() { - return Ok(ImageRef { entity: img.entity }); + return Ok(ImageRef { + entity: img.entity, + graphics: None, + }); } // an offscreen Graphics renders into an image; a window has no texture to sample if let Ok(g) = ob.extract::>() { @@ -585,12 +615,14 @@ impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { } return Ok(ImageRef { entity: g.surface.entity, + graphics: Some(g.entity), }); } #[cfg(feature = "webcam")] if let Ok(cam) = ob.extract::>() { return Ok(ImageRef { entity: cam.image_entity()?, + graphics: None, }); } Err(pyo3::exceptions::PyTypeError::new_err( @@ -629,6 +661,26 @@ impl Image { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + /// Resize to `width` x `height` pixels. A 0 for either keeps the aspect ratio. + pub fn resize(&self, width: u32, height: u32) -> PyResult<()> { + let (w, h) = image_size(self.entity).map_err(rt_err)?; + let (width, height) = match (width, height) { + (0, 0) => return Err(PyValueError::new_err("resize() needs a non-zero size")), + (0, height) => ((w as f32 * height as f32 / h as f32).round() as u32, height), + (width, 0) => (width, (h as f32 * width as f32 / w as f32).round() as u32), + size => size, + }; + image_resize( + self.entity, + Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + ) + .map_err(rt_err) + } + /// Image width in pixels. #[getter] fn width(&self) -> PyResult { @@ -1258,8 +1310,19 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + /// A color (same arguments as `fill()`), or an `Image`, `Graphics` or + /// `Webcam` stretched over the canvas. #[pyo3(signature = (*args))] pub fn background(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + if args.len() == 1 + && let Ok(image) = args.get_item(0)?.extract::() + { + return graphics_record_command( + self.entity, + DrawCommand::BackgroundImage(image.texture()?), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } let color = extract_color_with_mode( args, &graphics_get_color_mode(self.entity) @@ -1269,11 +1332,6 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn background_image(&self, image: &Image) -> PyResult<()> { - graphics_record_command(self.entity, DrawCommand::BackgroundImage(image.entity)) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) - } - pub fn clear(&self) -> PyResult<()> { graphics_record_command( self.entity, @@ -1951,7 +2009,7 @@ impl Graphics { graphics_record_command( self.entity, DrawCommand::Image { - entity: source.entity, + entity: source.texture()?, dx, dy, d_width, @@ -2340,7 +2398,7 @@ impl Graphics { } pub fn texture(&self, source: ImageRef) -> PyResult<()> { - graphics_record_command(self.entity, DrawCommand::Texture(source.entity)) + graphics_record_command(self.entity, DrawCommand::Texture(source.texture()?)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -2473,10 +2531,7 @@ impl Graphics { MULTIPLY, SCREEN, REPLACE)", ) })?; - let (src_entity, flush) = resolve_composite_source(src)?; - if let Some(g) = flush { - graphics_flush(g).map_err(rt_err)?; - } + let src_entity = src.extract::()?.texture()?; let sr = normalize_src_rect(src_entity, src_rect)?; let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); composite_apply(self.entity, src_entity, code, sr, dr, opacity) @@ -2492,10 +2547,7 @@ impl Graphics { src_rect: Option<[f32; 4]>, dst_rect: Option<[f32; 4]>, ) -> PyResult<()> { - let (src_entity, flush) = resolve_composite_source(src)?; - if let Some(g) = flush { - graphics_flush(g).map_err(rt_err)?; - } + let src_entity = src.extract::()?.texture()?; let sr = normalize_src_rect(src_entity, src_rect)?; let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); composite_apply(self.entity, src_entity, COMPOSITE_MODE_REPLACE, sr, dr, 1.0) @@ -2519,10 +2571,7 @@ impl Graphics { /// Applies a mask source's brightness as this graphics' alpha channel /// (Processing `mask`). `mask` may be an `Image`, webcam, or `Graphics`. pub fn mask(&self, mask: &Bound<'_, PyAny>) -> PyResult<()> { - let (src_entity, flush) = resolve_composite_source(mask)?; - if let Some(g) = flush { - graphics_flush(g).map_err(rt_err)?; - } + let src_entity = mask.extract::()?.texture()?; composite_apply( self.entity, src_entity, diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 021c0d4..6a65ed1 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -1687,13 +1687,7 @@ pub mod processing_py { #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn background(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let graphics = graphics!(module); - let first = args.get_item(0)?; - if first.is_instance_of::() { - graphics.background_image(&*first.extract::>()?) - } else { - graphics.background(args) - } + graphics!(module).background(args) } #[pyfunction] diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 95cf57a..2a1c4fe 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -16,7 +16,7 @@ pub struct Material { pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult { if let Ok(img_ref) = value.extract::() { - return Ok(shader_value::ShaderValue::Texture(img_ref.entity)); + return Ok(shader_value::ShaderValue::Texture(img_ref.texture()?)); } if let Ok(int_val) = value.cast::() { if let Ok(v) = int_val.extract::() { @@ -47,6 +47,16 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult() { + let m = bevy::math::Mat4::from_cols_array(&rows).transpose(); + return Ok(shader_value::ShaderValue::Mat4(m.to_cols_array())); + } + if let Ok(rows) = value.extract::<[[f32; 4]; 4]>() { + let m = bevy::math::Mat4::from_cols_array_2d(&rows).transpose(); + return Ok(shader_value::ShaderValue::Mat4(m.to_cols_array())); + } + if let Ok(v) = value.extract::<[f32; 4]>() { return Ok(shader_value::ShaderValue::Float4(v)); } @@ -88,6 +98,32 @@ fn apply_albedo(entity: Entity, value: &Bound<'_, PyAny>) -> PyResult<()> { ))) } +fn apply_emissive(entity: Entity, value: &Bound<'_, PyAny>) -> PyResult<()> { + let rt = |e| PyRuntimeError::new_err(format!("{e}")); + if let Ok(buf) = value.extract::>() { + return material_set_emissive_buffer(entity, buf.entity).map_err(rt); + } + // emissive is a linear radiance, not an sRGB color + let linear: bevy::color::LinearRgba = if let Ok(c) = value.extract::>() { + c.0.into() + } else if let Ok([r, g, b, a]) = value.extract::<[f32; 4]>() { + bevy::color::LinearRgba::new(r, g, b, a) + } else if let Ok([r, g, b]) = value.extract::<[f32; 3]>() { + bevy::color::LinearRgba::new(r, g, b, 1.0) + } else { + return Err(PyRuntimeError::new_err(format!( + "unsupported emissive type: {} (expected Color, Buffer, or [r,g,b,(a)])", + value.get_type().name()? + ))); + }; + material_set( + entity, + "emissive", + shader_value::ShaderValue::Float4([linear.red, linear.green, linear.blue, linear.alpha]), + ) + .map_err(rt) +} + fn py_truthy(value: &Bound<'_, PyAny>) -> PyResult { value .extract::() @@ -100,6 +136,7 @@ fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { let rt = |e| PyRuntimeError::new_err(format!("{e}")); match name.as_str() { "albedo" => apply_albedo(entity, &value)?, + "emissive" => apply_emissive(entity, &value)?, "unlit" => material_set_unlit(entity, py_truthy(&value)?).map_err(rt)?, "double_sided" => material_set_double_sided(entity, py_truthy(&value)?).map_err(rt)?, "depth_write" => material_set_depth_write(entity, py_truthy(&value)?).map_err(rt)?, @@ -166,6 +203,70 @@ impl Material { }; apply_kwargs(self.entity, kwargs) } + + /// Base color: a `Color`, `[r, g, b, (a)]`, or a per-instance `Buffer`. + pub fn albedo(&self, value: &Bound<'_, PyAny>) -> PyResult<()> { + apply_albedo(self.entity, value) + } + + pub fn metalness(&self, value: f32) -> PyResult<()> { + self.set_pbr("metallic", value) + } + + pub fn roughness(&self, value: f32) -> PyResult<()> { + self.set_pbr("roughness", value) + } + + pub fn reflectance(&self, value: f32) -> PyResult<()> { + self.set_pbr("reflectance", value) + } + + /// Emitted light: a `Color`, linear `[r, g, b, (a)]`, or a per-instance `Buffer`. + pub fn emissive(&self, value: &Bound<'_, PyAny>) -> PyResult<()> { + apply_emissive(self.entity, value) + } + + pub fn opaque(&self) -> PyResult<()> { + material_set_alpha_mode(self.entity, 0, 0.0).map_err(rt) + } + + /// Discards fragments with alpha below `cutoff`. + #[pyo3(signature = (cutoff=0.5))] + pub fn mask(&self, cutoff: f32) -> PyResult<()> { + material_set_alpha_mode(self.entity, 1, cutoff).map_err(rt) + } + + pub fn transparent(&self) -> PyResult<()> { + material_set_alpha_mode(self.entity, 2, 0.0).map_err(rt) + } + + #[pyo3(signature = (on=true))] + pub fn double_sided(&self, on: bool) -> PyResult<()> { + material_set_double_sided(self.entity, on).map_err(rt) + } + + #[pyo3(signature = (on=true))] + pub fn depth_write(&self, on: bool) -> PyResult<()> { + material_set_depth_write(self.entity, on).map_err(rt) + } + + /// Blends with what's behind it using a `BlendMode` (`ADD`, `MULTIPLY`, a custom one, ...). + pub fn blend_mode(&self, mode: &crate::graphics::PyBlendMode) -> PyResult<()> { + let state = mode + .blend_state + .unwrap_or(bevy::render::render_resource::BlendState::ALPHA_BLENDING); + material_set_custom_blend(self.entity, state).map_err(rt) + } +} + +fn rt(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) +} + +impl Material { + fn set_pbr(&self, name: &str, value: f32) -> PyResult<()> { + material_set(self.entity, name, shader_value::ShaderValue::Float(value)).map_err(rt) + } } impl Drop for Material { diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 86194b2..adeff56 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -697,6 +697,15 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { Ok(()) } +/// Brings the target up to date with what was drawn so far; no update when nothing is queued +pub fn flush_pending(app: &mut App, entity: Entity) -> Result<()> { + if has_commands(app, entity) { + flush(app, entity) + } else { + upload_dirty(app, entity) + } +} + fn has_commands(app: &App, entity: Entity) -> bool { app.world() .get::(entity) diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index fee055d..f89e82a 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -412,10 +412,19 @@ pub fn resize( .get_mut(entity) .map_err(|_| ProcessingError::ImageNotFound)?; - images + let format = p_image.texture_format; + let old_size = p_image.size; + let mut image = images .get_mut(&p_image.handle) - .ok_or(ProcessingError::ImageNotFound)? - .resize_in_place(new_size); + .ok_or(ProcessingError::ImageNotFound)?; + let resampled = match image.data.as_deref() { + Some(data) => Some(resample(data, format, old_size, new_size)?), + None => None, + }; + image.resize_in_place(new_size); + if resampled.is_some() { + image.data = resampled; + } p_image.coherence = Coherence::InSync; p_image.readback_buffer = create_readback_buffer( @@ -430,6 +439,96 @@ pub fn resize( Ok(()) } +/// Scales tightly packed pixels to `to`: bilinear when enlarging, area average when shrinking, +/// as Processing's `resize`; channels are filtered as stored, with alpha premultiplied +fn resample(data: &[u8], format: TextureFormat, from: Extent3d, to: Extent3d) -> Result> { + let srgb = format == TextureFormat::Rgba8UnormSrgb; + let stride = from.width as usize * pixel_size(format)?; + let src: Vec<[f32; 4]> = bytes_to_pixels(data, format, from.width, from.height, stride)? + .into_iter() + .map(|p| { + let [r, g, b, a] = if srgb { + Srgba::from(p).to_f32_array() + } else { + p.to_f32_array() + }; + [r * a, g * a, b * a, a] + }) + .collect(); + + // one axis at a time: `len` source texels starting at `at(line, i)` become `out_len` + let pass = |src: &[[f32; 4]], lines: usize, len: usize, out_len: usize, horizontal: bool| { + let scale = len as f32 / out_len as f32; + let mut out = vec![[0.0f32; 4]; lines * out_len]; + for o in 0..out_len { + let (start, end) = (o as f32 * scale, (o as f32 + 1.0) * scale); + let center = (start + end) / 2.0; + let (first, last, weights): (usize, usize, Vec) = if scale > 1.0 { + // how much of each source texel the destination texel covers + let (first, last) = (start.floor() as usize, (end.ceil() as usize).min(len)); + let weights = (first..last) + .map(|i| (end.min(i as f32 + 1.0) - start.max(i as f32)).max(0.0)) + .collect(); + (first, last, weights) + } else { + // the two nearest texel centers, clamped at the edges + let lo = (center - 0.5).floor(); + let t = center - 0.5 - lo; + let first = lo.max(0.0) as usize; + let last = ((lo as isize + 2).max(1) as usize).min(len); + let weights = if last - first == 2 { + vec![1.0 - t, t] + } else { + vec![1.0] + }; + (first, last, weights) + }; + let total: f32 = weights.iter().sum(); + for line in 0..lines { + let mut acc = [0.0f32; 4]; + for (i, w) in (first..last).zip(&weights) { + let texel = if horizontal { + src[line * len + i] + } else { + src[i * lines + line] + }; + for c in 0..4 { + acc[c] += texel[c] * w; + } + } + let dst = if horizontal { + line * out_len + o + } else { + o * lines + line + }; + out[dst] = acc.map(|v| v / total); + } + } + out + }; + let (fw, fh) = (from.width as usize, from.height as usize); + let (tw, th) = (to.width as usize, to.height as usize); + let wide = pass(&src, fh, fw, tw, true); + let scaled = pass(&wide, tw, fh, th, false); + + let pixels: Vec = scaled + .into_iter() + .map(|[r, g, b, a]| { + let (r, g, b) = if a > 0.0 { + (r / a, g / a, b / a) + } else { + (0.0, 0.0, 0.0) + }; + if srgb { + Srgba::new(r, g, b, a).into() + } else { + LinearRgba::new(r, g, b, a) + } + }) + .collect(); + pixels_to_bytes(&pixels, format) +} + pub fn readback( In((entity, texture)): In<(Entity, Texture)>, mut p_images: Query<&mut Image>, diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 54363dd..c023a38 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -563,6 +563,11 @@ pub fn graphics_flush(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::flush(app, graphics_entity)) } +/// Flush only if draws or `set` pixels are pending, for sampling the graphics as an image. +pub fn graphics_flush_pending(graphics_entity: Entity) -> error::Result<()> { + app_mut(|app| graphics::flush_pending(app, graphics_entity)) +} + /// Present the current frame to the surface. pub fn graphics_present(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::present(app, graphics_entity))