diff --git a/Cargo.toml b/Cargo.toml index acc5c3ac..816779d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -264,6 +264,10 @@ 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" diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 32b1e394..41510f34 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 6dbffb06..b81adb1f 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 872c91c9..2d4fbbd6 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -27,6 +27,16 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { ); add!(m, CORNER, CORNERS, CENTER, RADIUS); add!(m, OPEN, CHORD, PIE, CLOSE); + + 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); 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 00000000..c59aeaef --- /dev/null +++ b/crates/processing_pyo3/src/filter.rs @@ -0,0 +1,184 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::{PyDict, PyTuple}, +}; +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; + +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) +} + +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 { + 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, ...) 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}"))) + }; + + 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))?; + set("cutoff", ShaderValue::Float(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", + )); + } + set("levels", ShaderValue::UInt(levels_f as u32))?; + } + BLUR_U8 => { + let radius = parse_scalar(args, kwargs, "BLUR", "radius", Some(1.0))?; + set("radius", ShaderValue::Float(radius))?; + } + _ => {} + } + + Ok(entity) +} + +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 0cad553a..171d1600 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -638,6 +638,18 @@ impl Graphics { } } + #[pyo3(signature = (kind, *args, **kwargs))] + pub fn filter( + &self, + kind: Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + 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))] 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 1a7ac92c..3ce155f5 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/shaders/processing/filter.wesl b/crates/processing_render/shaders/processing/filter.wesl new file mode 100644 index 00000000..21d62fb3 --- /dev/null +++ b/crates/processing_render/shaders/processing/filter.wesl @@ -0,0 +1,26 @@ +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, +} + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; +@group(0) @binding(2) var params: 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 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/compute.rs b/crates/processing_render/src/compute.rs index 7ab5417c..22518d31 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 d8fa2b9a..5ebae48d 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, }, surface::Surface, }; @@ -495,6 +496,10 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { 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<()> { graphics_mut!(app, entity) .get_mut::() diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 1f7f65f6..5ee51ec1 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; @@ -67,6 +68,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 +482,88 @@ pub fn graphics_end_draw(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::end_draw(app, graphics_entity)) } +/// 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<()> { + 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. pub fn graphics_destroy(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| { @@ -2107,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 11360b31..7f895b14 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( @@ -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 new file mode 100644 index 00000000..efc28a6e --- /dev/null +++ b/crates/processing_render/src/render/filter.rs @@ -0,0 +1,437 @@ +//! A [`Filter`] wraps a shader as a fullscreen pass. + +use std::collections::{BTreeSet, HashMap}; + +use bevy::{ + core_pipeline::FullscreenShader, + prelude::*, + render::{ + 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::Shader as ShaderAsset, +}; +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 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"; + +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"); +} + +#[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(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>, +} + +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); + } + } +} + +fn init_filter_sampler(mut commands: Commands, render_device: Res) { + let sampler = render_device.create_sampler(&SamplerDescriptor::default()); + commands.insert_resource(FilterSampler(sampler)); +} + +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) +} + +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(()) +} + +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)); + } + + app.sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with( + run_pass, + (graphics, shader, layouts, pipeline_id, size, passes), + ) + .unwrap() +} + +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(()) +} + +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() +} + +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) +} + +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 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), + } +} + +#[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; + } + } + 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(); + + // 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(); + 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(); + 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, + ), + )); + } + + 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); + } + render_queue.submit(std::iter::once(encoder.finish())); + } + + Ok(()) +} + +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 00000000..b0b17a9b --- /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 00000000..739b9528 --- /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 00000000..abbabd28 --- /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 new file mode 100644 index 00000000..ad3c1f27 --- /dev/null +++ b/crates/processing_render/src/render/filters/gray.wgsl @@ -0,0 +1,8 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + 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 new file mode 100644 index 00000000..2fd08dcd --- /dev/null +++ b/crates/processing_render/src/render/filters/invert.wgsl @@ -0,0 +1,7 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + 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 new file mode 100644 index 00000000..34ffb3f2 --- /dev/null +++ b/crates/processing_render/src/render/filters/opaque.wgsl @@ -0,0 +1,7 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + 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 new file mode 100644 index 00000000..9270170c --- /dev/null +++ b/crates/processing_render/src/render/filters/posterize.wgsl @@ -0,0 +1,17 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +struct Params { + levels: u32, + _p0: u32, + _p1: u32, + _p2: u32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + 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 new file mode 100644 index 00000000..ee779d7d --- /dev/null +++ b/crates/processing_render/src/render/filters/threshold.wgsl @@ -0,0 +1,17 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +struct Params { + cutoff: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + 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/render/mod.rs b/crates/processing_render/src/render/mod.rs index cc173adf..c72a5494 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/crates/processing_render/src/shader_property.rs b/crates/processing_render/src/shader_property.rs new file mode 100644 index 00000000..c5047302 --- /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 new file mode 100644 index 00000000..f0b770d8 --- /dev/null +++ b/examples/filter.rs @@ -0,0 +1,93 @@ +//! Cycles through all implemented filters. +use std::time::Instant; + +use bevy::color::Color; +use processing_glfw::GlfwContext; + +use processing::prelude::*; +use processing_render::render::command::DrawCommand; +use processing_render::shader_value::ShaderValue; + +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 threshold = filter_threshold()?; + 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(); + + 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, filters[idx])?; + + 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 4e72ec99..34f95a3d 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::Filter, *, };