From 753cb428192861414f3c9938cae889143ebe8f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Thu, 16 Apr 2026 23:38:01 -0700 Subject: [PATCH 1/3] Filters. --- Cargo.toml | 66 +++++++ crates/processing_pyo3/src/constants.rs | 8 + crates/processing_pyo3/src/filter.rs | 131 +++++++++++++ crates/processing_pyo3/src/graphics.rs | 11 ++ crates/processing_pyo3/src/lib.rs | 1 + crates/processing_render/src/graphics.rs | 19 ++ crates/processing_render/src/lib.rs | 8 + crates/processing_render/src/render/filter.rs | 177 ++++++++++++++++++ .../src/render/filters/gray.wgsl | 17 ++ .../src/render/filters/invert.wgsl | 15 ++ .../src/render/filters/opaque.wgsl | 15 ++ .../src/render/filters/posterize.wgsl | 20 ++ .../src/render/filters/threshold.wgsl | 21 +++ crates/processing_render/src/render/mod.rs | 1 + examples/filter.rs | 88 +++++++++ src/prelude.rs | 1 + 16 files changed, 599 insertions(+) create mode 100644 crates/processing_pyo3/src/filter.rs create mode 100644 crates/processing_render/src/render/filter.rs create mode 100644 crates/processing_render/src/render/filters/gray.wgsl create mode 100644 crates/processing_render/src/render/filters/invert.wgsl create mode 100644 crates/processing_render/src/render/filters/opaque.wgsl create mode 100644 crates/processing_render/src/render/filters/posterize.wgsl create mode 100644 crates/processing_render/src/render/filters/threshold.wgsl create mode 100644 examples/filter.rs diff --git a/Cargo.toml b/Cargo.toml index acc5c3a..2f1d6b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -264,8 +264,74 @@ path = "examples/text.rs" name = "text_3d" path = "examples/text_3d.rs" +[[example]] +name = "filter" +path = "examples/filter.rs" + [profile.wasm-release] inherits = "release" opt-level = "z" lto = "fat" codegen-units = 1 + +# Local override to test uniform-index cleanup fix before upstreaming. +[patch."https://github.com/bevyengine/bevy"] +bevy = { path = "/Users/char/src/github.com/bevyengine/bevy" } +bevy_a11y = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_a11y" } +bevy_android = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_android" } +bevy_animation = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_animation" } +bevy_anti_alias = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_anti_alias" } +bevy_app = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_app" } +bevy_asset = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_asset" } +bevy_audio = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_audio" } +bevy_camera = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_camera" } +bevy_camera_controller = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_camera_controller" } +bevy_color = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_color" } +bevy_core_pipeline = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_core_pipeline" } +bevy_derive = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_derive" } +bevy_dev_tools = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_dev_tools" } +bevy_diagnostic = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_diagnostic" } +bevy_dylib = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_dylib" } +bevy_ecs = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ecs" } +bevy_encase_derive = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_encase_derive" } +bevy_feathers = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_feathers" } +bevy_gilrs = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gilrs" } +bevy_gizmos = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gizmos" } +bevy_gizmos_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gizmos_render" } +bevy_gltf = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gltf" } +bevy_image = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_image" } +bevy_input = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_input" } +bevy_input_focus = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_input_focus" } +bevy_internal = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_internal" } +bevy_light = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_light" } +bevy_log = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_log" } +bevy_macro_utils = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_macro_utils" } +bevy_material = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_material" } +bevy_math = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_math" } +bevy_mesh = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_mesh" } +bevy_pbr = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_pbr" } +bevy_picking = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_picking" } +bevy_platform = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_platform" } +bevy_post_process = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_post_process" } +bevy_ptr = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ptr" } +bevy_reflect = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_reflect" } +bevy_remote = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_remote" } +bevy_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_render" } +bevy_scene = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_scene" } +bevy_settings = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_settings" } +bevy_shader = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_shader" } +bevy_solari = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_solari" } +bevy_sprite = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_sprite" } +bevy_sprite_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_sprite_render" } +bevy_state = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_state" } +bevy_tasks = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_tasks" } +bevy_text = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_text" } +bevy_time = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_time" } +bevy_transform = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_transform" } +bevy_ui = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui" } +bevy_ui_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui_render" } +bevy_ui_widgets = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui_widgets" } +bevy_utils = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_utils" } +bevy_window = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_window" } +bevy_winit = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_winit" } +bevy_world_serialization = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_world_serialization" } diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs index 872c91c..dbff12f 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -27,6 +27,14 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { ); add!(m, CORNER, CORNERS, CENTER, RADIUS); add!(m, OPEN, CHORD, PIE, CLOSE); + + // Filters. Values are the single source of truth in `crate::filter`. + m.add("INVERT", crate::filter::INVERT_U8)?; + m.add("GRAY", crate::filter::GRAY_U8)?; + m.add("THRESHOLD", crate::filter::THRESHOLD_U8)?; + m.add("POSTERIZE", crate::filter::POSTERIZE_U8)?; + m.add("OPAQUE", crate::filter::OPAQUE_U8)?; + add!(m, LEFT, RIGHT); add!(m, NEAREST, CLAMP, REPEAT, MIRROR); add!(m, SRGB, LINEAR, HSL, HSV, HWB, OKLAB, OKLCH, LAB, LCH, XYZ); diff --git a/crates/processing_pyo3/src/filter.rs b/crates/processing_pyo3/src/filter.rs new file mode 100644 index 0000000..cec081d --- /dev/null +++ b/crates/processing_pyo3/src/filter.rs @@ -0,0 +1,131 @@ +use processing::prelude::*; +use pyo3::{ + exceptions::{PyTypeError, PyValueError}, + prelude::*, + types::{PyDict, PyTuple}, +}; + +// Must match the `#[pymodule_export] const` values in lib.rs. +pub const INVERT_U8: u8 = 0; +pub const GRAY_U8: u8 = 1; +pub const THRESHOLD_U8: u8 = 2; +pub const POSTERIZE_U8: u8 = 3; +pub const OPAQUE_U8: u8 = 5; + +pub fn parse_filter_op( + kind: &Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, +) -> PyResult { + let Ok(kind_u8) = kind.extract::() else { + return Err(PyTypeError::new_err( + "filter(): first argument must be a filter constant (INVERT, GRAY, ...)", + )); + }; + + let kind = match kind_u8 { + INVERT_U8 => { + reject_params(args, kwargs, "INVERT")?; + FilterKind::Invert + } + GRAY_U8 => { + reject_params(args, kwargs, "GRAY")?; + FilterKind::Gray + } + OPAQUE_U8 => { + reject_params(args, kwargs, "OPAQUE")?; + FilterKind::Opaque + } + THRESHOLD_U8 => { + let cutoff = parse_scalar(args, kwargs, "THRESHOLD", "cutoff", Some(0.5))?; + FilterKind::Threshold { cutoff } + } + POSTERIZE_U8 => { + let levels_f = parse_scalar(args, kwargs, "POSTERIZE", "levels", None)?; + if !(2.0..=255.0).contains(&levels_f) || levels_f.fract() != 0.0 { + return Err(PyValueError::new_err( + "filter(POSTERIZE, levels): levels must be an integer in 2..=255", + )); + } + FilterKind::Posterize { + levels: levels_f as u32, + } + } + n => { + return Err(PyValueError::new_err(format!( + "filter(): unknown or unimplemented filter constant {n}" + ))); + } + }; + + Ok(FilterOp::new(kind)) +} + +fn reject_params( + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + name: &str, +) -> PyResult<()> { + if args.len() > 0 { + return Err(PyValueError::new_err(format!( + "filter({name}): takes no parameters, got {} positional", + args.len() + ))); + } + if let Some(kw) = kwargs + && !kw.is_empty() + { + return Err(PyValueError::new_err(format!( + "filter({name}): takes no parameters" + ))); + } + Ok(()) +} + +fn parse_scalar( + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + filter_name: &str, + kw_name: &str, + default: Option, +) -> PyResult { + if args.len() > 1 { + return Err(PyValueError::new_err(format!( + "filter({filter_name}): expected at most 1 positional arg, got {}", + args.len() + ))); + } + if let Some(kw) = kwargs { + for key in kw.keys().iter() { + let k_str = key.str().map(|s| s.to_string()).unwrap_or_default(); + if k_str != kw_name { + return Err(PyValueError::new_err(format!( + "filter({filter_name}): unknown keyword arg `{k_str}` (expected `{kw_name}`)" + ))); + } + } + } + + let from_kw = kwargs + .and_then(|kw| kw.get_item(kw_name).ok().flatten()) + .map(|v| v.extract::()) + .transpose()?; + + let from_pos = if args.len() > 0 { + Some(args.get_item(0)?.extract::()?) + } else { + None + }; + + match (from_pos, from_kw) { + (Some(_), Some(_)) => Err(PyValueError::new_err(format!( + "filter({filter_name}): got both positional and keyword `{kw_name}`" + ))), + (Some(v), None) | (None, Some(v)) => Ok(v), + (None, None) => default.ok_or_else(|| { + PyValueError::new_err(format!( + "filter({filter_name}): missing required parameter `{kw_name}`" + )) + }), + } +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 0cad553..d5a8346 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -638,6 +638,17 @@ impl Graphics { } } + #[pyo3(signature = (kind, *args, **kwargs))] + pub fn filter( + &self, + kind: Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + let op = crate::filter::parse_filter_op(&kind, args, kwargs)?; + graphics_apply_filter(self.entity, op).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[pyo3(signature = (*args))] pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult { extract_color_with_mode( diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 1a7ac92..3ce155f 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -13,6 +13,7 @@ pub(crate) mod compute; mod constants; #[cfg(feature = "cuda")] pub(crate) mod cuda; +pub(crate) mod filter; mod glfw; mod gltf; mod graphics; diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index d8fa2b9..bd4c50e 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -31,6 +31,7 @@ use crate::{ render::{ BATCH_INDEX_STEP, RenderState, command::{CommandBuffer, DrawCommand}, + filter::{self, FilterOp}, }, surface::Surface, }; @@ -495,6 +496,24 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { Ok(()) } +pub fn apply_filter(app: &mut App, entity: Entity, op: FilterOp) -> Result<()> { + { + let mut e = graphics_mut!(app, entity); + e.insert(Flush); + filter::attach(&mut e, op); + } + app.update(); + { + let mut e = graphics_mut!(app, entity); + e.remove::(); + filter::detach(&mut e); + } + app.world_mut() + .run_system_cached(update_view_targets) + .expect("Failed to run update_view_targets"); + Ok(()) +} + pub fn present(app: &mut App, entity: Entity) -> Result<()> { graphics_mut!(app, entity) .get_mut::() diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 1f7f65f..fb27555 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -67,6 +67,7 @@ impl Plugin for ProcessingRenderPlugin { material::custom::CustomMaterialPlugin, compute::ComputePlugin, particles::ParticlesPlugin, + render::filter::FilterPlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, @@ -480,6 +481,13 @@ pub fn graphics_end_draw(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::end_draw(app, graphics_entity)) } +pub fn graphics_apply_filter( + graphics_entity: Entity, + op: render::filter::FilterOp, +) -> error::Result<()> { + app_mut(|app| graphics::apply_filter(app, graphics_entity, op)) +} + /// Destroy the graphics surface and free its resources. pub fn graphics_destroy(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| { diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs new file mode 100644 index 0000000..f9190a5 --- /dev/null +++ b/crates/processing_render/src/render/filter.rs @@ -0,0 +1,177 @@ +//! Full-screen post-process filters (Processing 4 / p5.js `filter()`). + +use bevy::{ + asset::embedded_asset, + core_pipeline::fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin}, + ecs::component::Component, + math::Vec4, + prelude::*, + render::{ + Render, RenderApp, RenderSystems, + extract_component::{DynamicUniformIndex, ExtractComponent}, + render_resource::ShaderType, + }, + shader::ShaderRef, +}; + + +#[derive(Debug, Clone, Copy)] +pub enum FilterKind { + Invert, + Gray, + Threshold { cutoff: f32 }, + Posterize { levels: u32 }, + Opaque, +} + +#[derive(Debug, Clone, Copy)] +pub struct FilterOp { + pub kind: FilterKind, +} + +impl FilterOp { + pub fn new(kind: FilterKind) -> Self { + Self { kind } + } +} + +#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] +pub struct InvertFilter { + _padding: Vec4, +} + +impl FullscreenMaterial for InvertFilter { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/render/filters/invert.wgsl".into() + } +} + +#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] +pub struct GrayFilter { + _padding: Vec4, +} + +impl FullscreenMaterial for GrayFilter { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/render/filters/gray.wgsl".into() + } +} + +#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] +pub struct ThresholdFilter { + pub cutoff: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} + +impl FullscreenMaterial for ThresholdFilter { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/render/filters/threshold.wgsl".into() + } +} + +#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] +pub struct PosterizeFilter { + pub levels: u32, + _p0: u32, + _p1: u32, + _p2: u32, +} + +impl FullscreenMaterial for PosterizeFilter { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/render/filters/posterize.wgsl".into() + } +} + +#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] +pub struct OpaqueFilter { + _padding: Vec4, +} + +impl FullscreenMaterial for OpaqueFilter { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/render/filters/opaque.wgsl".into() + } +} + +pub struct FilterPlugin; + +impl Plugin for FilterPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "filters/invert.wgsl"); + embedded_asset!(app, "filters/gray.wgsl"); + embedded_asset!(app, "filters/threshold.wgsl"); + embedded_asset!(app, "filters/posterize.wgsl"); + embedded_asset!(app, "filters/opaque.wgsl"); + + app.add_plugins(( + FullscreenMaterialPlugin::::default(), + FullscreenMaterialPlugin::::default(), + FullscreenMaterialPlugin::::default(), + FullscreenMaterialPlugin::::default(), + FullscreenMaterialPlugin::::default(), + )); + + // Bevy's `UniformComponentPlugin` doesn't clear `DynamicUniformIndex` + // when `T` is removed. The fullscreen-material system queries on that + // index, so without this cleanup the filter keeps running after detach. + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.add_systems( + Render, + ( + clear_stale_uniform_index::, + clear_stale_uniform_index::, + clear_stale_uniform_index::, + clear_stale_uniform_index::, + clear_stale_uniform_index::, + ) + .in_set(RenderSystems::PrepareResources), + ); + } + } +} + +fn clear_stale_uniform_index( + mut commands: Commands, + stale: Query>, Without)>, +) { + for e in &stale { + commands.entity(e).remove::>(); + } +} + +pub fn attach(entity_commands: &mut EntityWorldMut<'_>, op: FilterOp) { + match op.kind { + FilterKind::Invert => { + entity_commands.insert(InvertFilter::default()); + } + FilterKind::Gray => { + entity_commands.insert(GrayFilter::default()); + } + FilterKind::Threshold { cutoff } => { + entity_commands.insert(ThresholdFilter { + cutoff, + ..default() + }); + } + FilterKind::Posterize { levels } => { + entity_commands.insert(PosterizeFilter { + levels, + ..default() + }); + } + FilterKind::Opaque => { + entity_commands.insert(OpaqueFilter::default()); + } + } +} + +pub fn detach(entity_commands: &mut EntityWorldMut<'_>) { + entity_commands.remove::(); + entity_commands.remove::(); + entity_commands.remove::(); + entity_commands.remove::(); + entity_commands.remove::(); +} diff --git a/crates/processing_render/src/render/filters/gray.wgsl b/crates/processing_render/src/render/filters/gray.wgsl new file mode 100644 index 0000000..3c878c7 --- /dev/null +++ b/crates/processing_render/src/render/filters/gray.wgsl @@ -0,0 +1,17 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; + +struct Settings { + _padding: vec4, +} +@group(0) @binding(2) var settings: Settings; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(screen_texture, texture_sampler, in.uv); + // Processing 4 weights (PImage.java:990): 77/256, 151/256, 28/256 + let luma = 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; + return vec4(luma, luma, luma, c.a); +} diff --git a/crates/processing_render/src/render/filters/invert.wgsl b/crates/processing_render/src/render/filters/invert.wgsl new file mode 100644 index 0000000..b0c6425 --- /dev/null +++ b/crates/processing_render/src/render/filters/invert.wgsl @@ -0,0 +1,15 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; + +struct Settings { + _padding: vec4, +} +@group(0) @binding(2) var settings: Settings; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(screen_texture, texture_sampler, in.uv); + return vec4(1.0 - c.r, 1.0 - c.g, 1.0 - c.b, c.a); +} diff --git a/crates/processing_render/src/render/filters/opaque.wgsl b/crates/processing_render/src/render/filters/opaque.wgsl new file mode 100644 index 0000000..e46345a --- /dev/null +++ b/crates/processing_render/src/render/filters/opaque.wgsl @@ -0,0 +1,15 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; + +struct Settings { + _padding: vec4, +} +@group(0) @binding(2) var settings: Settings; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(screen_texture, texture_sampler, in.uv); + return vec4(c.rgb, 1.0); +} diff --git a/crates/processing_render/src/render/filters/posterize.wgsl b/crates/processing_render/src/render/filters/posterize.wgsl new file mode 100644 index 0000000..12945cb --- /dev/null +++ b/crates/processing_render/src/render/filters/posterize.wgsl @@ -0,0 +1,20 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; + +struct Settings { + levels: u32, + _p0: u32, + _p1: u32, + _p2: u32, +} +@group(0) @binding(2) var settings: Settings; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(screen_texture, texture_sampler, in.uv); + let n = f32(max(settings.levels, 2u)); + let q = floor(c.rgb * n) / (n - 1.0); + return vec4(clamp(q, vec3(0.0), vec3(1.0)), c.a); +} diff --git a/crates/processing_render/src/render/filters/threshold.wgsl b/crates/processing_render/src/render/filters/threshold.wgsl new file mode 100644 index 0000000..08ff028 --- /dev/null +++ b/crates/processing_render/src/render/filters/threshold.wgsl @@ -0,0 +1,21 @@ +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; + +struct Settings { + cutoff: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} +@group(0) @binding(2) var settings: Settings; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = textureSample(screen_texture, texture_sampler, in.uv); + // Processing 4 weights (PImage.java:990): 77/256, 151/256, 28/256 + let luma = 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; + let v = select(0.0, 1.0, luma >= settings.cutoff); + return vec4(v, v, v, c.a); +} diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index cc173ad..c72a549 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -1,4 +1,5 @@ pub mod command; +pub mod filter; pub mod material; pub mod mesh_builder; pub mod primitive; diff --git a/examples/filter.rs b/examples/filter.rs new file mode 100644 index 0000000..02cba4b --- /dev/null +++ b/examples/filter.rs @@ -0,0 +1,88 @@ +//! Cycles through all implemented filters (2s each) applied to a color palette. +//! +//! Bottom-right blue rect is drawn AFTER the filter, so it should always stay +//! pure blue — that's the Processing 4 immediate-mode semantic: `filter()` bakes +//! into the current canvas; subsequent draws land on top unfiltered. +use std::time::Instant; + +use bevy::color::Color; +use processing_glfw::GlfwContext; + +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + match sketch() { + Ok(_) => exit(0).unwrap(), + Err(e) => { + eprintln!("Sketch error: {:?}", e); + exit(1).unwrap(); + } + }; +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(400, 400)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(400, 400)?; + let graphics = graphics_create(surface, 400, 400, TextureFormat::Rgba16Float)?; + + let palette = [ + (40.0, 40.0, Color::srgb(1.0, 0.2, 0.2)), + (200.0, 40.0, Color::srgb(0.2, 1.0, 0.2)), + (40.0, 200.0, Color::srgb(0.2, 0.4, 1.0)), + (200.0, 200.0, Color::srgb(0.95, 0.85, 0.2)), + ]; + let filters = [ + FilterKind::Invert, + FilterKind::Gray, + FilterKind::Threshold { cutoff: 0.5 }, + FilterKind::Posterize { levels: 4 }, + FilterKind::Opaque, + ]; + + let start = Instant::now(); + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(Color::srgb(0.1, 0.1, 0.15)), + )?; + graphics_record_command(graphics, DrawCommand::NoStroke)?; + + for &(x, y, color) in &palette { + graphics_record_command(graphics, DrawCommand::Fill(color))?; + graphics_record_command( + graphics, + DrawCommand::Rect { + x, + y, + w: 160.0, + h: 160.0, + radii: [0.0; 4], + }, + )?; + } + + let idx = (start.elapsed().as_secs() / 2) as usize % filters.len(); + graphics_apply_filter(graphics, FilterOp::new(filters[idx]))?; + + // Drawn AFTER the filter: should always render pure blue regardless of cycle. + graphics_record_command(graphics, DrawCommand::Fill(Color::srgb(0.0, 0.0, 1.0)))?; + graphics_record_command( + graphics, + DrawCommand::Rect { + x: 160.0, + y: 160.0, + w: 80.0, + h: 80.0, + radii: [0.0; 4], + }, + )?; + + graphics_end_draw(graphics)?; + } + Ok(()) +} diff --git a/src/prelude.rs b/src/prelude.rs index 4e72ec9..934829d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -14,6 +14,7 @@ pub use processing_render::{ ArcMode, BlendMode, DrawCommand, ShapeKind, ShapeMode, StrokeCapMode, StrokeJoinMode, TextAlignH, TextAlignV, TextStyle, TextWrapMode, custom_blend_state, }, + render::filter::{FilterKind, FilterOp}, *, }; From 8fcb6011ac51bd830b30b10c35fde4ff031a3aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 17 Apr 2026 21:44:00 -0700 Subject: [PATCH 2/3] Filters --- Cargo.toml | 62 --- crates/processing_core/src/error.rs | 2 + crates/processing_ffi/src/lib.rs | 240 ++++++++- crates/processing_pyo3/src/constants.rs | 4 +- crates/processing_pyo3/src/filter.rs | 109 +++- crates/processing_pyo3/src/graphics.rs | 5 +- .../shaders/processing/filter.wesl | 27 + crates/processing_render/src/compute.rs | 68 +-- crates/processing_render/src/graphics.rs | 20 +- crates/processing_render/src/lib.rs | 107 +++- .../processing_render/src/material/custom.rs | 2 +- crates/processing_render/src/render/filter.rs | 493 +++++++++++++----- .../src/render/filters/blur.wgsl | 29 ++ .../src/render/filters/dilate.wgsl | 27 + .../src/render/filters/erode.wgsl | 27 + .../src/render/filters/gray.wgsl | 13 +- .../src/render/filters/invert.wgsl | 12 +- .../src/render/filters/opaque.wgsl | 12 +- .../src/render/filters/posterize.wgsl | 13 +- .../src/render/filters/threshold.wgsl | 16 +- .../processing_render/src/shader_property.rs | 83 +++ examples/filter.rs | 24 +- src/prelude.rs | 2 +- 23 files changed, 1012 insertions(+), 385 deletions(-) create mode 100644 crates/processing_render/shaders/processing/filter.wesl create mode 100644 crates/processing_render/src/render/filters/blur.wgsl create mode 100644 crates/processing_render/src/render/filters/dilate.wgsl create mode 100644 crates/processing_render/src/render/filters/erode.wgsl create mode 100644 crates/processing_render/src/shader_property.rs diff --git a/Cargo.toml b/Cargo.toml index 2f1d6b3..816779d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -273,65 +273,3 @@ inherits = "release" opt-level = "z" lto = "fat" codegen-units = 1 - -# Local override to test uniform-index cleanup fix before upstreaming. -[patch."https://github.com/bevyengine/bevy"] -bevy = { path = "/Users/char/src/github.com/bevyengine/bevy" } -bevy_a11y = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_a11y" } -bevy_android = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_android" } -bevy_animation = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_animation" } -bevy_anti_alias = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_anti_alias" } -bevy_app = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_app" } -bevy_asset = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_asset" } -bevy_audio = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_audio" } -bevy_camera = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_camera" } -bevy_camera_controller = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_camera_controller" } -bevy_color = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_color" } -bevy_core_pipeline = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_core_pipeline" } -bevy_derive = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_derive" } -bevy_dev_tools = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_dev_tools" } -bevy_diagnostic = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_diagnostic" } -bevy_dylib = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_dylib" } -bevy_ecs = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ecs" } -bevy_encase_derive = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_encase_derive" } -bevy_feathers = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_feathers" } -bevy_gilrs = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gilrs" } -bevy_gizmos = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gizmos" } -bevy_gizmos_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gizmos_render" } -bevy_gltf = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_gltf" } -bevy_image = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_image" } -bevy_input = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_input" } -bevy_input_focus = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_input_focus" } -bevy_internal = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_internal" } -bevy_light = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_light" } -bevy_log = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_log" } -bevy_macro_utils = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_macro_utils" } -bevy_material = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_material" } -bevy_math = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_math" } -bevy_mesh = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_mesh" } -bevy_pbr = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_pbr" } -bevy_picking = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_picking" } -bevy_platform = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_platform" } -bevy_post_process = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_post_process" } -bevy_ptr = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ptr" } -bevy_reflect = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_reflect" } -bevy_remote = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_remote" } -bevy_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_render" } -bevy_scene = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_scene" } -bevy_settings = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_settings" } -bevy_shader = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_shader" } -bevy_solari = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_solari" } -bevy_sprite = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_sprite" } -bevy_sprite_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_sprite_render" } -bevy_state = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_state" } -bevy_tasks = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_tasks" } -bevy_text = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_text" } -bevy_time = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_time" } -bevy_transform = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_transform" } -bevy_ui = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui" } -bevy_ui_render = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui_render" } -bevy_ui_widgets = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_ui_widgets" } -bevy_utils = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_utils" } -bevy_window = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_window" } -bevy_winit = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_winit" } -bevy_world_serialization = { path = "/Users/char/src/github.com/bevyengine/bevy/crates/bevy_world_serialization" } diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 32b1e39..41510f3 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -48,6 +48,8 @@ pub enum ProcessingError { CudaError(String), #[error("Compute shader not found")] ComputeNotFound, + #[error("Filter not found")] + FilterNotFound, #[error("Buffer not found")] BufferNotFound, #[error("Buffer map error: {0}")] diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 6dbffb0..b81adb1 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -3,7 +3,7 @@ use bevy::{ prelude::Entity, render::render_resource::{Extent3d, TextureFormat}, }; -use processing::prelude::{error::ProcessingError, *}; +use processing::prelude::{error::ProcessingError, shader_value::ShaderValue, *}; use crate::color::Color; @@ -2905,39 +2905,163 @@ pub extern "C" fn processing_compute_create(shader_id: u64) -> u64 { } /// # Safety -/// - `name` must be non-null +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_compute_set_float( - compute_id: u64, +pub unsafe extern "C" fn processing_shader_set_float( + entity: u64, name: *const std::ffi::c_char, value: f32, ) { error::clear_error(); error::check(|| { let name = unsafe { cstr_to_str(name) }?; - compute_set( - Entity::from_bits(compute_id), + shader_set(Entity::from_bits(entity), name, ShaderValue::Float(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_int( + entity: u64, + name: *const std::ffi::c_char, + value: i32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Int(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_uint( + entity: u64, + name: *const std::ffi::c_char, + value: u32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::UInt(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec2( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Float2([x, y])) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec3( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), name, - shader_value::ShaderValue::Float(value), + ShaderValue::Float3([x, y, z]), ) }); } /// # Safety -/// - `name` must be non-null +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec4( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, + w: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Float4([x, y, z, w]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +/// - `value` must point to at least 16 f32 elements (column-major). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_mat4( + entity: u64, + name: *const std::ffi::c_char, + value: *const f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + // SAFETY: caller guarantees 16 valid f32 elements + let m: [f32; 16] = unsafe { std::slice::from_raw_parts(value, 16) } + .try_into() + .unwrap(); + shader_set(Entity::from_bits(entity), name, ShaderValue::Mat4(m)) + }); +} + +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_compute_set_buffer( - compute_id: u64, +pub unsafe extern "C" fn processing_shader_set_texture( + entity: u64, + name: *const std::ffi::c_char, + image_id: u64, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Texture(Entity::from_bits(image_id)), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_buffer( + entity: u64, name: *const std::ffi::c_char, buf_id: u64, ) { error::clear_error(); error::check(|| { let name = unsafe { cstr_to_str(name) }?; - compute_set( - Entity::from_bits(compute_id), + shader_set( + Entity::from_bits(entity), name, - shader_value::ShaderValue::Buffer(Entity::from_bits(buf_id)), + ShaderValue::Buffer(Entity::from_bits(buf_id)), ) }); } @@ -2954,6 +3078,96 @@ pub extern "C" fn processing_compute_destroy(compute_id: u64) { error::check(|| compute_destroy(Entity::from_bits(compute_id))); } +/// Create a filter from a shader entity. +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_create(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| filter_create(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_invert() -> u64 { + error::clear_error(); + error::check(filter_invert) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_gray() -> u64 { + error::clear_error(); + error::check(filter_gray).map(|e| e.to_bits()).unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_threshold() -> u64 { + error::clear_error(); + error::check(filter_threshold) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_posterize() -> u64 { + error::clear_error(); + error::check(filter_posterize) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_blur() -> u64 { + error::clear_error(); + error::check(filter_blur).map(|e| e.to_bits()).unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_opaque() -> u64 { + error::clear_error(); + error::check(filter_opaque) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_erode() -> u64 { + error::clear_error(); + error::check(filter_erode).map(|e| e.to_bits()).unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_dilate() -> u64 { + error::clear_error(); + error::check(filter_dilate) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Set the number of fullscreen passes a filter runs. +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_set_passes(filter_id: u64, passes: u32) { + error::clear_error(); + error::check(|| filter_set_passes(Entity::from_bits(filter_id), passes)); +} + +/// Apply a filter to a graphics canvas. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_apply_filter(graphics_id: u64, filter_id: u64) { + error::clear_error(); + error::check(|| { + graphics_apply_filter(Entity::from_bits(graphics_id), Entity::from_bits(filter_id)) + }); +} + +/// Destroy a filter entity. +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_destroy(filter_id: u64) { + error::clear_error(); + error::check(|| filter_destroy(Entity::from_bits(filter_id))); +} + // Mouse buttons pub const PROCESSING_MOUSE_LEFT: u8 = 0; pub const PROCESSING_MOUSE_MIDDLE: u8 = 1; diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs index dbff12f..2d4fbbd 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -28,12 +28,14 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { add!(m, CORNER, CORNERS, CENTER, RADIUS); add!(m, OPEN, CHORD, PIE, CLOSE); - // Filters. Values are the single source of truth in `crate::filter`. m.add("INVERT", crate::filter::INVERT_U8)?; m.add("GRAY", crate::filter::GRAY_U8)?; m.add("THRESHOLD", crate::filter::THRESHOLD_U8)?; m.add("POSTERIZE", crate::filter::POSTERIZE_U8)?; + m.add("BLUR", crate::filter::BLUR_U8)?; m.add("OPAQUE", crate::filter::OPAQUE_U8)?; + m.add("ERODE", crate::filter::ERODE_U8)?; + m.add("DILATE", crate::filter::DILATE_U8)?; add!(m, LEFT, RIGHT); add!(m, NEAREST, CLAMP, REPEAT, MIRROR); diff --git a/crates/processing_pyo3/src/filter.rs b/crates/processing_pyo3/src/filter.rs index cec081d..c59aeae 100644 --- a/crates/processing_pyo3/src/filter.rs +++ b/crates/processing_pyo3/src/filter.rs @@ -1,44 +1,99 @@ +use bevy::prelude::Entity; use processing::prelude::*; use pyo3::{ - exceptions::{PyTypeError, PyValueError}, + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, prelude::*, types::{PyDict, PyTuple}, }; +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; -// Must match the `#[pymodule_export] const` values in lib.rs. pub const INVERT_U8: u8 = 0; pub const GRAY_U8: u8 = 1; pub const THRESHOLD_U8: u8 = 2; pub const POSTERIZE_U8: u8 = 3; +pub const BLUR_U8: u8 = 4; pub const OPAQUE_U8: u8 = 5; +pub const ERODE_U8: u8 = 6; +pub const DILATE_U8: u8 = 7; + +fn resolve_user_filter( + shader_entity: Entity, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, +) -> PyResult { + if !args.is_empty() { + return Err(PyValueError::new_err( + "filter(shader): parameters must be passed as keyword arguments", + )); + } + let entity = + filter_create(shader_entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + if name == "passes" { + let passes: u32 = value.extract()?; + filter_set_passes(entity, passes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + continue; + } + let sv = py_to_shader_value(&value)?; + filter_set(entity, &name, sv).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + } + Ok(entity) +} -pub fn parse_filter_op( +fn create_builtin(kind: u8) -> PyResult { + let result = match kind { + INVERT_U8 => filter_invert(), + GRAY_U8 => filter_gray(), + THRESHOLD_U8 => filter_threshold(), + POSTERIZE_U8 => filter_posterize(), + BLUR_U8 => filter_blur(), + OPAQUE_U8 => filter_opaque(), + ERODE_U8 => filter_erode(), + DILATE_U8 => filter_dilate(), + n => { + return Err(PyValueError::new_err(format!( + "filter(): unknown or unimplemented filter constant {n}" + ))); + } + }; + result.map_err(|e| PyRuntimeError::new_err(format!("{e}"))) +} + +pub fn resolve_filter( kind: &Bound<'_, PyAny>, args: &Bound<'_, PyTuple>, kwargs: Option<&Bound<'_, PyDict>>, -) -> PyResult { +) -> PyResult { + if let Ok(shader) = kind.extract::>() { + return resolve_user_filter(shader.entity, args, kwargs); + } + let Ok(kind_u8) = kind.extract::() else { return Err(PyTypeError::new_err( - "filter(): first argument must be a filter constant (INVERT, GRAY, ...)", + "filter(): first argument must be a filter constant (INVERT, GRAY, ...) or a Shader", )); }; + let entity = create_builtin(kind_u8)?; + let set = |name: &str, value: ShaderValue| { + filter_set(entity, name, value).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + }; - let kind = match kind_u8 { - INVERT_U8 => { - reject_params(args, kwargs, "INVERT")?; - FilterKind::Invert - } - GRAY_U8 => { - reject_params(args, kwargs, "GRAY")?; - FilterKind::Gray - } - OPAQUE_U8 => { - reject_params(args, kwargs, "OPAQUE")?; - FilterKind::Opaque - } + match kind_u8 { + INVERT_U8 => reject_params(args, kwargs, "INVERT")?, + GRAY_U8 => reject_params(args, kwargs, "GRAY")?, + OPAQUE_U8 => reject_params(args, kwargs, "OPAQUE")?, + ERODE_U8 => reject_params(args, kwargs, "ERODE")?, + DILATE_U8 => reject_params(args, kwargs, "DILATE")?, THRESHOLD_U8 => { let cutoff = parse_scalar(args, kwargs, "THRESHOLD", "cutoff", Some(0.5))?; - FilterKind::Threshold { cutoff } + set("cutoff", ShaderValue::Float(cutoff))?; } POSTERIZE_U8 => { let levels_f = parse_scalar(args, kwargs, "POSTERIZE", "levels", None)?; @@ -47,18 +102,16 @@ pub fn parse_filter_op( "filter(POSTERIZE, levels): levels must be an integer in 2..=255", )); } - FilterKind::Posterize { - levels: levels_f as u32, - } + set("levels", ShaderValue::UInt(levels_f as u32))?; } - n => { - return Err(PyValueError::new_err(format!( - "filter(): unknown or unimplemented filter constant {n}" - ))); + BLUR_U8 => { + let radius = parse_scalar(args, kwargs, "BLUR", "radius", Some(1.0))?; + set("radius", ShaderValue::Float(radius))?; } - }; + _ => {} + } - Ok(FilterOp::new(kind)) + Ok(entity) } fn reject_params( diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index d5a8346..171d160 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -645,8 +645,9 @@ impl Graphics { args: &Bound<'_, PyTuple>, kwargs: Option<&Bound<'_, PyDict>>, ) -> PyResult<()> { - let op = crate::filter::parse_filter_op(&kind, args, kwargs)?; - graphics_apply_filter(self.entity, op).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + let filter = crate::filter::resolve_filter(&kind, args, kwargs)?; + graphics_apply_filter(self.entity, filter) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[pyo3(signature = (*args))] diff --git a/crates/processing_render/shaders/processing/filter.wesl b/crates/processing_render/shaders/processing/filter.wesl new file mode 100644 index 0000000..b254d62 --- /dev/null +++ b/crates/processing_render/shaders/processing/filter.wesl @@ -0,0 +1,27 @@ +struct ProcessingFilter { + resolution: vec2, + // 1.0 / resolution — one-texel step for neighbor sampling. + texel_size: vec2, + // Pass index (0-based) and total count. + pass_index: u32, + pass_count: u32, + _pad: vec2, +} + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; +@group(0) @binding(2) var filter: ProcessingFilter; + +struct FullscreenVertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +fn sample(uv: vec2) -> vec4 { + return textureSample(screen_texture, texture_sampler, uv); +} + +fn resolution() -> vec2 { return filter.resolution; } +fn texel_size() -> vec2 { return filter.texel_size; } +fn pass_index() -> u32 { return filter.pass_index; } +fn pass_count() -> u32 { return filter.pass_count; } diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 7ab5417..22518d3 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -1,7 +1,6 @@ use std::collections::BTreeSet; use bevy::asset::RenderAssetUsages; -use bevy::reflect::PartialReflect; use bevy::{ prelude::*, render::{ @@ -20,9 +19,7 @@ use bevy::{ use bevy_naga_reflect::dynamic_shader::DynamicShader; -use crate::image::Image as PImage; -use crate::material::custom::{Shader, apply_reflect_field, shader_value_to_reflect}; -use crate::shader_value::ShaderValue; +use crate::material::custom::Shader; use processing_core::error::{ProcessingError, Result}; pub struct ComputePlugin; @@ -265,69 +262,6 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { Err(ProcessingError::PipelineNotReady(MAX_WAIT)) } -pub fn set_compute_property( - In((entity, name, value)): In<(Entity, String, ShaderValue)>, - mut computes: Query<&mut Compute>, - mut p_buffers: Query<&mut Buffer>, - p_images: Query<&PImage>, -) -> Result<()> { - use bevy_naga_reflect::reflect::ParameterCategory; - - let mut compute = computes - .get_mut(entity) - .map_err(|_| ProcessingError::ComputeNotFound)?; - - // resources need a category check; scalars/vectors fall through to apply_reflect_field - match value { - ShaderValue::Buffer(buf_entity) => { - let category = compute - .shader - .reflection() - .parameter(&name) - .map(|p| p.category()) - .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - let ParameterCategory::Storage { read_only } = category else { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {category:?}, got Buffer", - ))); - }; - let mut buffer = p_buffers - .get_mut(buf_entity) - .map_err(|_| ProcessingError::BufferNotFound)?; - compute.shader.insert(&name, buffer.handle.clone()); - if !read_only { - buffer.bound_rw = true; - } - Ok(()) - } - ShaderValue::Texture(img_entity) => { - let category = compute - .shader - .reflection() - .parameter(&name) - .map(|p| p.category()) - .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - if !matches!( - category, - ParameterCategory::Texture | ParameterCategory::StorageTexture - ) { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {category:?}, got Texture", - ))); - } - let image = p_images - .get(img_entity) - .map_err(|_| ProcessingError::ImageNotFound)?; - compute.shader.insert(&name, image.handle.clone()); - Ok(()) - } - v => { - let reflect_value: Box = shader_value_to_reflect(&v)?; - apply_reflect_field(&mut compute.shader, &name, &*reflect_value) - } - } -} - pub fn dispatch( In((pipeline_id, layout_descriptors, shader, x, y, z)): In<( CachedComputePipelineId, diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index bd4c50e..5ebae48 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -31,7 +31,7 @@ use crate::{ render::{ BATCH_INDEX_STEP, RenderState, command::{CommandBuffer, DrawCommand}, - filter::{self, FilterOp}, + filter, }, surface::Surface, }; @@ -496,22 +496,8 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { Ok(()) } -pub fn apply_filter(app: &mut App, entity: Entity, op: FilterOp) -> Result<()> { - { - let mut e = graphics_mut!(app, entity); - e.insert(Flush); - filter::attach(&mut e, op); - } - app.update(); - { - let mut e = graphics_mut!(app, entity); - e.remove::(); - filter::detach(&mut e); - } - app.world_mut() - .run_system_cached(update_view_targets) - .expect("Failed to run update_view_targets"); - Ok(()) +pub fn apply_filter(app: &mut App, graphics: Entity, filter: Entity) -> Result<()> { + filter::apply(app, graphics, filter) } pub fn present(app: &mut App, entity: Entity) -> Result<()> { diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index fb27555..5ee51ec 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -12,6 +12,7 @@ pub mod material; pub mod monitor; pub mod particles; pub mod render; +pub mod shader_property; pub mod shader_value; pub mod sketch; pub mod surface; @@ -481,11 +482,86 @@ pub fn graphics_end_draw(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::end_draw(app, graphics_entity)) } -pub fn graphics_apply_filter( - graphics_entity: Entity, - op: render::filter::FilterOp, +/// Apply a filter to a graphics canvas. +pub fn graphics_apply_filter(graphics_entity: Entity, filter_entity: Entity) -> error::Result<()> { + app_mut(|app| graphics::apply_filter(app, graphics_entity, filter_entity)) +} + +pub fn filter_create(shader_entity: Entity) -> error::Result { + app_mut(|app| render::filter::create(app, shader_entity)) +} + +pub fn filter_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, ) -> error::Result<()> { - app_mut(|app| graphics::apply_filter(app, graphics_entity, op)) + shader_set(entity, name, value) +} + +pub fn filter_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(render::filter::destroy, entity) + .unwrap() + }) +} + +fn builtin_filter(source: &'static str, passes: u32) -> error::Result { + app_mut(|app| { + if let Some(&filter) = app + .world() + .resource::() + .builtins + .get(source) + { + return Ok(filter); + } + let shader = app + .world_mut() + .run_system_cached_with(material::custom::create_shader, source.to_string()) + .unwrap()?; + let filter = render::filter::create(app, shader)?; + if passes != 1 { + app.world_mut() + .run_system_cached_with(render::filter::set_passes, (filter, passes)) + .unwrap()?; + } + app.world_mut() + .resource_mut::() + .builtins + .insert(source, filter); + Ok(filter) + }) +} + +macro_rules! builtin_filter { + ($name:ident, $src:ident) => { + #[doc = concat!("The built-in `", stringify!($src), "` filter.")] + pub fn $name() -> error::Result { + builtin_filter(render::filter::builtin::$src, 1) + } + }; +} + +builtin_filter!(filter_invert, INVERT); +builtin_filter!(filter_gray, GRAY); +builtin_filter!(filter_threshold, THRESHOLD); +builtin_filter!(filter_posterize, POSTERIZE); +builtin_filter!(filter_opaque, OPAQUE); +builtin_filter!(filter_erode, ERODE); +builtin_filter!(filter_dilate, DILATE); + +pub fn filter_set_passes(entity: Entity, passes: u32) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(render::filter::set_passes, (entity, passes)) + .unwrap() + }) +} + +pub fn filter_blur() -> error::Result { + builtin_filter(render::filter::builtin::BLUR, 2) } /// Destroy the graphics surface and free its resources. @@ -2115,18 +2191,37 @@ pub fn compute_create(shader_entity: Entity) -> error::Result { app_mut(|app| compute::create_compute(app, shader_entity)) } -pub fn compute_set( +pub fn shader_set( entity: Entity, name: impl Into, value: shader_value::ShaderValue, ) -> error::Result<()> { + let name = name.into(); app_mut(|app| { + let handled = app + .world_mut() + .run_system_cached_with( + shader_property::set_property, + (entity, name.clone(), value.clone()), + ) + .unwrap()?; + if handled { + return Ok(()); + } app.world_mut() - .run_system_cached_with(compute::set_compute_property, (entity, name.into(), value)) + .run_system_cached_with(material::set_property, (entity, name, value)) .unwrap() }) } +pub fn compute_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + shader_set(entity, name, value) +} + pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { app_mut(|app| { app.update(); diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 11360b3..58e3fbd 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -136,7 +136,7 @@ impl wesl::Resolver for ProcessingResolver<'_> { } } -fn compile_shader(source: &str) -> Result<(String, naga::Module)> { +pub(crate) fn compile_shader(source: &str) -> Result<(String, naga::Module)> { let mut pkg_resolver = PkgResolver::new(); pkg_resolver.add_package(&processing::PACKAGE); pkg_resolver.add_package(&lygia::PACKAGE); diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs index f9190a5..1c4ff8a 100644 --- a/crates/processing_render/src/render/filter.rs +++ b/crates/processing_render/src/render/filter.rs @@ -1,177 +1,418 @@ -//! Full-screen post-process filters (Processing 4 / p5.js `filter()`). +//! A [`Filter`] wraps a shader as a fullscreen pass. + +use std::collections::{BTreeSet, HashMap}; use bevy::{ - asset::embedded_asset, - core_pipeline::fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin}, - ecs::component::Component, - math::Vec4, + core_pipeline::FullscreenShader, prelude::*, render::{ - Render, RenderApp, RenderSystems, - extract_component::{DynamicUniformIndex, ExtractComponent}, - render_resource::ShaderType, + RenderApp, RenderStartup, + render_asset::RenderAssets, + render_resource::{ + BindGroupEntry, BindGroupLayoutDescriptor, CachedPipelineState, CachedRenderPipelineId, + ColorTargetState, ColorWrites, CommandEncoderDescriptor, FragmentState, LoadOp, + Operations, PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, + RenderPipelineDescriptor, Sampler, SamplerDescriptor, StoreOp, TextureFormat, + }, + renderer::{RenderDevice, RenderQueue}, + storage::GpuShaderBuffer, + sync_world::MainEntity, + texture::GpuImage, + view::ViewTarget, }, - shader::ShaderRef, + shader::Shader as ShaderAsset, }; +use bevy_naga_reflect::dynamic_shader::DynamicShader; +use crate::material::custom::{Shader, apply_reflect_field, find_param_containing_field}; +use processing_core::error::{ProcessingError, Result}; -#[derive(Debug, Clone, Copy)] -pub enum FilterKind { - Invert, - Gray, - Threshold { cutoff: f32 }, - Posterize { levels: u32 }, - Opaque, -} +const INPUT_TEXTURE: &str = "screen_texture"; +const INPUT_SAMPLER: &str = "texture_sampler"; +const F_RESOLUTION: &str = "resolution"; +const F_TEXEL_SIZE: &str = "texel_size"; +const F_PASS_INDEX: &str = "pass_index"; +const F_PASS_COUNT: &str = "pass_count"; -#[derive(Debug, Clone, Copy)] -pub struct FilterOp { - pub kind: FilterKind, +pub mod builtin { + pub const INVERT: &str = include_str!("filters/invert.wgsl"); + pub const GRAY: &str = include_str!("filters/gray.wgsl"); + pub const THRESHOLD: &str = include_str!("filters/threshold.wgsl"); + pub const POSTERIZE: &str = include_str!("filters/posterize.wgsl"); + pub const OPAQUE: &str = include_str!("filters/opaque.wgsl"); + pub const ERODE: &str = include_str!("filters/erode.wgsl"); + pub const DILATE: &str = include_str!("filters/dilate.wgsl"); + pub const BLUR: &str = include_str!("filters/blur.wgsl"); } -impl FilterOp { - pub fn new(kind: FilterKind) -> Self { - Self { kind } - } +#[derive(Component)] +pub struct Filter { + pub shader: DynamicShader, + pub entry_point: String, + pub handle: Handle, + pub layouts: Vec<(u32, BindGroupLayoutDescriptor)>, + pub passes: u32, + pub pipelines: HashMap, } -#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] -pub struct InvertFilter { - _padding: Vec4, +#[derive(Resource)] +struct FilterSampler(Sampler); + +#[derive(Resource, Default)] +pub struct FilterRegistry { + /// User filters, keyed by the shader entity they wrap. + pub(crate) by_shader: HashMap, + /// Built-in filters, keyed by their shader source. + pub(crate) builtins: HashMap<&'static str, Entity>, } -impl FullscreenMaterial for InvertFilter { - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/render/filters/invert.wgsl".into() +pub struct FilterPlugin; + +impl Plugin for FilterPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.add_systems(RenderStartup, init_filter_sampler); + } } } -#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] -pub struct GrayFilter { - _padding: Vec4, +fn init_filter_sampler(mut commands: Commands, render_device: Res) { + let sampler = render_device.create_sampler(&SamplerDescriptor::default()); + commands.insert_resource(FilterSampler(sampler)); } -impl FullscreenMaterial for GrayFilter { - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/render/filters/gray.wgsl".into() +pub fn create(app: &mut App, shader_entity: Entity) -> Result { + if let Some(&filter) = app + .world() + .resource::() + .by_shader + .get(&shader_entity) + { + return Ok(filter); } + + let (module, handle) = { + let program = app + .world() + .get::(shader_entity) + .ok_or(ProcessingError::ShaderNotFound)?; + (program.module.clone(), program.shader_handle.clone()) + }; + + let entry_point = module + .entry_points + .iter() + .find(|e| e.stage == naga::ShaderStage::Fragment) + .map(|e| e.name.clone()) + .ok_or_else(|| { + ProcessingError::ShaderCompilationError("filter shader has no fragment entry".into()) + })?; + + let mut shader = DynamicShader::new(module) + .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; + shader.init(); + let layouts = reflected_layouts(&shader); + + let filter = app + .world_mut() + .spawn(Filter { + shader, + entry_point, + handle, + layouts, + passes: 1, + pipelines: HashMap::new(), + }) + .id(); + app.world_mut() + .resource_mut::() + .by_shader + .insert(shader_entity, filter); + Ok(filter) } -#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] -pub struct ThresholdFilter { - pub cutoff: f32, - _p0: f32, - _p1: f32, - _p2: f32, +pub fn set_passes( + In((entity, passes)): In<(Entity, u32)>, + mut filters: Query<&mut Filter>, +) -> Result<()> { + let mut filter = filters + .get_mut(entity) + .map_err(|_| ProcessingError::FilterNotFound)?; + filter.passes = passes.max(1); + Ok(()) } -impl FullscreenMaterial for ThresholdFilter { - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/render/filters/threshold.wgsl".into() +pub fn apply(app: &mut App, graphics: Entity, filter: Entity) -> Result<()> { + let (shader, handle, entry, layouts, passes) = { + let f = app + .world() + .get::(filter) + .ok_or(ProcessingError::FilterNotFound)?; + ( + f.shader.clone(), + f.handle.clone(), + f.entry_point.clone(), + f.layouts.clone(), + f.passes, + ) + }; + + // flush() also extracts the shader asset to the render world. + crate::graphics::flush(app, graphics)?; + + // The pipeline must target the view target's actual texture format, which + // bevy may transform from the requested format (HDR, sRGB compositing). + let (format, size) = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(target_info, graphics) + .unwrap()?; + + let pipeline_id = match app + .world() + .get::(filter) + .and_then(|f| f.pipelines.get(&format).copied()) + { + Some(id) => id, + None => { + let id = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(queue_pipeline, (handle, entry, layouts.clone(), format)) + .unwrap(); + if let Some(mut f) = app.world_mut().get_mut::(filter) { + f.pipelines.insert(format, id); + } + id + } + }; + + const MAX_WAIT: u32 = 64; + let mut ready = false; + for _ in 0..MAX_WAIT { + ready = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(pump_pipeline, pipeline_id) + .unwrap()?; + if ready { + break; + } + } + if !ready { + return Err(ProcessingError::PipelineNotReady(MAX_WAIT)); } -} -#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] -pub struct PosterizeFilter { - pub levels: u32, - _p0: u32, - _p1: u32, - _p2: u32, + app.sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with( + run_pass, + (graphics, shader, layouts, pipeline_id, size, passes), + ) + .unwrap() } -impl FullscreenMaterial for PosterizeFilter { - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/render/filters/posterize.wgsl".into() - } +pub fn destroy( + In(entity): In, + mut commands: Commands, + mut registry: ResMut, +) -> Result<()> { + registry.by_shader.retain(|_, &mut f| f != entity); + registry.builtins.retain(|_, &mut f| f != entity); + commands.entity(entity).despawn(); + Ok(()) } -#[derive(Component, Clone, Copy, Default, ExtractComponent, ShaderType)] -pub struct OpaqueFilter { - _padding: Vec4, +fn reflected_layouts(shader: &DynamicShader) -> Vec<(u32, BindGroupLayoutDescriptor)> { + let reflection = shader.reflection(); + let groups: BTreeSet = reflection.parameters().map(|p| p.group()).collect(); + groups + .into_iter() + .map(|group| { + ( + group, + BindGroupLayoutDescriptor { + label: "processing_filter_layout".into(), + entries: reflection.bind_group_layout(group), + }, + ) + }) + .collect() } -impl FullscreenMaterial for OpaqueFilter { - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/render/filters/opaque.wgsl".into() +fn target_info( + In(entity): In, + targets: Query<(&MainEntity, &ViewTarget)>, +) -> Result<(TextureFormat, UVec2)> { + for (main_entity, vt) in targets.iter() { + if **main_entity == entity { + let size = vt.main_texture().size(); + return Ok(( + vt.main_texture_format(), + UVec2::new(size.width, size.height), + )); + } } + Err(ProcessingError::GraphicsNotFound) } -pub struct FilterPlugin; - -impl Plugin for FilterPlugin { - fn build(&self, app: &mut App) { - embedded_asset!(app, "filters/invert.wgsl"); - embedded_asset!(app, "filters/gray.wgsl"); - embedded_asset!(app, "filters/threshold.wgsl"); - embedded_asset!(app, "filters/posterize.wgsl"); - embedded_asset!(app, "filters/opaque.wgsl"); - - app.add_plugins(( - FullscreenMaterialPlugin::::default(), - FullscreenMaterialPlugin::::default(), - FullscreenMaterialPlugin::::default(), - FullscreenMaterialPlugin::::default(), - FullscreenMaterialPlugin::::default(), - )); - - // Bevy's `UniformComponentPlugin` doesn't clear `DynamicUniformIndex` - // when `T` is removed. The fullscreen-material system queries on that - // index, so without this cleanup the filter keeps running after detach. - if let Some(render_app) = app.get_sub_app_mut(RenderApp) { - render_app.add_systems( - Render, - ( - clear_stale_uniform_index::, - clear_stale_uniform_index::, - clear_stale_uniform_index::, - clear_stale_uniform_index::, - clear_stale_uniform_index::, - ) - .in_set(RenderSystems::PrepareResources), - ); - } +fn queue_pipeline( + In((handle, entry, layouts, format)): In<( + Handle, + String, + Vec<(u32, BindGroupLayoutDescriptor)>, + TextureFormat, + )>, + pipeline_cache: Res, + fullscreen: Res, +) -> CachedRenderPipelineId { + let max_group = layouts.iter().map(|(g, _)| *g).max().map_or(0, |g| g + 1); + let mut layout = vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + for (group, desc) in &layouts { + layout[*group as usize] = desc.clone(); } + + let descriptor = RenderPipelineDescriptor { + label: Some("processing_filter_pipeline".into()), + layout, + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: handle, + entry_point: Some(entry.into()), + targets: vec![Some(ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + ..default() + }), + ..default() + }; + pipeline_cache.queue_render_pipeline(descriptor) } -fn clear_stale_uniform_index( - mut commands: Commands, - stale: Query>, Without)>, -) { - for e in &stale { - commands.entity(e).remove::>(); +fn pump_pipeline( + In(id): In, + mut pipeline_cache: ResMut, +) -> Result { + pipeline_cache.process_queue(); + match pipeline_cache.get_render_pipeline_state(id) { + CachedPipelineState::Ok(_) => Ok(true), + CachedPipelineState::Err(e) => Err(ProcessingError::PipelineCompileError(format!("{e}"))), + _ => Ok(false), } } -pub fn attach(entity_commands: &mut EntityWorldMut<'_>, op: FilterOp) { - match op.kind { - FilterKind::Invert => { - entity_commands.insert(InvertFilter::default()); +#[allow(clippy::too_many_arguments)] +fn run_pass( + In((entity, mut shader, layouts, pipeline_id, size, passes)): In<( + Entity, + DynamicShader, + Vec<(u32, BindGroupLayoutDescriptor)>, + CachedRenderPipelineId, + UVec2, + u32, + )>, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + gpu_buffers: Res>, + filter_sampler: Res, + targets: Query<(&MainEntity, &ViewTarget)>, +) -> Result<()> { + let pipeline = pipeline_cache + .get_render_pipeline(pipeline_id) + .ok_or(ProcessingError::PipelineNotReady(0))?; + + let mut view_target = None; + for (main_entity, vt) in targets.iter() { + if **main_entity == entity { + view_target = Some(vt); + break; } - FilterKind::Gray => { - entity_commands.insert(GrayFilter::default()); + } + let view_target = view_target.ok_or(ProcessingError::GraphicsNotFound)?; + + // Fill the engine-owned `filter` uniform (only the fields the shader uses). + let resolution = Vec2::new(size.x as f32, size.y as f32); + let texel = Vec2::new(1.0 / resolution.x, 1.0 / resolution.y); + fill_system(&mut shader, F_RESOLUTION, &resolution); + fill_system(&mut shader, F_TEXEL_SIZE, &texel); + fill_system(&mut shader, F_PASS_COUNT, &passes); + let has_pass_index = find_param_containing_field(&shader, F_PASS_INDEX).is_some(); + + for pass in 0..passes { + if has_pass_index { + let _ = apply_reflect_field(&mut shader, F_PASS_INDEX, &pass); } - FilterKind::Threshold { cutoff } => { - entity_commands.insert(ThresholdFilter { - cutoff, - ..default() - }); + + let post_process = view_target.post_process_write(); + shader.insert_texture_view(INPUT_TEXTURE, post_process.source.clone()); + shader.insert_sampler(INPUT_SAMPLER, filter_sampler.0.clone()); + + let reflection = shader.reflection(); + let mut bind_groups = Vec::new(); + for (group, desc) in &layouts { + let layout = pipeline_cache.get_bind_group_layout(desc); + let bindings = reflection.create_bindings( + *group, + &shader, + &render_device, + &gpu_images, + &gpu_buffers, + ); + let entries: Vec = bindings + .iter() + .map(|(binding, resource)| BindGroupEntry { + binding: *binding, + resource: resource.get_binding(), + }) + .collect(); + bind_groups.push(( + *group, + render_device.create_bind_group( + Some("processing_filter_bind_group"), + &layout, + &entries, + ), + )); } - FilterKind::Posterize { levels } => { - entity_commands.insert(PosterizeFilter { - levels, - ..default() + + let mut encoder = + render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + { + let mut render = encoder.begin_render_pass(&RenderPassDescriptor { + label: Some("processing_filter_pass"), + color_attachments: &[Some(RenderPassColorAttachment { + view: post_process.destination, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Load, + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + multiview_mask: None, + timestamp_writes: None, + occlusion_query_set: None, }); + render.set_pipeline(pipeline); + for (group, bind_group) in &bind_groups { + render.set_bind_group(*group, bind_group, &[]); + } + render.draw(0..3, 0..1); } - FilterKind::Opaque => { - entity_commands.insert(OpaqueFilter::default()); - } + render_queue.submit(std::iter::once(encoder.finish())); } + + Ok(()) } -pub fn detach(entity_commands: &mut EntityWorldMut<'_>) { - entity_commands.remove::(); - entity_commands.remove::(); - entity_commands.remove::(); - entity_commands.remove::(); - entity_commands.remove::(); +fn fill_system(shader: &mut DynamicShader, name: &str, value: &dyn PartialReflect) { + if find_param_containing_field(shader, name).is_some() { + let _ = apply_reflect_field(shader, name, value); + } } diff --git a/crates/processing_render/src/render/filters/blur.wgsl b/crates/processing_render/src/render/filters/blur.wgsl new file mode 100644 index 0000000..b0b17a9 --- /dev/null +++ b/crates/processing_render/src/render/filters/blur.wgsl @@ -0,0 +1,29 @@ +import processing::filter::{sample, texel_size, pass_index, FullscreenVertexOutput}; + +struct Params { + radius: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let dir = select(vec2(0.0, t.y), vec2(t.x, 0.0), pass_index() == 0u); + + let radius = max(params.radius, 1.0); + let sigma = radius * 0.5 + 0.5; + let r = i32(radius); + + var sum = vec4(0.0); + var weight_sum = 0.0; + for (var i = -r; i <= r; i++) { + let x = f32(i); + let w = exp(-(x * x) / (2.0 * sigma * sigma)); + sum += sample(in.uv + dir * x) * w; + weight_sum += w; + } + return sum / weight_sum; +} diff --git a/crates/processing_render/src/render/filters/dilate.wgsl b/crates/processing_render/src/render/filters/dilate.wgsl new file mode 100644 index 0000000..739b952 --- /dev/null +++ b/crates/processing_render/src/render/filters/dilate.wgsl @@ -0,0 +1,27 @@ +import processing::filter::{sample, texel_size, FullscreenVertexOutput}; + +fn luma(c: vec3) -> f32 { + return 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let offsets = array, 4>( + vec2(-t.x, 0.0), + vec2(t.x, 0.0), + vec2(0.0, -t.y), + vec2(0.0, t.y), + ); + var best = sample(in.uv); + var best_luma = luma(best.rgb); + for (var i = 0; i < 4; i++) { + let s = sample(in.uv + offsets[i]); + let sl = luma(s.rgb); + if sl > best_luma { + best = s; + best_luma = sl; + } + } + return best; +} diff --git a/crates/processing_render/src/render/filters/erode.wgsl b/crates/processing_render/src/render/filters/erode.wgsl new file mode 100644 index 0000000..abbabd2 --- /dev/null +++ b/crates/processing_render/src/render/filters/erode.wgsl @@ -0,0 +1,27 @@ +import processing::filter::{sample, texel_size, FullscreenVertexOutput}; + +fn luma(c: vec3) -> f32 { + return 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let offsets = array, 4>( + vec2(-t.x, 0.0), + vec2(t.x, 0.0), + vec2(0.0, -t.y), + vec2(0.0, t.y), + ); + var best = sample(in.uv); + var best_luma = luma(best.rgb); + for (var i = 0; i < 4; i++) { + let s = sample(in.uv + offsets[i]); + let sl = luma(s.rgb); + if sl < best_luma { + best = s; + best_luma = sl; + } + } + return best; +} diff --git a/crates/processing_render/src/render/filters/gray.wgsl b/crates/processing_render/src/render/filters/gray.wgsl index 3c878c7..ad3c1f2 100644 --- a/crates/processing_render/src/render/filters/gray.wgsl +++ b/crates/processing_render/src/render/filters/gray.wgsl @@ -1,17 +1,8 @@ -#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput - -@group(0) @binding(0) var screen_texture: texture_2d; -@group(0) @binding(1) var texture_sampler: sampler; - -struct Settings { - _padding: vec4, -} -@group(0) @binding(2) var settings: Settings; +import processing::filter::{sample, FullscreenVertexOutput}; @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let c = textureSample(screen_texture, texture_sampler, in.uv); - // Processing 4 weights (PImage.java:990): 77/256, 151/256, 28/256 + let c = sample(in.uv); let luma = 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; return vec4(luma, luma, luma, c.a); } diff --git a/crates/processing_render/src/render/filters/invert.wgsl b/crates/processing_render/src/render/filters/invert.wgsl index b0c6425..2fd08dc 100644 --- a/crates/processing_render/src/render/filters/invert.wgsl +++ b/crates/processing_render/src/render/filters/invert.wgsl @@ -1,15 +1,7 @@ -#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput - -@group(0) @binding(0) var screen_texture: texture_2d; -@group(0) @binding(1) var texture_sampler: sampler; - -struct Settings { - _padding: vec4, -} -@group(0) @binding(2) var settings: Settings; +import processing::filter::{sample, FullscreenVertexOutput}; @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let c = textureSample(screen_texture, texture_sampler, in.uv); + let c = sample(in.uv); return vec4(1.0 - c.r, 1.0 - c.g, 1.0 - c.b, c.a); } diff --git a/crates/processing_render/src/render/filters/opaque.wgsl b/crates/processing_render/src/render/filters/opaque.wgsl index e46345a..34ffb3f 100644 --- a/crates/processing_render/src/render/filters/opaque.wgsl +++ b/crates/processing_render/src/render/filters/opaque.wgsl @@ -1,15 +1,7 @@ -#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput - -@group(0) @binding(0) var screen_texture: texture_2d; -@group(0) @binding(1) var texture_sampler: sampler; - -struct Settings { - _padding: vec4, -} -@group(0) @binding(2) var settings: Settings; +import processing::filter::{sample, FullscreenVertexOutput}; @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let c = textureSample(screen_texture, texture_sampler, in.uv); + let c = sample(in.uv); return vec4(c.rgb, 1.0); } diff --git a/crates/processing_render/src/render/filters/posterize.wgsl b/crates/processing_render/src/render/filters/posterize.wgsl index 12945cb..9270170 100644 --- a/crates/processing_render/src/render/filters/posterize.wgsl +++ b/crates/processing_render/src/render/filters/posterize.wgsl @@ -1,20 +1,17 @@ -#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +import processing::filter::{sample, FullscreenVertexOutput}; -@group(0) @binding(0) var screen_texture: texture_2d; -@group(0) @binding(1) var texture_sampler: sampler; - -struct Settings { +struct Params { levels: u32, _p0: u32, _p1: u32, _p2: u32, } -@group(0) @binding(2) var settings: Settings; +@group(1) @binding(0) var params: Params; @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let c = textureSample(screen_texture, texture_sampler, in.uv); - let n = f32(max(settings.levels, 2u)); + let c = sample(in.uv); + let n = f32(max(params.levels, 2u)); let q = floor(c.rgb * n) / (n - 1.0); return vec4(clamp(q, vec3(0.0), vec3(1.0)), c.a); } diff --git a/crates/processing_render/src/render/filters/threshold.wgsl b/crates/processing_render/src/render/filters/threshold.wgsl index 08ff028..ee779d7 100644 --- a/crates/processing_render/src/render/filters/threshold.wgsl +++ b/crates/processing_render/src/render/filters/threshold.wgsl @@ -1,21 +1,17 @@ -#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +import processing::filter::{sample, FullscreenVertexOutput}; -@group(0) @binding(0) var screen_texture: texture_2d; -@group(0) @binding(1) var texture_sampler: sampler; - -struct Settings { +struct Params { cutoff: f32, _p0: f32, _p1: f32, _p2: f32, } -@group(0) @binding(2) var settings: Settings; +@group(1) @binding(0) var params: Params; @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let c = textureSample(screen_texture, texture_sampler, in.uv); - // Processing 4 weights (PImage.java:990): 77/256, 151/256, 28/256 - let luma = 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; - let v = select(0.0, 1.0, luma >= settings.cutoff); + let c = sample(in.uv); + let m = max(c.r, max(c.g, c.b)); + let v = select(0.0, 1.0, m >= params.cutoff); return vec4(v, v, v, c.a); } diff --git a/crates/processing_render/src/shader_property.rs b/crates/processing_render/src/shader_property.rs new file mode 100644 index 0000000..c504730 --- /dev/null +++ b/crates/processing_render/src/shader_property.rs @@ -0,0 +1,83 @@ +use bevy::prelude::*; +use bevy_naga_reflect::dynamic_shader::DynamicShader; +use bevy_naga_reflect::reflect::ParameterCategory; + +use crate::compute::{Buffer, Compute}; +use crate::image::Image as PImage; +use crate::material::custom::{apply_reflect_field, shader_value_to_reflect}; +use crate::render::filter::Filter; +use crate::shader_value::ShaderValue; +use processing_core::error::{ProcessingError, Result}; + +pub(crate) fn apply_shader_value( + shader: &mut DynamicShader, + name: &str, + value: ShaderValue, + p_buffers: &mut Query<&mut Buffer>, + p_images: &Query<&PImage>, +) -> Result<()> { + match value { + ShaderValue::Buffer(buf_entity) => { + let category = shader + .reflection() + .parameter(name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.to_string()))?; + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Buffer", + ))); + }; + let mut buffer = p_buffers + .get_mut(buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + shader.insert(name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; + } + Ok(()) + } + ShaderValue::Texture(img_entity) => { + let category = shader + .reflection() + .parameter(name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.to_string()))?; + if !matches!( + category, + ParameterCategory::Texture | ParameterCategory::StorageTexture + ) { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Texture", + ))); + } + let image = p_images + .get(img_entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + shader.insert(name, image.handle.clone()); + Ok(()) + } + v => { + let reflect_value = shader_value_to_reflect(&v)?; + apply_reflect_field(shader, name, &*reflect_value) + } + } +} + +pub fn set_property( + In((entity, name, value)): In<(Entity, String, ShaderValue)>, + mut computes: Query<&mut Compute>, + mut filters: Query<&mut Filter>, + mut p_buffers: Query<&mut Buffer>, + p_images: Query<&PImage>, +) -> Result { + if let Ok(mut compute) = computes.get_mut(entity) { + apply_shader_value(&mut compute.shader, &name, value, &mut p_buffers, &p_images)?; + return Ok(true); + } + if let Ok(mut filter) = filters.get_mut(entity) { + apply_shader_value(&mut filter.shader, &name, value, &mut p_buffers, &p_images)?; + return Ok(true); + } + Ok(false) +} diff --git a/examples/filter.rs b/examples/filter.rs index 02cba4b..8a323aa 100644 --- a/examples/filter.rs +++ b/examples/filter.rs @@ -1,8 +1,4 @@ -//! Cycles through all implemented filters (2s each) applied to a color palette. -//! -//! Bottom-right blue rect is drawn AFTER the filter, so it should always stay -//! pure blue — that's the Processing 4 immediate-mode semantic: `filter()` bakes -//! into the current canvas; subsequent draws land on top unfiltered. +//! Cycles through all implemented filters. use std::time::Instant; use bevy::color::Color; @@ -10,6 +6,7 @@ use processing_glfw::GlfwContext; use processing::prelude::*; use processing_render::render::command::DrawCommand; +use processing_render::shader_value::ShaderValue; fn main() { match sketch() { @@ -34,12 +31,16 @@ fn sketch() -> error::Result<()> { (40.0, 200.0, Color::srgb(0.2, 0.4, 1.0)), (200.0, 200.0, Color::srgb(0.95, 0.85, 0.2)), ]; + let threshold = filter_threshold()?; + filter_set(threshold, "cutoff", ShaderValue::Float(0.5))?; + let posterize = filter_posterize()?; + filter_set(posterize, "levels", ShaderValue::UInt(4))?; let filters = [ - FilterKind::Invert, - FilterKind::Gray, - FilterKind::Threshold { cutoff: 0.5 }, - FilterKind::Posterize { levels: 4 }, - FilterKind::Opaque, + filter_invert()?, + filter_gray()?, + threshold, + posterize, + filter_opaque()?, ]; let start = Instant::now(); @@ -67,9 +68,8 @@ fn sketch() -> error::Result<()> { } let idx = (start.elapsed().as_secs() / 2) as usize % filters.len(); - graphics_apply_filter(graphics, FilterOp::new(filters[idx]))?; + graphics_apply_filter(graphics, filters[idx])?; - // Drawn AFTER the filter: should always render pure blue regardless of cycle. graphics_record_command(graphics, DrawCommand::Fill(Color::srgb(0.0, 0.0, 1.0)))?; graphics_record_command( graphics, diff --git a/src/prelude.rs b/src/prelude.rs index 934829d..34f95a3 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -14,7 +14,7 @@ pub use processing_render::{ ArcMode, BlendMode, DrawCommand, ShapeKind, ShapeMode, StrokeCapMode, StrokeJoinMode, TextAlignH, TextAlignV, TextStyle, TextWrapMode, custom_blend_state, }, - render::filter::{FilterKind, FilterOp}, + render::filter::Filter, *, }; From 7a472625e9aa3a3e4eab9a49c9f7dd98eaf38b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Wed, 5 Aug 2026 13:47:35 -0700 Subject: [PATCH 3/3] Fixup. --- .../shaders/processing/filter.wesl | 11 ++++--- .../processing_render/src/material/custom.rs | 10 +++---- crates/processing_render/src/render/filter.rs | 29 +++++++++++++++---- examples/filter.rs | 5 ++++ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/processing_render/shaders/processing/filter.wesl b/crates/processing_render/shaders/processing/filter.wesl index b254d62..21d62fb 100644 --- a/crates/processing_render/shaders/processing/filter.wesl +++ b/crates/processing_render/shaders/processing/filter.wesl @@ -5,12 +5,11 @@ struct ProcessingFilter { // Pass index (0-based) and total count. pass_index: u32, pass_count: u32, - _pad: vec2, } @group(0) @binding(0) var screen_texture: texture_2d; @group(0) @binding(1) var texture_sampler: sampler; -@group(0) @binding(2) var filter: ProcessingFilter; +@group(0) @binding(2) var params: ProcessingFilter; struct FullscreenVertexOutput { @builtin(position) position: vec4, @@ -21,7 +20,7 @@ fn sample(uv: vec2) -> vec4 { return textureSample(screen_texture, texture_sampler, uv); } -fn resolution() -> vec2 { return filter.resolution; } -fn texel_size() -> vec2 { return filter.texel_size; } -fn pass_index() -> u32 { return filter.pass_index; } -fn pass_count() -> u32 { return filter.pass_count; } +fn resolution() -> vec2 { return params.resolution; } +fn texel_size() -> vec2 { return params.texel_size; } +fn pass_index() -> u32 { return params.pass_index; } +fn pass_count() -> u32 { return params.pass_count; } diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 58e3fbd..7f895b1 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -120,12 +120,12 @@ impl wesl::Resolver for ProcessingResolver<'_> { match &path.origin { PathOrigin::Package(pkg) => { - // self-referential package imports: within a package, imports to - // the same package stack the name (e.g. "lygia/lygia/lygia/..."). - // collapse to the root package name before resolving. - let root = pkg.split('/').next().unwrap(); + // wesl encodes a cross-package import as a synthetic "parent/child" + // origin (e.g. importing processing from the entry module yields + // "entry/processing"). The real package is the leaf. + let leaf = pkg.rsplit('/').next().unwrap(); let mut fixed = path.clone(); - fixed.origin = PathOrigin::Package(root.to_string()); + fixed.origin = PathOrigin::Package(leaf.to_string()); self.pkg_resolver.resolve_source(&fixed) } _ => Err(wesl::ResolveError::ModuleNotFound( diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs index 1c4ff8a..efc28a6 100644 --- a/crates/processing_render/src/render/filter.rs +++ b/crates/processing_render/src/render/filter.rs @@ -22,13 +22,11 @@ use bevy::{ }, shader::Shader as ShaderAsset, }; -use bevy_naga_reflect::dynamic_shader::DynamicShader; +use bevy_naga_reflect::{dynamic_shader::DynamicShader, reflect::ParameterCategory}; use crate::material::custom::{Shader, apply_reflect_field, find_param_containing_field}; use processing_core::error::{ProcessingError, Result}; -const INPUT_TEXTURE: &str = "screen_texture"; -const INPUT_SAMPLER: &str = "texture_sampler"; const F_RESOLUTION: &str = "resolution"; const F_TEXEL_SIZE: &str = "texel_size"; const F_PASS_INDEX: &str = "pass_index"; @@ -343,14 +341,35 @@ fn run_pass( fill_system(&mut shader, F_PASS_COUNT, &passes); let has_pass_index = find_param_containing_field(&shader, F_PASS_INDEX).is_some(); + // wesl mangles the imported `processing::filter` bindings, so the screen + // texture and sampler are bound by their reflected (mangled) names, found by + // category in group 0. + let (input_texture, input_sampler) = { + let reflection = shader.reflection(); + let mut texture = None; + let mut sampler = None; + for param in reflection.parameters().filter(|p| p.group() == 0) { + match param.category() { + ParameterCategory::Texture => texture = param.name().map(String::from), + ParameterCategory::Sampler => sampler = param.name().map(String::from), + _ => {} + } + } + (texture, sampler) + }; + for pass in 0..passes { if has_pass_index { let _ = apply_reflect_field(&mut shader, F_PASS_INDEX, &pass); } let post_process = view_target.post_process_write(); - shader.insert_texture_view(INPUT_TEXTURE, post_process.source.clone()); - shader.insert_sampler(INPUT_SAMPLER, filter_sampler.0.clone()); + if let Some(name) = &input_texture { + shader.insert_texture_view(name, post_process.source.clone()); + } + if let Some(name) = &input_sampler { + shader.insert_sampler(name, filter_sampler.0.clone()); + } let reflection = shader.reflection(); let mut bind_groups = Vec::new(); diff --git a/examples/filter.rs b/examples/filter.rs index 8a323aa..f0b770d 100644 --- a/examples/filter.rs +++ b/examples/filter.rs @@ -35,12 +35,17 @@ fn sketch() -> error::Result<()> { filter_set(threshold, "cutoff", ShaderValue::Float(0.5))?; let posterize = filter_posterize()?; filter_set(posterize, "levels", ShaderValue::UInt(4))?; + let blur = filter_blur()?; + filter_set(blur, "radius", ShaderValue::Float(4.0))?; let filters = [ filter_invert()?, filter_gray()?, threshold, posterize, filter_opaque()?, + filter_erode()?, + filter_dilate()?, + blur, ]; let start = Instant::now();