diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index f7ad743c..7edc0b26 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -6,6 +6,8 @@ pub type Result = std::result::Result; pub enum ProcessingError { #[error("App was accessed from multiple threads")] AppAccess, + #[error("Not initialized yet; call size() first")] + AppNotInitialized, #[error("Error initializing tracing: {0}")] Tracing(#[from] tracing::subscriber::SetGlobalDefaultError), #[error("Surface not found")] diff --git a/crates/processing_core/src/lib.rs b/crates/processing_core/src/lib.rs index 29638048..70c3cc72 100644 --- a/crates/processing_core/src/lib.rs +++ b/crates/processing_core/src/lib.rs @@ -24,7 +24,7 @@ pub fn app_mut(cb: impl FnOnce(&mut App) -> error::Result) -> error::Resul .map_err(|_| error::ProcessingError::AppAccess)?; let app = app_borrow .as_mut() - .ok_or(error::ProcessingError::AppAccess)?; + .ok_or(error::ProcessingError::AppNotInitialized)?; cb(app) }); match res { diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index 9f7208fb..528bd729 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -1,3 +1,4 @@ +use bevy::color::ColorToComponents; use bevy::prelude::Entity; use processing::prelude::*; use pyo3::{ @@ -112,7 +113,7 @@ impl Buffer { value: &Bound<'_, PyAny>, ) -> PyResult<()> { if let Ok(i) = index.extract::() { - let sv = py_to_shader_value(value)?; + let sv = buffer_element(value)?; self.check_element_type(&sv)?; let bytes = sv .to_bytes() @@ -246,11 +247,19 @@ fn slice_positions(indices: &PySliceIndices) -> impl Iterator + us (0..slicelength as isize).map(move |i| start + i * step) } +/// A buffer has no color type: a `Color` is stored as its linear RGBA floats. +fn buffer_element(value: &Bound<'_, PyAny>) -> PyResult { + Ok(match py_to_shader_value(value)? { + ShaderValue::Color(c) => ShaderValue::Float4(c.to_linear().to_f32_array()), + sv => sv, + }) +} + fn shader_values_to_bytes(values: &Bound<'_, PyAny>) -> PyResult<(Vec, Option)> { let mut bytes = Vec::new(); let mut element_type: Option = None; for item in values.try_iter()? { - let sv = py_to_shader_value(&item?)?; + let sv = buffer_element(&item?)?; if let Some(ref existing) = element_type && std::mem::discriminant(existing) != std::mem::discriminant(&sv) { @@ -288,6 +297,7 @@ fn shader_value_to_py<'py>(py: Python<'py>, sv: &ShaderValue) -> PyResult list(py, v), ShaderValue::UInt4(v) => list(py, v), ShaderValue::Mat4(v) => list(py, v), + ShaderValue::Color(c) => Ok(crate::color::PyColor(*c).into_pyobject(py)?.into_any()), ShaderValue::Texture(_) | ShaderValue::Buffer(_) | ShaderValue::MeshAttribute(..) diff --git a/crates/processing_pyo3/src/gltf.rs b/crates/processing_pyo3/src/gltf.rs index 081c213d..05b29090 100644 --- a/crates/processing_pyo3/src/gltf.rs +++ b/crates/processing_pyo3/src/gltf.rs @@ -2,7 +2,7 @@ use bevy::prelude::Entity; use processing::prelude::*; use pyo3::{exceptions::PyRuntimeError, prelude::*}; -use crate::graphics::{Geometry, Light}; +use crate::graphics::{Geometry, Image, Light}; use crate::material::Material; #[pyclass(unsendable)] @@ -35,6 +35,13 @@ impl Gltf { Ok(Material { entity }) } + /// The texture on the named mesh, e.g. for `LOOKUP`. + pub fn texture(&self, name: &str) -> PyResult { + let entity = + gltf_texture(self.entity, name).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Image::wrap(entity)) + } + pub fn mesh_names(&self) -> PyResult> { gltf_mesh_names(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 6a65ed18..dc97ab68 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -43,7 +43,7 @@ use pyo3::{ types::{PyDict, PyList, PyTuple}, }; use shader::Shader; -use std::ffi::{CStr, CString}; +use std::ffi::CString; /// Register a window `Graphics` in the module's `_windows` list so the run loop /// draws + presents it each frame. @@ -349,7 +349,11 @@ fn create_graphics_context( let env = detect_environment(module)?; let interactive = env != "script"; - let log_level = if interactive { Some("error") } else { None }; + let log_level = if interactive { + Some("error") + } else { + Some("warn") + }; let has_existing = module .getattr("_graphics") @@ -475,6 +479,40 @@ pub(crate) fn reset_tracked_globals() { LAST_GLOBALS.with(|cache| cache.borrow_mut().clear()); } +/// Live reload: re-define the saved file's imports, functions and classes in the running +/// sketch's globals, so state survives and `size()`/`run()` don't run again. +fn reload_definitions(py: Python<'_>, source: &str, globals: &Bound<'_, PyAny>) -> PyResult<()> { + let ast = PyModule::import(py, "ast")?; + let tree = ast.getattr("parse")?.call1((source,))?; + let keep = PyTuple::new( + py, + [ + "FunctionDef", + "AsyncFunctionDef", + "ClassDef", + "Import", + "ImportFrom", + ] + .iter() + .map(|n| ast.getattr(*n)) + .collect::>>()?, + )?; + let body = PyList::empty(py); + for node in tree.getattr("body")?.try_iter()? { + let node = node?; + if node.is_instance(&keep)? { + body.append(node)?; + } + } + tree.setattr("body", body)?; + let builtins = PyModule::import(py, "builtins")?; + let code = builtins + .getattr("compile")? + .call1((tree, "", "exec"))?; + builtins.getattr("exec")?.call1((code, globals))?; + Ok(()) +} + fn get_sketch_info() -> PyResult<(String, String)> { Python::attach(|py| { let sys = PyModule::import(py, "sys")?; @@ -483,7 +521,12 @@ fn get_sketch_info() -> PyResult<(String, String)> { let os = PyModule::import(py, "os")?; let path = os.getattr("path")?; - if filename.is_empty() || !path.getattr("isfile")?.call1((filename,))?.is_truthy()? { + // live reload watches the sketch file; it's opt-in while it's experimental + let live_reload = env::var_os("PROCESSING_LIVE_RELOAD").is_some_and(|v| !v.is_empty()); + if !live_reload + || filename.is_empty() + || !path.getattr("isfile")?.call1((filename,))?.is_truthy()? + { let cwd = os.getattr("getcwd")?.call0()?.to_string(); return Ok((cwd, String::new())); } @@ -1254,7 +1297,7 @@ pub mod processing_py { ensure_graphics(module)?; - let mut globals = if let Some(ref draw) = draw_fn { + let globals = if let Some(ref draw) = draw_fn { draw.getattr("__globals__")? } else if let Some(ref setup) = setup_fn { setup.getattr("__globals__")? @@ -1278,6 +1321,7 @@ pub mod processing_py { .end_draw()?; loop { + py.check_signals()?; { let mut graphics = get_graphics_mut(module)? .ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; @@ -1298,35 +1342,32 @@ pub mod processing_py { let mut first_frame = true; loop { + // also catches Ctrl-C while paused, when no Python code runs + py.check_signals()?; { let mut graphics = get_graphics_mut(module)? .ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - // TODO: this shouldn't be on the graphics object - let sketch = graphics.poll_for_sketch_update()?; - if !sketch.source.is_empty() { - let locals = PyDict::new(py); - - let ok = CString::new(sketch.source.as_str()).unwrap(); - let cstr: &CStr = ok.as_c_str(); + if !graphics.surface.poll_events() { + break; + } + } - match py.run(cstr, None, Some(&locals)) { - Ok(_) => {} - Err(e) => { - eprintln!("sketch reload error: {e}"); + // TODO: this shouldn't be on the graphics object + let sketch = get_graphics(module)? + .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? + .poll_for_sketch_update()?; + if !sketch.source.is_empty() { + match reload_definitions(py, &sketch.source, &globals) { + Ok(()) => { + if let Ok(draw) = globals.get_item("draw") { + *draw_fn_ref = draw; } + // the re-run `import *` rebound the tracked globals + reset_tracked_globals(); + first_frame = true; } - - *draw_fn_ref = locals.get_item("draw").unwrap().unwrap(); - globals = draw_fn_ref.getattr("__globals__")?; - reset_tracked_globals(); - first_frame = true; - - dbg!(locals); - } - - if !graphics.surface.poll_events() { - break; + Err(e) => eprintln!("sketch reload error: {e}"), } } @@ -1360,9 +1401,7 @@ pub mod processing_py { wg.bind(py).borrow().begin_draw()?; } sync_globals(module, &globals)?; - draw_fn_ref - .call0() - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + draw_fn_ref.call0()?; for wg in &windows { wg.bind(py).borrow().end_draw()?; } @@ -1377,6 +1416,17 @@ pub mod processing_py { } Ok(()) + }) + .or_else(|e: PyErr| { + // Ctrl-C quits the sketch like closing its window, rather than a + // traceback from wherever draw() happened to be + Python::attach(|py| { + if e.is_instance_of::(py) { + Ok(()) + } else { + Err(e) + } + }) }); // tear down the app while the thread-local is still alive; the eager diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 2a1c4fe0..8a1f06e5 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -39,6 +39,10 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult>() { return Ok(shader_value::ShaderValue::Float2(v.0.to_array())); } + // before the sequence fallbacks: a Color iterates in its own space (h, s, v, a for hsva) + if let Ok(c) = value.extract::>() { + return Ok(shader_value::ShaderValue::Color(c.0)); + } if let Ok(buf) = value.extract::>() { return Ok(shader_value::ShaderValue::Buffer(buf.entity)); diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index 788f0d6d..84bc52d5 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -118,6 +118,26 @@ fn parse_falloff(s: &str) -> PyResult { } } +/// `emit()` data: flat numbers, or one value per particle (`[x, y, z]`, a `Color`, ...) +/// converted like `Buffer.write`. +fn emit_bytes(name: &str, value: &Bound<'_, PyAny>) -> PyResult> { + let mut bytes = Vec::new(); + for item in value.try_iter()? { + let item = item?; + // a bare int would become an i32 shader value; attributes are floats + if let Ok(f) = item.extract::() { + bytes.extend_from_slice(&f.to_le_bytes()); + continue; + } + let sv = crate::material::py_to_shader_value(&item)?; + let b = sv.to_bytes().ok_or_else(|| { + PyTypeError::new_err(format!("attribute '{name}': unsupported value {sv:?}")) + })?; + bytes.extend_from_slice(&b); + } + Ok(bytes) +} + #[pyclass(unsendable)] pub struct Grid { pub(crate) entity: Entity, @@ -977,19 +997,20 @@ impl Particles { for (key, value) in kwargs.iter() { let name: String = key.extract()?; let attr_entity = self.resolve_attribute(&key)?; + particles_ensure_attribute(self.entity, attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let (_, fmt) = geometry_attribute_info(attr_entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let fmt = AttributeFormat::from_inner(fmt); - let floats: Vec = value.extract()?; + let bytes = emit_bytes(&name, &value)?; let expected = (n as usize) * fmt.float_count(); - if floats.len() != expected { + if bytes.len() != expected * 4 { return Err(PyRuntimeError::new_err(format!( "attribute '{name}': expected {expected} floats ({} per particle × {n}), got {}", fmt.float_count(), - floats.len(), + bytes.len() / 4, ))); } - let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); data.push((attr_entity, bytes)); } particles_emit(self.entity, n, data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index e11a2810..cce8eb27 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; use bevy::asset::{AssetId, RenderAssetUsages}; use bevy::mesh::MeshVertexAttribute; @@ -21,6 +21,7 @@ use bevy::{ }; use bevy_naga_reflect::dynamic_shader::DynamicShader; +use bevy_naga_reflect::reflect::ParameterCategory; use crate::geometry::{Attribute, Geometry}; use crate::material::custom::Shader; @@ -220,6 +221,8 @@ pub struct Compute { pub pipeline_id: CachedComputePipelineId, pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, pub mesh_bindings: HashMap, + /// Storage arrays bound to a particle system's columns by the last apply or emit. + pub column_bindings: HashSet, } fn queue_pipeline( @@ -328,6 +331,7 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { pipeline_id, bind_group_layout_descriptors, mesh_bindings: HashMap::new(), + column_bindings: HashSet::new(), }) .id()); } @@ -370,6 +374,15 @@ pub fn dispatch( if !ready { return Err(ProcessingError::ResourceNotReady(name.to_string())); } + // wgpu treats an incomplete bind group as fatal, so fail here instead + if matches!(param.category(), ParameterCategory::Storage { .. }) + && shader.buffer_handle(name).is_none() + && !mesh_bindings.iter().any(|(n, _)| n == name) + { + return Err(ProcessingError::InvalidArgument(format!( + "nothing is bound to the shader's storage array `{name}`" + ))); + } } let mut bind_groups = Vec::new(); diff --git a/crates/processing_render/src/gltf.rs b/crates/processing_render/src/gltf.rs index edac444b..8b7af62d 100644 --- a/crates/processing_render/src/gltf.rs +++ b/crates/processing_render/src/gltf.rs @@ -7,7 +7,7 @@ use bevy::{ io::{AssetSourceId, embedded::GetAssetServer}, }, ecs::system::RunSystemOnce, - gltf::{Gltf, GltfMaterial, GltfMeshName}, + gltf::{Gltf, GltfMaterial, GltfMaterialName, GltfMeshName}, pbr::ExtendedMaterial, platform::collections::HashSet, prelude::*, @@ -185,21 +185,7 @@ pub fn geometry( let instance_id = gltf_handle.instance_id; let (mesh_handle, global_transform) = { - let spawner = world.resource::(); - - // find the mesh with the given name component that bevy added post-spawn - // name is derived from gltf node or computed - let mesh_entity = spawner - .iter_instance_entities(instance_id) - .find(|&e| { - world - .get::(e) - .map(|n| n.0 == name) - .unwrap_or(false) - }) - .ok_or_else(|| { - ProcessingError::GltfLoadError(format!("Mesh '{}' not found in GLTF scene", name)) - })?; + let mesh_entity = find_mesh_entity(world, instance_id, &name)?; let mesh3d = world.get::(mesh_entity).ok_or_else(|| { ProcessingError::GltfLoadError(format!( @@ -229,6 +215,62 @@ pub fn geometry( Ok(entity) } +/// The scene entity bevy spawned for a mesh, by the name it gave that mesh (from the +/// glTF node, or computed). +fn find_mesh_entity( + world: &World, + instance_id: bevy::world_serialization::InstanceId, + name: &str, +) -> Result { + world + .resource::() + .iter_instance_entities(instance_id) + .find(|&e| world.get::(e).is_some_and(|n| n.0 == name)) + .ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Mesh '{}' not found in GLTF scene", name)) + }) +} + +/// The base color texture of a mesh's material, as an [`Image`](crate::image::Image). +pub fn texture(In((gltf_entity, name)): In<(Entity, String)>, world: &mut World) -> Result { + let gltf_handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let (instance_id, handle) = (gltf_handle.instance_id, gltf_handle.handle.clone()); + + let mesh_entity = find_mesh_entity(world, instance_id, &name)?; + let material_name = world + .get::(mesh_entity) + .ok_or_else(|| ProcessingError::GltfLoadError(format!("Mesh '{}' has no material", name)))? + .0 + .clone(); + + let texture = { + let gltf = world + .resource::>() + .get(&handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + let material = gltf + .named_materials + .get(material_name.as_str()) + .and_then(|h| world.resource::>().get(h)) + .ok_or_else(|| { + ProcessingError::GltfLoadError(format!( + "Material '{}' not found in GLTF", + material_name + )) + })?; + material.base_color_texture.clone().ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Mesh '{}' has no texture", name)) + })? + }; + + block_on_load(world, |w| w.get_asset_server().load_state(&texture))?; + world + .run_system_once_with(crate::image::from_handle, texture) + .unwrap() +} + /// Translate a bevy [`GltfMaterial`] into the [`StandardMaterial`] base of a /// [`ProcessingExtendedMaterial`]. `GltfMaterial` mirrors `StandardMaterial`'s /// fields, so we copy across the ones that drive shading and let the rest fall diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index adeff56e..15912fb6 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -8,7 +8,7 @@ use bevy::{ CameraMainTextureUsages, CameraOutputMode, CameraProjection, ClearColorConfig, Hdr, ImageRenderTarget, MsaaWriteback, Projection, RenderTarget, visibility::RenderLayers, }, - core_pipeline::tonemapping::Tonemapping, + core_pipeline::tonemapping::{DebandDither, Tonemapping}, ecs::query::QueryEntityError, math::{Mat4, Vec3A, Vec4}, prelude::*, @@ -269,6 +269,8 @@ pub fn create( }, target, Tonemapping::None, + // dithering only applies after tonemapping + DebandDither::Disabled, // we need to be able to write to the texture CameraMainTextureUsages::default().with(TextureUsages::COPY_DST), Projection::custom(ProcessingProjection::new(width as f32, height as f32)), @@ -598,11 +600,17 @@ pub fn tonemapping_from_u8(v: u8) -> Option { pub fn set_tonemapping( In((entity, tonemapping)): In<(Entity, Tonemapping)>, - mut query: Query<&mut Tonemapping>, + mut query: Query<(&mut Tonemapping, &mut DebandDither)>, ) -> Result<()> { - *query + let (mut current, mut dither) = query .get_mut(entity) - .map_err(|_| ProcessingError::GraphicsNotFound)? = tonemapping; + .map_err(|_| ProcessingError::GraphicsNotFound)?; + *dither = if tonemapping == Tonemapping::None { + DebandDither::Disabled + } else { + DebandDither::Enabled + }; + *current = tonemapping; Ok(()) } diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index c023a38b..159b0571 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2343,6 +2343,15 @@ pub fn gltf_material(gltf_entity: Entity, name: &str) -> error::Result { }) } +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_texture(gltf_entity: Entity, name: &str) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::texture, (gltf_entity, name.to_string())) + .unwrap() + }) +} + #[cfg(not(target_arch = "wasm32"))] pub fn gltf_mesh_names(gltf_entity: Entity) -> error::Result> { app_mut(|app| { @@ -2870,12 +2879,27 @@ fn text_query_state( render::primitive::text::OwnedTextParams, text::font::TextContext, )> { - let state = app - .world() + let world = app.world(); + let state = world .get::(graphics_entity) .ok_or(error::ProcessingError::GraphicsNotFound)?; - let params = render::primitive::text::OwnedTextParams::from_render_state(state, max_w, max_h); - let text_cx = app.world().resource::().clone(); + // text_size() and friends are queued until the next flush, so replay them + let mut style = state.style.clone(); + if let Some(buffer) = world.get::(graphics_entity) { + for cmd in &buffer.commands { + match cmd { + DrawCommand::PushStyle => style.push(), + DrawCommand::PopStyle => style.pop(), + cmd => render::apply_text_style_command(&mut style, cmd.clone(), |entity| { + world + .get::(entity) + .map(|f| f.family_name.clone()) + }), + } + } + } + let params = render::primitive::text::OwnedTextParams::from_style(&style, max_w, max_h); + let text_cx = world.resource::().clone(); Ok((params, text_cx)) } diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 5619e035..63c9bda9 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -561,6 +561,7 @@ pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result Box::new(UVec3::from_array(*v)), ShaderValue::UInt4(v) => Box::new(UVec4::from_array(*v)), ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), + ShaderValue::Color(c) => Box::new(Vec4::from_array(c.to_linear().to_f32_array())), ShaderValue::Texture(_) | ShaderValue::Buffer(_) | ShaderValue::MeshAttribute(..) diff --git a/crates/processing_render/src/material/pbr.rs b/crates/processing_render/src/material/pbr.rs index f4fd86f8..abd7cacc 100644 --- a/crates/processing_render/src/material/pbr.rs +++ b/crates/processing_render/src/material/pbr.rs @@ -11,12 +11,16 @@ pub fn set_property( ) -> Result<()> { match name { "base_color" | "color" => { - let ShaderValue::Float4(c) = value else { - return Err(ProcessingError::InvalidArgument(format!( - "'{name}' expects Float4, got {value:?}" - ))); + material.base_color = match value { + // plain numbers are colors as written, i.e. sRGB + ShaderValue::Float4(c) => Color::srgba(c[0], c[1], c[2], c[3]), + ShaderValue::Color(c) => *c, + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "'{name}' expects a color, got {value:?}" + ))); + } }; - material.base_color = Color::srgba(c[0], c[1], c[2], c[3]); } "metallic" => { let ShaderValue::Float(v) = value else { @@ -43,12 +47,15 @@ pub fn set_property( material.reflectance = *v; } "emissive" => { - let ShaderValue::Float4(c) = value else { - return Err(ProcessingError::InvalidArgument(format!( - "'{name}' expects Float4, got {value:?}" - ))); + material.emissive = match value { + ShaderValue::Float4(c) => LinearRgba::new(c[0], c[1], c[2], c[3]), + ShaderValue::Color(c) => c.to_linear(), + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "'{name}' expects a color, got {value:?}" + ))); + } }; - material.emissive = LinearRgba::new(c[0], c[1], c[2], c[3]); } "base_color_texture" | "texture" => { let Some(handle) = texture_handle else { diff --git a/crates/processing_render/src/particles/emit.rs b/crates/processing_render/src/particles/emit.rs index 51cd91a9..6fa51177 100644 --- a/crates/processing_render/src/particles/emit.rs +++ b/crates/processing_render/src/particles/emit.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Mutex; use bevy::prelude::*; @@ -5,15 +6,103 @@ use bevy::prelude::*; use processing_core::app_mut; use processing_core::error; +use crate::compute::Compute; use crate::geometry; use crate::particles::grid::grid_build; use crate::particles::kernels::KernelRequires; use crate::particles::{Particles, particles_ensure_attribute}; use crate::shader_value::ShaderValue; use crate::{buffer_write_element, compute_create, compute_dispatch, compute_set, shader_load}; +use bevy_naga_reflect::reflect::ParameterCategory; const WORKGROUP_SIZE: u32 = 64; +/// Creates every column `compute_entity` reads or writes that the system doesn't +/// have yet: the kernel's declared requirements, plus any storage array the shader +/// names after a known attribute. A storage array left unbound would make the +/// bind group incomplete, which wgpu treats as fatal, so an unknown name is an error. +fn ensure_columns(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { + let (required, missing) = app_mut(|app| { + let world = app.world(); + let required = world + .get::(compute_entity) + .map(|r| r.0.clone()) + .unwrap_or_default(); + let compute = world + .get::(compute_entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + let particles = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let have: HashSet<&str> = particles + .buffers + .keys() + .filter_map(|&e| world.get::(e).map(|a| a.name)) + .chain( + particles + .neighbor_lists + .map(|_| ["neighbors", "neighbor_count"]) + .into_iter() + .flatten(), + ) + .collect(); + let missing: Vec = compute + .shader + .reflection() + .parameters() + .filter(|p| matches!(p.category(), ParameterCategory::Storage { .. })) + .filter_map(|p| p.name().map(str::to_string)) + .filter(|name| { + !have.contains(name.as_str()) + && (compute.shader.buffer_handle(name).is_none() + || compute.column_bindings.contains(name)) + && !compute.mesh_bindings.contains_key(name) + }) + .collect(); + Ok((required, missing)) + })?; + + for attr_entity in required { + particles_ensure_attribute(particles_entity, attr_entity)?; + } + for name in missing { + let attr = crate::geometry_attribute_find(name.as_str())?.ok_or_else(|| { + error::ProcessingError::InvalidArgument(format!( + "the shader uses a column named `{name}` that these particles don't have. \ + Create the column before applying it" + )) + })?; + particles_ensure_attribute(particles_entity, attr)?; + } + Ok(()) +} + +/// Binds each column the shader declares and records them as this system's, so a +/// later system that lacks one doesn't inherit the binding. +fn bind_columns(compute_entity: Entity, buffers: Vec<(String, Entity)>) -> error::Result<()> { + let mut bound = HashSet::with_capacity(buffers.len()); + for (name, buf_entity) in buffers { + match compute_set( + compute_entity, + name.as_str(), + ShaderValue::Buffer(buf_entity), + ) { + Ok(()) => { + bound.insert(name); + } + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + app_mut(|app| { + app.world_mut() + .get_mut::(compute_entity) + .ok_or(error::ProcessingError::ComputeNotFound)? + .column_bindings = bound; + Ok(()) + }) +} + pub fn particles_emit_gpu( particles_entity: Entity, count: u32, @@ -23,6 +112,8 @@ pub fn particles_emit_gpu( return Ok(()); } + ensure_columns(particles_entity, compute_entity)?; + let (capacity, head, buffers) = app_mut(|app| { let world = app.world(); let field = world @@ -44,13 +135,7 @@ pub fn particles_emit_gpu( Ok((field.capacity, field.emit_head, buffers)) })?; - for (name, buf_entity) in buffers { - match compute_set(compute_entity, name, ShaderValue::Buffer(buf_entity)) { - Ok(()) => {} - Err(error::ProcessingError::UnknownShaderProperty(_)) => {} - Err(e) => return Err(e), - } - } + bind_columns(compute_entity, buffers)?; for (name, value) in [ ("emit_base", ShaderValue::UInt(head)), @@ -208,16 +293,7 @@ pub fn particles_gather( } pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { - let required: Vec = app_mut(|app| { - Ok(app - .world() - .get::(compute_entity) - .map(|r| r.0.clone()) - .unwrap_or_default()) - })?; - for attr_entity in required { - particles_ensure_attribute(particles_entity, attr_entity)?; - } + ensure_columns(particles_entity, compute_entity)?; let (capacity, buffers) = app_mut(|app| { let world = app.world(); @@ -238,13 +314,7 @@ pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> erro Ok((field.capacity, buffers)) })?; - for (name, buf_entity) in buffers { - match compute_set(compute_entity, name, ShaderValue::Buffer(buf_entity)) { - Ok(()) => {} - Err(error::ProcessingError::UnknownShaderProperty(_)) => {} - Err(e) => return Err(e), - } - } + bind_columns(compute_entity, buffers)?; let workgroup_count = capacity.div_ceil(WORKGROUP_SIZE); compute_dispatch(compute_entity, workgroup_count, 1, 1) diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs index 7cb8c83a..a3600aec 100644 --- a/crates/processing_render/src/particles/kernels/mod.rs +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -8,21 +8,26 @@ use bevy::prelude::*; use processing_core::app_mut; use processing_core::error::{self, ProcessingError}; -use crate::geometry::{BuiltinAttributes, Geometry}; +use crate::geometry::Geometry; use crate::shader_value::ShaderValue; use crate::{compute_create, compute_set, shader_load}; #[derive(Component, Default, Clone)] pub struct KernelRequires(pub Vec); -fn set_requires(compute: Entity, names: &[&str]) -> error::Result<()> { +pub(crate) fn set_requires(compute: Entity, names: &[&str]) -> error::Result<()> { + let attrs = names + .iter() + .map(|name| { + crate::geometry_attribute_find(*name)?.ok_or_else(|| { + ProcessingError::InvalidArgument(format!("no attribute named `{name}`")) + }) + }) + .collect::>>()?; app_mut(|app| { - let world = app.world_mut(); - let attrs: Vec = { - let builtins = world.resource::(); - names.iter().filter_map(|n| builtins.by_name(n)).collect() - }; - world.entity_mut(compute).insert(KernelRequires(attrs)); + app.world_mut() + .entity_mut(compute) + .insert(KernelRequires(attrs)); Ok(()) }) } @@ -266,7 +271,8 @@ pub fn particles_kernel_orient() -> error::Result { pub fn particles_kernel_field() -> error::Result { let shader = shader_load("embedded://processing_render/particles/kernels/field.wgsl")?; let entity = compute_create(shader)?; - set_requires(entity, &["position"])?; + crate::geometry_attribute_create("weight", crate::geometry::AttributeFormat::Float)?; + set_requires(entity, &["position", "weight"])?; compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; compute_set(entity, "radius", ShaderValue::Float(1.0))?; compute_set( diff --git a/crates/processing_render/src/particles/kernels/scatter_surface.wgsl b/crates/processing_render/src/particles/kernels/scatter_surface.wgsl index a9fa3b60..ab86240b 100644 --- a/crates/processing_render/src/particles/kernels/scatter_surface.wgsl +++ b/crates/processing_render/src/particles/kernels/scatter_surface.wgsl @@ -21,6 +21,8 @@ struct EmitRange { @group(0) @binding(6) var life: array; @group(0) @binding(7) var params: Params; @group(0) @binding(8) var emit_range: EmitRange; +@group(0) @binding(9) var source_uv: array; +@group(0) @binding(10) var uv: array; fn hash(n: u32) -> u32 { var x = n; @@ -91,6 +93,13 @@ fn main(@builtin(global_invocation_id) gid: vec3) { position[slot * 3u + 0u] = p.x; position[slot * 3u + 1u] = p.y; position[slot * 3u + 2u] = p.z; + // texture coordinate at the same spot, for LOOKUP + let t0 = vec2(source_uv[i0 * 2u], source_uv[i0 * 2u + 1u]); + let t1 = vec2(source_uv[i1 * 2u], source_uv[i1 * 2u + 1u]); + let t2 = vec2(source_uv[i2 * 2u], source_uv[i2 * 2u + 1u]); + let t = (1.0 - u - v) * t0 + u * t1 + v * t2; + uv[slot * 2u + 0u] = t.x; + uv[slot * 2u + 1u] = t.y; scale[slot * 3u + 0u] = 1.0; scale[slot * 3u + 1u] = 1.0; scale[slot * 3u + 2u] = 1.0; diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs index e6013b40..1d1ec118 100644 --- a/crates/processing_render/src/particles/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -100,6 +100,9 @@ pub struct Particles { /// Ring-buffer write cursor; wraps at `capacity`. pub emit_head: u32, pub neighbor_lists: Option, + /// The mesh a system made from a `Geometry` came from. Attributes created + /// later start from its data when it has them, so only what's used is copied. + pub source_mesh: Option>, } /// The `neighbors` (up to `max` indices per particle) and `neighbor_count` attributes. @@ -163,6 +166,7 @@ pub fn create( connectivity: None, emit_head: 0, neighbor_lists: None, + source_mesh: None, }) .id(); Ok(entity) @@ -245,6 +249,7 @@ pub fn create_from_geometry( connectivity, emit_head: 0, neighbor_lists: None, + source_mesh: Some(geom.handle.clone()), }) .id(); Ok(entity) @@ -434,6 +439,7 @@ pub fn materialize_attribute( mut commands: Commands, mut particles_q: Query<&mut Particles>, attributes: Query<&Attribute>, + meshes: Res>, mut shader_buffers: ResMut>, render_device: Res, ) -> Result { @@ -442,7 +448,7 @@ pub fn materialize_attribute( .map_err(|_| ProcessingError::InvalidEntity)? .clone(); - let capacity = { + let (capacity, from_mesh) = { let particles = particles_q .get(particles_entity) .map_err(|_| ProcessingError::ParticlesNotFound)?; @@ -477,7 +483,15 @@ pub fn materialize_attribute( ))), }; } - particles.capacity as usize + let capacity = particles.capacity as usize; + let from_mesh = particles + .source_mesh + .as_ref() + .and_then(|handle| meshes.get(handle)) + .and_then(|mesh| mesh.attribute(attr.inner)) + .and_then(|values| attribute_values_to_bytes(values, attr.format)) + .filter(|bytes| bytes.len() == capacity * attr.format.byte_size()); + (capacity, from_mesh) }; let elem_size = attr.format.byte_size(); @@ -506,7 +520,11 @@ pub fn materialize_attribute( } }; - let initial = tile_seed(&per_element, capacity); + // an explicit default wins over the mesh's data + let initial = match (&seed, from_mesh) { + (AttributeSeed::Declare(Some(_)), _) | (_, None) => tile_seed(&per_element, capacity), + (_, Some(bytes)) => bytes, + }; let buffer_entity = make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); particles_q .get_mut(particles_entity) diff --git a/crates/processing_render/src/particles/particles.wgsl b/crates/processing_render/src/particles/particles.wgsl index 05a7419d..8c67be7f 100644 --- a/crates/processing_render/src/particles/particles.wgsl +++ b/crates/processing_render/src/particles/particles.wgsl @@ -1,6 +1,7 @@ #import bevy_pbr::{ pbr_fragment::pbr_input_from_standard_material, pbr_functions::alpha_discard, + pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT, mesh_functions, } @@ -52,7 +53,11 @@ fn fragment( let out = deferred_output(in, pbr_input); #else var out: FragmentOutput; - out.color = apply_pbr_lighting(pbr_input); + if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + out.color = apply_pbr_lighting(pbr_input); + } else { + out.color = pbr_input.material.base_color; + } out.color = main_pass_post_lighting_processing(pbr_input, out.color); #endif diff --git a/crates/processing_render/src/particles/scatter.rs b/crates/processing_render/src/particles/scatter.rs index fb917589..b6a40e05 100644 --- a/crates/processing_render/src/particles/scatter.rs +++ b/crates/processing_render/src/particles/scatter.rs @@ -5,13 +5,14 @@ use processing_core::app_mut; use processing_core::error::{self, ProcessingError, Result}; use crate::geometry::Geometry; +use crate::particles::kernels::set_requires; use crate::shader_value::ShaderValue; use crate::{ buffer_create_with_data, compute_create, compute_set, geometry_attribute_position, shader_load, }; pub fn particles_scatter_create(source_geometry: Entity) -> error::Result { - let (cdf_bytes, indices_bytes, face_count) = app_mut(|app| { + let (cdf_bytes, indices_bytes, uv_bytes, face_count) = app_mut(|app| { app.world_mut() .run_system_cached_with(prepare_scatter_source, source_geometry) .unwrap() @@ -19,10 +20,12 @@ pub fn particles_scatter_create(source_geometry: Entity) -> error::Result error::Result, geometries: Query<&Geometry>, mut meshes: ResMut>, -) -> Result<(Vec, Vec, u32)> { +) -> Result<(Vec, Vec, Vec, u32)> { let geom = geometries .get(geom_entity) .map_err(|_| ProcessingError::GeometryNotFound)?; @@ -130,6 +134,11 @@ pub fn prepare_scatter_source( let (positions, dense_indices) = extract_scatter_geometry(mesh)?; let face_count = (dense_indices.len() / 3) as u32; + // a mesh without UVs scatters with uv (0, 0) + let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0) { + Some(VertexAttributeValues::Float32x2(uv)) => uv.clone(), + _ => vec![[0.0, 0.0]; positions.len()], + }; let mut cum = Vec::with_capacity(face_count as usize); let mut total = 0.0_f32; @@ -156,7 +165,8 @@ pub fn prepare_scatter_source( let cdf_bytes: Vec = cum.iter().flat_map(|f| f.to_le_bytes()).collect(); let indices_bytes: Vec = dense_indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - Ok((cdf_bytes, indices_bytes, face_count)) + let uv_bytes: Vec = uvs.iter().flatten().flat_map(|f| f.to_le_bytes()).collect(); + Ok((cdf_bytes, indices_bytes, uv_bytes, face_count)) } pub fn prepare_scatter_volume_source( diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 35ed401c..a98c1f02 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -9,7 +9,7 @@ pub mod transform; use bevy::{ camera::Camera3dDepthLoadOp, camera::{ - CameraOutputMode, ClearColorConfig, + CameraOutputMode, ClearColorConfig, RenderTarget, primitives::Aabb, visibility::{NoFrustumCulling, RenderLayers}, }, @@ -18,6 +18,7 @@ use bevy::{ pbr::gpu_instance_batch::GpuBatchedMesh3d, prelude::*, render::render_resource::BlendState, + window::WindowRef, }; use command::{CommandBuffer, DrawCommand, ShapeMode}; use material::{MaterialKey, ProcessingExtendedMaterial}; @@ -95,6 +96,57 @@ impl BatchState { } } +/// Applies a text-style command to `style`; shared by the flush and by text queries, which +/// replay the frame's pending commands. `font_family` resolves a font entity. +pub(crate) fn apply_text_style_command( + style: &mut StyleStack, + cmd: DrawCommand, + font_family: impl Fn(Entity) -> Option, +) { + match cmd { + DrawCommand::TextFont(font_entity) => { + if let Some(entity) = font_entity { + if let Some(family) = font_family(entity) { + style.text_font_family = Some(family); + } + } else { + style.text_font_family = None; + } + } + DrawCommand::TextStyle(text_style) => style.text_style = text_style, + DrawCommand::TextWeight(weight) => style.text_weight = Some(weight), + DrawCommand::TextVariation { tag, value } => { + if let Some(existing) = style.text_variations.iter_mut().find(|(t, _)| *t == tag) { + existing.1 = value; + } else { + style.text_variations.push((tag, value)); + } + } + DrawCommand::ClearTextVariations => style.text_variations.clear(), + DrawCommand::TextFeature { tag, value } => { + if let Some(existing) = style.text_features.iter_mut().find(|(t, _)| *t == tag) { + existing.1 = value; + } else { + style.text_features.push((tag, value)); + } + } + DrawCommand::NoTextFeature { tag } => style.text_features.retain(|(t, _)| *t != tag), + DrawCommand::ClearTextFeatures => style.text_features.clear(), + DrawCommand::TextSize(size) => { + style.text_size = size; + style.text_leading = None; + } + DrawCommand::TextAlign { h, v } => { + style.text_align_h = h; + style.text_align_v = v; + } + DrawCommand::TextLeading(leading) => style.text_leading = Some(leading), + DrawCommand::TextWrap(mode) => style.text_wrap = mode, + DrawCommand::TextGlyphColors(colors) => style.text_glyph_colors = Some(colors), + _ => {} + } +} + #[derive(Debug, Component)] pub struct RenderState { pub style: StyleStack, @@ -161,9 +213,11 @@ pub fn flush_draw_commands( &mut Camera3d, &mut crate::image::BackgroundBlit, Has, + Option<&RenderTarget>, ), With, >, + p_windows: Query<&Window>, p_images: Query<&Image>, p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, @@ -190,8 +244,16 @@ pub fn flush_draw_commands( mut camera_3d, mut background_blit, presents, + target, ) in graphics.iter_mut() { + // an opaque window ignores background alpha, as in Processing + let opaque_window = matches!( + target, + Some(RenderTarget::Window(WindowRef::Entity(window))) + if p_windows.get(*window).is_ok_and(|w| !w.transparent) + ); + // color is kept unless `background()` opens the pass; depth is kept only within a frame camera.clear_color = ClearColorConfig::None; camera_3d.depth_load_op = if std::mem::take(&mut state.clear_depth) { @@ -904,6 +966,11 @@ pub fn flush_draw_commands( drained = false; break; } + let color = if opaque_window { + color.with_alpha(1.0) + } else { + color + }; camera.clear_color = ClearColorConfig::Custom(color); camera_3d.depth_load_op = Camera3dDepthLoadOp::Clear(0.0); } @@ -1282,70 +1349,22 @@ pub fn flush_draw_commands( &p_material_handles, ); } - DrawCommand::TextFont(font_entity) => { - if let Some(entity) = font_entity { - if let Ok(font) = p_fonts.get(entity) { - state.style.text_font_family = Some(font.family_name.clone()); - } - } else { - state.style.text_font_family = None; - } - } - DrawCommand::TextStyle(style) => { - state.style.text_style = style; - } - DrawCommand::TextWeight(weight) => { - state.style.text_weight = Some(weight); - } - DrawCommand::TextVariation { tag, value } => { - if let Some(existing) = state - .style - .text_variations - .iter_mut() - .find(|(t, _)| *t == tag) - { - existing.1 = value; - } else { - state.style.text_variations.push((tag, value)); - } - } - DrawCommand::ClearTextVariations => { - state.style.text_variations.clear(); - } - DrawCommand::TextFeature { tag, value } => { - if let Some(existing) = state - .style - .text_features - .iter_mut() - .find(|(t, _)| *t == tag) - { - existing.1 = value; - } else { - state.style.text_features.push((tag, value)); - } - } - DrawCommand::NoTextFeature { tag } => { - state.style.text_features.retain(|(t, _)| *t != tag); - } - DrawCommand::ClearTextFeatures => { - state.style.text_features.clear(); - } - DrawCommand::TextSize(size) => { - state.style.text_size = size; - state.style.text_leading = None; - } - DrawCommand::TextAlign { h, v } => { - state.style.text_align_h = h; - state.style.text_align_v = v; - } - DrawCommand::TextLeading(leading) => { - state.style.text_leading = Some(leading); - } - DrawCommand::TextWrap(mode) => { - state.style.text_wrap = mode; - } - DrawCommand::TextGlyphColors(colors) => { - state.style.text_glyph_colors = Some(colors); + cmd @ (DrawCommand::TextFont(_) + | DrawCommand::TextStyle(_) + | DrawCommand::TextWeight(_) + | DrawCommand::TextVariation { .. } + | DrawCommand::ClearTextVariations + | DrawCommand::TextFeature { .. } + | DrawCommand::NoTextFeature { .. } + | DrawCommand::ClearTextFeatures + | DrawCommand::TextSize(_) + | DrawCommand::TextAlign { .. } + | DrawCommand::TextLeading(_) + | DrawCommand::TextWrap(_) + | DrawCommand::TextGlyphColors(_)) => { + apply_text_style_command(&mut state.style, cmd, |entity| { + p_fonts.get(entity).ok().map(|f| f.family_name.clone()) + }); } DrawCommand::Text { content, diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs index 5cc4ab73..bc45bd12 100644 --- a/crates/processing_render/src/render/primitive/text.rs +++ b/crates/processing_render/src/render/primitive/text.rs @@ -28,6 +28,7 @@ use crate::render::{ RenderState, command::{TextAlignH, TextAlignV, TextStyle, TextWrapMode}, mesh_builder::MeshBuilder, + style::Style, }; use crate::text::font::{DEFAULT_FONT_FAMILY, TextContext}; @@ -91,19 +92,23 @@ impl OwnedTextParams { /// Snapshot a `RenderState`'s text state. `glyph_colors` is left unset; the /// draw path fills it in, measurement queries don't need it. pub fn from_render_state(state: &RenderState, max_w: Option, max_h: Option) -> Self { + Self::from_style(&state.style, max_w, max_h) + } + + pub fn from_style(style: &Style, max_w: Option, max_h: Option) -> Self { Self { - text_size: state.style.text_size, - align_h: state.style.text_align_h, - align_v: state.style.text_align_v, - leading: state.style.text_leading, + text_size: style.text_size, + align_h: style.text_align_h, + align_v: style.text_align_v, + leading: style.text_leading, max_w, max_h, - wrap: state.style.text_wrap, - font_family: state.style.text_font_family.clone(), - text_style: state.style.text_style, - text_weight: state.style.text_weight, - text_variations: state.style.text_variations.clone(), - text_features: state.style.text_features.clone(), + wrap: style.text_wrap, + font_family: style.text_font_family.clone(), + text_style: style.text_style, + text_weight: style.text_weight, + text_variations: style.text_variations.clone(), + text_features: style.text_features.clone(), glyph_colors: None, } } @@ -394,6 +399,27 @@ pub fn text_to_paths( /// Default `sample_factor` for [`text_to_points`]. pub const DEFAULT_SAMPLE_FACTOR: f32 = 0.1; +/// Samples a segment at `step` points per unit of length, like p5's `textToPoints`. +fn sample_outline( + points: &mut Vec<[f32; 2]>, + step: f32, + point: &dyn Fn(f32) -> [f32; 2], + include_end: bool, +) { + let mut len = 0.0; + let mut prev = point(0.0); + for i in 1..=16 { + let p = point(i as f32 / 16.0); + len += ((p[0] - prev[0]).powi(2) + (p[1] - prev[1]).powi(2)).sqrt(); + prev = p; + } + let steps = (len * step).ceil().max(1.0) as usize; + let last = if include_end { steps } else { steps - 1 }; + for i in 1..=last { + points.push(point(i as f32 / steps as f32)); + } +} + /// Sample points along text outlines. Higher `sample_factor` = more points; /// see [`DEFAULT_SAMPLE_FACTOR`]. pub fn text_to_points( @@ -416,6 +442,10 @@ pub fn text_to_points( let step = sample_factor.max(0.001); let mut points = Vec::new(); + let lerp = |a: lyon::math::Point, b: lyon::math::Point| { + move |t: f32| [a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t] + }; + for path in &glyph_paths { for event in path.iter() { use lyon::path::Event; @@ -424,47 +454,49 @@ pub fn text_to_points( points.push([at.x, at.y]); } Event::Line { from, to } => { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - let steps = (len * step).max(1.0) as usize; - for i in 1..=steps { - let t = i as f32 / steps as f32; - points.push([from.x + dx * t, from.y + dy * t]); - } + sample_outline(&mut points, step, &lerp(from, to), true) } - Event::Quadratic { from, ctrl, to } => { - let steps = (20.0 * step).max(2.0) as usize; - for i in 1..=steps { - let t = i as f32 / steps as f32; + Event::Quadratic { from, ctrl, to } => sample_outline( + &mut points, + step, + &|t| { let inv = 1.0 - t; - let px = inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x; - let py = inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y; - points.push([px, py]); - } - } + [ + inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x, + inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y, + ] + }, + true, + ), Event::Cubic { from, ctrl1, ctrl2, to, - } => { - let steps = (30.0 * step).max(2.0) as usize; - for i in 1..=steps { - let t = i as f32 / steps as f32; + } => sample_outline( + &mut points, + step, + &|t| { let inv = 1.0 - t; - let px = inv * inv * inv * from.x - + 3.0 * inv * inv * t * ctrl1.x - + 3.0 * inv * t * t * ctrl2.x - + t * t * t * to.x; - let py = inv * inv * inv * from.y - + 3.0 * inv * inv * t * ctrl1.y - + 3.0 * inv * t * t * ctrl2.y - + t * t * t * to.y; - points.push([px, py]); + [ + inv * inv * inv * from.x + + 3.0 * inv * inv * t * ctrl1.x + + 3.0 * inv * t * t * ctrl2.x + + t * t * t * to.x, + inv * inv * inv * from.y + + 3.0 * inv * inv * t * ctrl1.y + + 3.0 * inv * t * t * ctrl2.y + + t * t * t * to.y, + ] + }, + true, + ), + // the closing edge back to the start; its endpoint is the Begin point + Event::End { last, first, close } => { + if close && last != first { + sample_outline(&mut points, step, &lerp(last, first), false); } } - Event::End { .. } => {} } } } diff --git a/crates/processing_render/src/shader_property.rs b/crates/processing_render/src/shader_property.rs index f42e5a0c..d20e5a45 100644 --- a/crates/processing_render/src/shader_property.rs +++ b/crates/processing_render/src/shader_property.rs @@ -157,11 +157,13 @@ pub fn set_property( grids: Query<&Grid>, ) -> Result { let shader = if let Ok(compute) = computes.get_mut(entity) { + let compute = compute.into_inner(); + compute.column_bindings.remove(&name); if let ShaderValue::MeshAttribute(..) | ShaderValue::MeshIndex(..) = value { - bind_compute_mesh(compute.into_inner(), name, value)?; + bind_compute_mesh(compute, name, value)?; return Ok(true); } - &mut compute.into_inner().shader + &mut compute.shader } else if let Ok(filter) = filters.get_mut(entity) { &mut filter.into_inner().shader } else { diff --git a/crates/processing_render/src/shader_value.rs b/crates/processing_render/src/shader_value.rs index 5e1f7ce8..5967997e 100644 --- a/crates/processing_render/src/shader_value.rs +++ b/crates/processing_render/src/shader_value.rs @@ -15,6 +15,8 @@ pub enum ShaderValue { UInt3([u32; 3]), UInt4([u32; 4]), Mat4([f32; 16]), + /// Linear RGBA in a shader; PBR color properties take it as-is. + Color(Color), Texture(Entity), Buffer(Entity), MeshAttribute(Entity, Entity), @@ -39,6 +41,13 @@ impl ShaderValue { ShaderValue::UInt3(v) => Some(v.iter().flat_map(|u| u.to_le_bytes()).collect()), ShaderValue::UInt4(v) => Some(v.iter().flat_map(|u| u.to_le_bytes()).collect()), ShaderValue::Mat4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Color(c) => Some( + c.to_linear() + .to_f32_array() + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(), + ), ShaderValue::Texture(_) | ShaderValue::Buffer(_) | ShaderValue::MeshAttribute(..) @@ -52,7 +61,10 @@ impl ShaderValue { ShaderValue::Float(_) | ShaderValue::Int(_) | ShaderValue::UInt(_) => Some(4), ShaderValue::Float2(_) | ShaderValue::Int2(_) | ShaderValue::UInt2(_) => Some(8), ShaderValue::Float3(_) | ShaderValue::Int3(_) | ShaderValue::UInt3(_) => Some(12), - ShaderValue::Float4(_) | ShaderValue::Int4(_) | ShaderValue::UInt4(_) => Some(16), + ShaderValue::Float4(_) + | ShaderValue::Int4(_) + | ShaderValue::UInt4(_) + | ShaderValue::Color(_) => Some(16), ShaderValue::Mat4(_) => Some(64), ShaderValue::Texture(_) | ShaderValue::Buffer(_) @@ -104,6 +116,10 @@ impl ShaderValue { ShaderValue::UInt3(_) => Some(ShaderValue::UInt3(u32s::<3>(bytes)?)), ShaderValue::UInt4(_) => Some(ShaderValue::UInt4(u32s::<4>(bytes)?)), ShaderValue::Mat4(_) => Some(ShaderValue::Mat4(f32s::<16>(bytes)?)), + ShaderValue::Color(_) => { + let [r, g, b, a] = f32s::<4>(bytes)?; + Some(ShaderValue::Color(Color::linear_rgba(r, g, b, a))) + } ShaderValue::Texture(_) | ShaderValue::Buffer(_) | ShaderValue::MeshAttribute(..) diff --git a/examples/particles_text_whirl.rs b/examples/particles_text_whirl.rs index 3fc13afc..51d374ad 100644 --- a/examples/particles_text_whirl.rs +++ b/examples/particles_text_whirl.rs @@ -290,7 +290,7 @@ fn sketch() -> error::Result<()> { let mesh = graphics_text_to_model(graphics, text, -w / 2.0, TEXT_PT * 0.4, EXTRUSION)?; let source = geometry_create_from_mesh(mesh)?; - let (cdf_bytes, indices_bytes, face_count) = processing_core::app_mut(|app| { + let (cdf_bytes, indices_bytes, _uv_bytes, face_count) = processing_core::app_mut(|app| { app.world_mut() .run_system_cached_with(processing_render::particles::prepare_scatter_source, source) .unwrap() diff --git a/src/lib.rs b/src/lib.rs index 7620f21c..ff284140 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,11 +21,11 @@ fn create_app(config: Config) -> App { AssetSourceBuilder::platform_default(asset_path, None), ); } - if config + // a sketch file is only set for live reload, the one user of file watching + let live_reload = config .get(ConfigKey::SketchFileName) - .is_some_and(|f| !f.is_empty()) - && let Some(sketch_path) = config.get(ConfigKey::SketchRootPath) - { + .is_some_and(|f| !f.is_empty()); + if live_reload && let Some(sketch_path) = config.get(ConfigKey::SketchRootPath) { app.register_asset_source( "sketch_directory", AssetSourceBuilder::platform_default(sketch_path, None), @@ -35,6 +35,10 @@ fn create_app(config: Config) -> App { #[cfg(not(target_arch = "wasm32"))] let plugins = DefaultPlugins .build() + .set(AssetPlugin { + watch_for_changes_override: Some(live_reload), + ..default() + }) .set(RenderPlugin { synchronous_pipeline_compilation: true, ..default() @@ -159,7 +163,9 @@ fn setup_tracing(log_level: Option<&str>) -> error::Result<()> { use tracing_subscriber::EnvFilter; use tracing_subscriber::util::SubscriberInitExt; - let filter = EnvFilter::try_new(log_level.unwrap_or("info")) + // RUST_LOG wins so a quiet frontend can still be debugged + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(log_level.unwrap_or("info"))) .unwrap_or_else(|_| EnvFilter::new("info")); let subscriber = tracing_subscriber::FmtSubscriber::builder() .with_env_filter(filter)