From 1bf2725270b89d8c2d71e0e1e0d9bb981bbba82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Wed, 18 Mar 2026 11:35:27 -0700 Subject: [PATCH 01/11] Add compute shader and buffer API. --- Cargo.lock | 33 +- Cargo.toml | 4 + crates/processing_core/src/error.rs | 14 +- crates/processing_core/src/lib.rs | 4 +- crates/processing_ffi/src/lib.rs | 184 ++++++++- crates/processing_pyo3/examples/compute.py | 106 +++++ crates/processing_pyo3/src/compute.rs | 280 ++++++++++++++ crates/processing_pyo3/src/lib.rs | 6 + crates/processing_pyo3/src/material.rs | 31 +- crates/processing_render/Cargo.toml | 1 - crates/processing_render/src/compute.rs | 366 ++++++++++++++++++ crates/processing_render/src/lib.rs | 170 +++++++- .../processing_render/src/material/custom.rs | 63 +-- crates/processing_render/src/material/mod.rs | 18 +- crates/processing_render/src/material/pbr.rs | 22 +- crates/processing_render/src/shader_value.rs | 82 ++++ crates/processing_wasm/src/lib.rs | 4 +- examples/compute_readback.rs | 90 +++++ examples/custom_material.rs | 2 +- examples/gltf_load.rs | 8 +- examples/lights.rs | 2 +- examples/materials.rs | 8 +- examples/primitives_3d.rs | 2 +- 23 files changed, 1395 insertions(+), 105 deletions(-) create mode 100644 crates/processing_pyo3/examples/compute.py create mode 100644 crates/processing_pyo3/src/compute.rs create mode 100644 crates/processing_render/src/compute.rs create mode 100644 crates/processing_render/src/shader_value.rs create mode 100644 examples/compute_readback.rs diff --git a/Cargo.lock b/Cargo.lock index f4ff552..088c4da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,7 +1329,7 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" version = "0.1.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#60010545e20027c7ae2ca084b21ce014664ccd36" +source = "git+https://github.com/tychedelia/bevy_naga_reflect#1d6bfcdddaf44e7a3ed2c4a946e6af2ace2f9f44" dependencies = [ "bevy", "naga", @@ -1418,7 +1418,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2023,9 +2023,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -2178,9 +2178,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2721,9 +2721,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "derive_more" @@ -4156,9 +4156,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -5754,7 +5754,6 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "processing_core", - "processing_midi", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -5814,7 +5813,7 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "pyo3" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "inventory", "libc", @@ -5828,7 +5827,7 @@ dependencies = [ [[package]] name = "pyo3-build-config" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "target-lexicon", ] @@ -5836,7 +5835,7 @@ dependencies = [ [[package]] name = "pyo3-ffi" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "libc", "pyo3-build-config", @@ -5845,7 +5844,7 @@ dependencies = [ [[package]] name = "pyo3-introspection" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "anyhow", "goblin", @@ -5856,7 +5855,7 @@ dependencies = [ [[package]] name = "pyo3-macros" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -5867,7 +5866,7 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index a813652..234b3c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,10 @@ path = "examples/blend_modes.rs" name = "camera_controllers" path = "examples/camera_controllers.rs" +[[example]] +name = "compute_readback" +path = "examples/compute_readback.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 7cc1e9e..2fc9607 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -32,8 +32,8 @@ pub enum ProcessingError { TransformNotFound, #[error("Material not found")] MaterialNotFound, - #[error("Unknown material property: {0}")] - UnknownMaterialProperty(String), + #[error("Unknown shader property: {0}")] + UnknownShaderProperty(String), #[error("GLTF load error: {0}")] GltfLoadError(String), #[error("Webcam not connected")] @@ -46,4 +46,14 @@ pub enum ProcessingError { MidiPortNotFound(usize), #[error("CUDA error: {0}")] CudaError(String), + #[error("Compute shader not found")] + ComputeNotFound, + #[error("Buffer not found")] + BufferNotFound, + #[error("Buffer map error: {0}")] + BufferMapError(String), + #[error("Pipeline compile error: {0}")] + PipelineCompileError(String), + #[error("Pipeline not ready after {0} frames")] + PipelineNotReady(u32), } diff --git a/crates/processing_core/src/lib.rs b/crates/processing_core/src/lib.rs index 9de4d39..2a9118b 100644 --- a/crates/processing_core/src/lib.rs +++ b/crates/processing_core/src/lib.rs @@ -15,7 +15,9 @@ thread_local! { pub fn app_mut(cb: impl FnOnce(&mut App) -> error::Result) -> error::Result { let res = APP.with(|app_cell| { - let mut app_borrow = app_cell.borrow_mut(); + let mut app_borrow = app_cell + .try_borrow_mut() + .map_err(|_| error::ProcessingError::AppAccess)?; let app = app_borrow .as_mut() .ok_or(error::ProcessingError::AppAccess)?; diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 7c76491..eff9470 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -10,6 +10,12 @@ use crate::color::Color; mod color; mod error; +unsafe fn cstr_to_str<'a>(ptr: *const std::ffi::c_char) -> Result<&'a str, ProcessingError> { + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .map_err(|_| ProcessingError::InvalidArgument("non-UTF8 C string".to_string())) +} + /// Initialize libProcessing. /// /// SAFETY: @@ -1776,12 +1782,12 @@ pub unsafe extern "C" fn processing_material_set_float( value: f32, ) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); error::check(|| { + let name = unsafe { cstr_to_str(name) }?; material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float(value), + shader_value::ShaderValue::Float(value), ) }); } @@ -1800,12 +1806,12 @@ pub unsafe extern "C" fn processing_material_set_float4( a: f32, ) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); error::check(|| { + let name = unsafe { cstr_to_str(name) }?; material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float4([r, g, b, a]), + shader_value::ShaderValue::Float4([r, g, b, a]), ) }); } @@ -1824,6 +1830,176 @@ pub extern "C" fn processing_material(window_id: u64, mat_id: u64) { error::check(|| graphics_record_command(window_entity, DrawCommand::Material(mat_entity))); } +// Shader + +/// Create a shader from WGSL source. +/// +/// # Safety +/// - `source` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_create(source: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let source = unsafe { cstr_to_str(source) }?; + shader_create(source) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Load a shader from a file path. +/// +/// # Safety +/// - `path` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_load(path: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let path = unsafe { cstr_to_str(path) }?; + shader_load(path) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_shader_destroy(shader_id: u64) { + error::clear_error(); + error::check(|| shader_destroy(Entity::from_bits(shader_id))); +} + +// Buffer + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_create(size: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_create(size)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Create a buffer initialized with data. +/// +/// # Safety +/// - `data` must point to `len` valid bytes +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_create_with_data(data: *const u8, len: u64) -> u64 { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_create_with_data(bytes)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Write data to a buffer. +/// +/// # Safety +/// - `data` must point to `len` valid bytes +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_write(buf_id: u64, data: *const u8, len: u64) { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_write(Entity::from_bits(buf_id), bytes)); +} + +/// Returns the byte length of a buffer, or 0 if the buffer does not exist +/// (in which case the error is set). +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_size(buf_id: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_size(Entity::from_bits(buf_id))).unwrap_or(0) +} + +/// Read buffer contents into a caller-provided buffer. +/// +/// Returns the buffer's byte length. If the returned size is `<= out_len`, the +/// data has been written to `out`; otherwise `out` is left untouched and the +/// caller should reallocate and retry. Returns 0 if the buffer does not exist +/// or the GPU readback failed (in which case the error is set). +/// +/// # Safety +/// - `out` must be valid for writes of `out_len` bytes (may be null if +/// `out_len == 0`, in which case this acts as a size query). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_read(buf_id: u64, out: *mut u8, out_len: u64) -> u64 { + error::clear_error(); + let Some(data) = error::check(|| buffer_read(Entity::from_bits(buf_id))) else { + return 0; + }; + let needed = data.len() as u64; + if needed <= out_len { + unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), out, data.len()) }; + } + needed +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_destroy(buf_id: u64) { + error::clear_error(); + error::check(|| buffer_destroy(Entity::from_bits(buf_id))); +} + +// Compute + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_create(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| compute_create(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Set a float property on a compute shader. +/// +/// # Safety +/// - `name` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_compute_set_float( + compute_id: 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), + name, + shader_value::ShaderValue::Float(value), + ) + }); +} + +/// Set a buffer property on a compute shader. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_compute_set_buffer( + compute_id: 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), + name, + shader_value::ShaderValue::Buffer(Entity::from_bits(buf_id)), + ) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_dispatch(compute_id: u64, x: u32, y: u32, z: u32) { + error::clear_error(); + error::check(|| compute_dispatch(Entity::from_bits(compute_id), x, y, z)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_destroy(compute_id: u64) { + error::clear_error(); + error::check(|| compute_destroy(Entity::from_bits(compute_id))); +} + // Mouse buttons pub const PROCESSING_MOUSE_LEFT: u8 = 0; pub const PROCESSING_MOUSE_MIDDLE: u8 = 1; diff --git a/crates/processing_pyo3/examples/compute.py b/crates/processing_pyo3/examples/compute.py new file mode 100644 index 0000000..26fb5e0 --- /dev/null +++ b/crates/processing_pyo3/examples/compute.py @@ -0,0 +1,106 @@ +import struct + +from mewnala import Graphics, Shader, Compute, Buffer + +g = Graphics.new_offscreen(1, 1, "", None) +g.begin_draw() + +shader = Shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(1) +fn main() { + output[0] = 1u; + output[1] = 2u; + output[2] = 3u; + output[3] = 4u; +} +""") + +buf = Buffer(size=16) +compute = Compute(shader) +compute.set(output=buf) +compute.dispatch(1, 1, 1) + +data = buf.read() +assert isinstance(data, bytes), f"expected bytes, got {type(data)}" +assert list(struct.unpack("<4I", data)) == [1, 2, 3, 4] +print("PASS") + + +buf2 = Buffer(data=[10.0, 20.0, 30.0, 40.0]) +assert len(buf2) == 4 +assert buf2[0] == 10.0 +assert buf2[-1] == 40.0 +assert buf2[1:3] == [20.0, 30.0] + +buf2[2] = 99.0 +assert buf2[2] == 99.0 + +buf2[0:2] = [111.0, 222.0] +assert buf2[0] == 111.0 +assert buf2[1] == 222.0 +print("PASS") + + +double_shader = Shader(""" +@group(0) @binding(0) +var data: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + data[id.x] = data[id.x] * 2.0; +} +""") + +buf3 = Buffer(data=[1.0, 2.0, 3.0, 4.0]) +compute3 = Compute(double_shader) +compute3.set(data=buf3) +compute3.dispatch(1, 1, 1) +assert buf3.read() == [2.0, 4.0, 6.0, 8.0] +print("PASS") + + +compute3.dispatch(1, 1, 1) +assert buf3.read() == [4.0, 8.0, 12.0, 16.0] +print("PASS") + + +wg_shader = Shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + output[id.x] = id.x + 1u; +} +""") + +buf5 = Buffer(size=32) +compute5 = Compute(wg_shader) +compute5.set(output=buf5) +compute5.dispatch(2, 1, 1) +assert list(struct.unpack("<8I", buf5.read())) == [1, 2, 3, 4, 5, 6, 7, 8] +print("PASS") + + +copy_shader = Shader(""" +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + dst[id.x] = src[id.x] * 10.0; +} +""") + +src_buf = Buffer(data=[1.0, 2.0, 3.0, 4.0]) +dst_buf = Buffer(size=16) +compute6 = Compute(copy_shader) +compute6.set(src=src_buf, dst=dst_buf) +compute6.dispatch(1, 1, 1) +assert list(struct.unpack("<4f", dst_buf.read())) == [10.0, 20.0, 30.0, 40.0] +print("PASS") + +g.end_draw() \ No newline at end of file diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs new file mode 100644 index 0000000..83e6f28 --- /dev/null +++ b/crates/processing_pyo3/src/compute.rs @@ -0,0 +1,280 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{ + exceptions::{PyIndexError, PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::{PyBytes, PyList, PySlice, PySliceIndices}, +}; + +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; + +#[pyclass(unsendable)] +pub struct Buffer { + pub(crate) entity: Entity, + element_type: Option, + size: u64, +} + +#[pymethods] +impl Buffer { + #[new] + #[pyo3(signature = (size=None, data=None))] + pub fn new(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + let (entity, size, element_type) = if let Some(data) = data { + let (bytes, element_type) = shader_values_to_bytes(data)?; + let size = bytes.len() as u64; + let entity = buffer_create_with_data(bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, element_type) + } else { + let size = size.unwrap_or(0); + let entity = + buffer_create(size).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, None) + }; + Ok(Self { + entity, + element_type, + size, + }) + } + + pub fn __len__(&self) -> usize { + match &self.element_type { + Some(et) => et + .byte_size() + .map(|s| self.size as usize / s) + .unwrap_or(self.size as usize), + None => self.size as usize, + } + } + + pub fn __getitem__(&self, py: Python<'_>, index: &Bound<'_, PyAny>) -> PyResult> { + let Some(ref et) = self.element_type else { + return Err(PyTypeError::new_err("no element type; write values first")); + }; + let elem_size = et.byte_size().unwrap() as u64; + + let read = |i: isize| -> PyResult> { + let bytes = buffer_read_element(self.entity, i as u64 * elem_size, elem_size) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let sv = et + .read_from_bytes(&bytes) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode element"))?; + shader_value_to_py(py, &sv) + }; + + if let Ok(i) = index.extract::() { + Ok(read(self.normalize_index(i)? as isize)?.into()) + } else if let Ok(slice) = index.cast::() { + let indices = slice.indices(self.__len__() as isize)?; + let values = slice_positions(&indices) + .map(read) + .collect::>>()?; + Ok(PyList::new(py, values)?.into()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn __setitem__( + &mut self, + index: &Bound<'_, PyAny>, + value: &Bound<'_, PyAny>, + ) -> PyResult<()> { + if let Ok(i) = index.extract::() { + let sv = py_to_shader_value(value)?; + self.check_element_type(&sv)?; + let bytes = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = bytes.len() as u64; + let i = self.normalize_index(i)?; + buffer_write_element(self.entity, i as u64 * elem_size, bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } else if let Ok(slice) = index.cast::() { + let (src_bytes, element_type) = shader_values_to_bytes(value)?; + let et = element_type + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = et.byte_size().unwrap() as u64; + self.check_element_type(&et)?; + let indices = slice.indices(self.__len__() as isize)?; + let src_elems = src_bytes.len() as u64 / elem_size; + if indices.slicelength as u64 != src_elems { + return Err(PyValueError::new_err(format!( + "slice length {} does not match value length {}", + indices.slicelength, src_elems + ))); + } + for (pos, chunk) in + slice_positions(&indices).zip(src_bytes.chunks_exact(elem_size as usize)) + { + buffer_write_element(self.entity, pos as u64 * elem_size, chunk.to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn write(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + let (bytes, element_type) = shader_values_to_bytes(values)?; + self.element_type = element_type; + buffer_write(self.entity, bytes).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn read<'py>(&mut self, py: Python<'py>) -> PyResult> { + let data = buffer_read(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + let Some(ref template) = self.element_type else { + return Ok(PyBytes::new(py, &data).into_any()); + }; + + let elem_size = template + .byte_size() + .ok_or_else(|| PyRuntimeError::new_err("unsupported element type"))?; + + let values = data + .chunks_exact(elem_size) + .map(|chunk| { + let sv = template + .read_from_bytes(chunk) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode bytes"))?; + shader_value_to_py(py, &sv) + }) + .collect::>>()?; + + Ok(PyList::new(py, values)?.into_any()) + } +} + +impl Buffer { + fn check_element_type(&mut self, sv: &ShaderValue) -> PyResult<()> { + match &self.element_type { + Some(existing) if std::mem::discriminant(existing) != std::mem::discriminant(sv) => { + Err(PyTypeError::new_err(format!( + "buffer element type mismatch: expected {existing:?}, got {sv:?}" + ))) + } + Some(_) => Ok(()), + None => { + self.element_type = Some(sv.clone()); + Ok(()) + } + } + } + + fn normalize_index(&self, i: isize) -> PyResult { + let len = self.__len__() as isize; + let i = if i < 0 { len + i } else { i }; + if i < 0 || i >= len { + Err(PyIndexError::new_err("buffer index out of range")) + } else { + Ok(i as usize) + } + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + let _ = buffer_destroy(self.entity); + } +} + +fn slice_positions(indices: &PySliceIndices) -> impl Iterator + use<> { + let PySliceIndices { + start, + step, + slicelength, + .. + } = *indices; + (0..slicelength as isize).map(move |i| start + i * step) +} + +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?)?; + if let Some(ref existing) = element_type + && std::mem::discriminant(existing) != std::mem::discriminant(&sv) + { + return Err(PyTypeError::new_err(format!( + "buffer elements must all share the same type: expected {existing:?}, got {sv:?}" + ))); + } + let b = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + element_type.get_or_insert(sv); + bytes.extend_from_slice(&b); + } + Ok((bytes, element_type)) +} + +fn shader_value_to_py<'py>(py: Python<'py>, sv: &ShaderValue) -> PyResult> { + fn list<'py, T: pyo3::IntoPyObject<'py> + Copy>( + py: Python<'py>, + xs: &[T], + ) -> PyResult> { + Ok(PyList::new(py, xs.iter().copied())?.into_any()) + } + match sv { + ShaderValue::Float(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Int(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::UInt(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Float2(v) => list(py, v), + ShaderValue::Float3(v) => list(py, v), + ShaderValue::Float4(v) => list(py, v), + ShaderValue::Int2(v) => list(py, v), + ShaderValue::Int3(v) => list(py, v), + ShaderValue::Int4(v) => list(py, v), + ShaderValue::Mat4(v) => list(py, v), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => Err(PyRuntimeError::new_err( + "cannot convert Texture/Buffer to Python value", + )), + } +} + +#[pyclass(unsendable)] +pub struct Compute { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Compute { + #[new] + pub fn new(shader: &Shader) -> PyResult { + let entity = + compute_create(shader.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[pyo3(signature = (**kwargs))] + pub fn set(&self, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return Ok(()); + }; + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let value = py_to_shader_value(&value)?; + compute_set(self.entity, &name, value) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } + + pub fn dispatch(&self, x: u32, y: u32, z: u32) -> PyResult<()> { + compute_dispatch(self.entity, x, y, z).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Compute { + fn drop(&mut self) { + let _ = compute_destroy(self.entity); + } +} diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 7902979..fcb9dc8 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -9,6 +9,7 @@ //! To allow Python users to create a similar experience, we provide module-level //! functions that forward to a singleton Graphics object pub(crate) behind the scenes. pub(crate) mod color; +pub(crate) mod compute; #[cfg(feature = "cuda")] pub(crate) mod cuda; mod glfw; @@ -25,6 +26,7 @@ mod time; #[cfg(feature = "webcam")] mod webcam; +use compute::{Buffer, Compute}; use graphics::{ Geometry, Graphics, Image, Light, PyBlendMode, Topology, get_graphics, get_graphics_mut, }; @@ -325,6 +327,10 @@ fn detect_environment(py: Python<'_>) -> PyResult { mod mewnala { use super::*; + #[pymodule_export] + use super::Buffer; + #[pymodule_export] + use super::Compute; #[pymodule_export] use super::Geometry; #[pymodule_export] diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 757d2d9..b7e9bc5 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -3,6 +3,7 @@ use processing::prelude::*; use pyo3::types::PyDict; use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use crate::compute::Buffer; use crate::math::{PyVec2, PyVec3, PyVec4}; use crate::shader::Shader; @@ -11,34 +12,38 @@ pub struct Material { pub(crate) entity: Entity, } -fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult { +pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult { if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Float(v)); + return Ok(shader_value::ShaderValue::Float(v)); } if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Int(v)); + return Ok(shader_value::ShaderValue::Int(v)); } // Accept PyVec types if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float4(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float4(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float3(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float3(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float2(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float2(v.0.to_array())); + } + + if let Ok(buf) = value.extract::>() { + return Ok(shader_value::ShaderValue::Buffer(buf.entity)); } // Fall back to raw arrays if let Ok(v) = value.extract::<[f32; 4]>() { - return Ok(material::MaterialValue::Float4(v)); + return Ok(shader_value::ShaderValue::Float4(v)); } if let Ok(v) = value.extract::<[f32; 3]>() { - return Ok(material::MaterialValue::Float3(v)); + return Ok(shader_value::ShaderValue::Float3(v)); } if let Ok(v) = value.extract::<[f32; 2]>() { - return Ok(material::MaterialValue::Float2(v)); + return Ok(shader_value::ShaderValue::Float2(v)); } Err(PyRuntimeError::new_err(format!( @@ -63,8 +68,8 @@ impl Material { if let Some(kwargs) = kwargs { for (key, value) in kwargs.iter() { let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(mat.entity, &name, mat_value) + let value = py_to_shader_value(&value)?; + material_set(mat.entity, &name, value) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; } } @@ -78,8 +83,8 @@ impl Material { }; for (key, value) in kwargs.iter() { let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(self.entity, &name, mat_value) + let value = py_to_shader_value(&value)?; + material_set(self.entity, &name, value) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; } Ok(()) diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index a2097a3..11e25b4 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -21,7 +21,6 @@ raw-window-handle = "0.6" half = "2.7" crossbeam-channel = "0.5" processing_core = { workspace = true } -processing_midi = { workspace = true } [build-dependencies] wesl = { workspace = true, features = ["package"] } diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs new file mode 100644 index 0000000..dcf88fc --- /dev/null +++ b/crates/processing_render/src/compute.rs @@ -0,0 +1,366 @@ +use std::collections::BTreeSet; + +use bevy::asset::RenderAssetUsages; +use bevy::reflect::PartialReflect; +use bevy::{ + prelude::*, + render::{ + RenderApp, + render_asset::RenderAssets, + render_resource::{ + BindGroupLayoutDescriptor, Buffer as WgpuBuffer, BufferDescriptor, BufferUsages, + CachedComputePipelineId, CachedPipelineState, CommandEncoderDescriptor, + ComputePassDescriptor, ComputePipelineDescriptor, MapMode, PipelineCache, PollType, + }, + renderer::{RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + texture::GpuImage, + }, +}; + +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 processing_core::error::{ProcessingError, Result}; + +#[derive(Component)] +pub struct Buffer { + pub handle: Handle, + pub readback_buffer: WgpuBuffer, + pub size: u64, +} + +fn readback_buffer(device: &RenderDevice, size: u64) -> WgpuBuffer { + device.create_buffer(&BufferDescriptor { + label: Some("Buffer Readback"), + size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }) +} + +pub fn create_buffer( + In(size): In, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let handle = buffers.add(ShaderBuffer::with_size( + size as usize, + RenderAssetUsages::all(), + )); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + }) + .id() +} + +pub fn create_buffer_with_data( + In(data): In>, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let size = data.len() as u64; + let handle = buffers.add(ShaderBuffer::new(&data, RenderAssetUsages::all())); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + }) + .id() +} + +pub fn write_buffer_gpu( + In((handle, offset, data)): In<(Handle, u64, Vec)>, + gpu_buffers: Res>, + render_queue: Res, +) -> Result<()> { + let gpu_buffer = &gpu_buffers + .get(&handle) + .ok_or(ProcessingError::BufferNotFound)? + .buffer; + render_queue.write_buffer(gpu_buffer, offset, &data); + Ok(()) +} + +pub fn read_buffer_gpu( + In((handle, readback_buffer, src_offset, len)): In<( + Handle, + WgpuBuffer, + u64, + u64, + )>, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, +) -> Result> { + let gpu_buffer = &gpu_buffers + .get(&handle) + .ok_or(ProcessingError::BufferNotFound)? + .buffer; + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + encoder.copy_buffer_to_buffer(gpu_buffer, src_offset, &readback_buffer, 0, len); + render_queue.submit(std::iter::once(encoder.finish())); + + let buffer_slice = readback_buffer.slice(0..len); + let (s, r) = crossbeam_channel::bounded(1); + buffer_slice.map_async(MapMode::Read, move |result| { + let _ = s.send(result); + }); + render_device + .poll(PollType::wait_indefinitely()) + .map_err(|e| ProcessingError::BufferMapError(format!("poll failed: {e}")))?; + r.recv() + .map_err(|e| ProcessingError::BufferMapError(format!("map channel closed: {e}")))? + .map_err(|e| ProcessingError::BufferMapError(format!("map failed: {e}")))?; + + let data = buffer_slice.get_mapped_range().to_vec(); + readback_buffer.unmap(); + + Ok(data) +} + +pub fn destroy_buffer(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} + +#[derive(Component)] +pub struct Compute { + pub shader: DynamicShader, + pub entry_point: String, + pub pipeline_id: CachedComputePipelineId, + pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, +} + +fn queue_pipeline( + In(descriptor): In, + pipeline_cache: Res, +) -> CachedComputePipelineId { + pipeline_cache.queue_compute_pipeline(descriptor) +} + +fn pump_pipeline( + In(id): In, + mut pipeline_cache: ResMut, +) -> Result { + pipeline_cache.process_queue(); + match pipeline_cache.get_compute_pipeline_state(id) { + CachedPipelineState::Ok(_) => Ok(true), + CachedPipelineState::Err(e) => Err(ProcessingError::PipelineCompileError(format!("{e}"))), + _ => Ok(false), + } +} + +pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { + let (module, shader_handle) = { + let program = app + .world() + .get::(shader_entity) + .ok_or(ProcessingError::ShaderNotFound)?; + (program.module.clone(), program.shader_handle.clone()) + }; + + let compute_ep = module + .entry_points + .iter() + .find(|ep| ep.stage == naga::ShaderStage::Compute) + .ok_or_else(|| { + ProcessingError::ShaderCompilationError( + "Shader has no @compute entry point".to_string(), + ) + })?; + let entry_point = compute_ep.name.clone(); + + let mut shader = DynamicShader::new(module) + .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; + shader.init(); + + let reflection = shader.reflection(); + let groups: BTreeSet = reflection.parameters().map(|p| p.group()).collect(); + + let bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)> = groups + .iter() + .map(|&group| { + let entries = reflection.bind_group_layout(group); + ( + group, + BindGroupLayoutDescriptor { + label: "compute_bind_group_layout".into(), + entries, + }, + ) + }) + .collect(); + + let max_group = groups.iter().last().copied().map_or(0, |g| g + 1); + let mut layout_descriptors = + vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + for (group, desc) in &bind_group_layout_descriptors { + layout_descriptors[*group as usize] = desc.clone(); + } + + let descriptor = ComputePipelineDescriptor { + label: Some("processing_compute".into()), + layout: layout_descriptors, + immediate_size: 0, + shader: shader_handle.clone(), + shader_defs: Vec::new(), + entry_point: Some(entry_point.clone().into()), + zero_initialize_workgroup_memory: true, + }; + + let pipeline_id = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(queue_pipeline, descriptor) + .unwrap(); + + const MAX_WAIT: u32 = 64; + for _ in 0..MAX_WAIT { + app.update(); + let done = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(pump_pipeline, pipeline_id) + .unwrap()?; + if done { + return Ok(app + .world_mut() + .spawn(Compute { + shader, + entry_point, + pipeline_id, + bind_group_layout_descriptors, + }) + .id()); + } + } + Err(ProcessingError::PipelineNotReady(MAX_WAIT)) +} + +pub fn set_compute_property( + In((entity, name, value)): In<(Entity, String, ShaderValue)>, + mut computes: Query<&mut Compute>, + p_buffers: Query<&Buffer>, + p_images: Query<&PImage>, +) -> Result<()> { + use bevy_naga_reflect::reflect::ParameterCategory; + + let mut compute = computes + .get_mut(entity) + .map_err(|_| ProcessingError::ComputeNotFound)?; + + let category = compute + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + + match (&value, category) { + (ShaderValue::Buffer(buf_entity), ParameterCategory::Storage { .. }) => { + let buffer = p_buffers + .get(*buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + compute.shader.insert(&name, buffer.handle.clone()); + Ok(()) + } + (ShaderValue::Texture(img_entity), ParameterCategory::Texture) + | (ShaderValue::Texture(img_entity), ParameterCategory::StorageTexture) => { + let image = p_images + .get(*img_entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + compute.shader.insert(&name, image.handle.clone()); + Ok(()) + } + (ShaderValue::Buffer(_), cat) | (ShaderValue::Texture(_), cat) => { + Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {cat:?}, got {value:?}", + ))) + } + (_, ParameterCategory::Uniform) => { + let reflect_value: Box = shader_value_to_reflect(&value)?; + apply_reflect_field(&mut compute.shader, &name, &*reflect_value) + } + (_, cat) => Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {cat:?}, got non-resource value" + ))), + } +} + +pub fn dispatch( + In((pipeline_id, layout_descriptors, shader, x, y, z)): In<( + CachedComputePipelineId, + Vec<(u32, BindGroupLayoutDescriptor)>, + DynamicShader, + u32, + u32, + u32, + )>, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + gpu_buffers: Res>, +) -> Result<()> { + let pipeline = pipeline_cache + .get_compute_pipeline(pipeline_id) + .ok_or(ProcessingError::PipelineNotReady(0))? + .clone(); + + let reflection = shader.reflection(); + + let mut bind_groups = Vec::new(); + for (group, desc) in &layout_descriptors { + let layout = pipeline_cache.get_bind_group_layout(desc); + let bindings = + reflection.create_bindings(*group, &shader, &render_device, &gpu_images, &gpu_buffers); + + let bind_group_entries: Vec<_> = bindings + .iter() + .map( + |(binding, resource)| bevy::render::render_resource::BindGroupEntry { + binding: *binding, + resource: resource.get_binding(), + }, + ) + .collect(); + + let bind_group = render_device.create_bind_group( + Some("compute_bind_group"), + &layout, + &bind_group_entries, + ); + bind_groups.push(bind_group); + } + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + { + let mut pass = encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("compute_pass"), + ..Default::default() + }); + pass.set_pipeline(&pipeline); + for ((group, _), bg) in layout_descriptors.iter().zip(bind_groups.iter()) { + pass.set_bind_group(*group, bg, &[]); + } + pass.dispatch_workgroups(x, y, z); + } + render_queue.submit(std::iter::once(encoder.finish())); + + Ok(()) +} + +pub fn destroy_compute(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 8d70629..f6d3e96 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2,6 +2,7 @@ pub mod camera; pub mod color; +pub mod compute; pub mod geometry; pub mod gltf; pub mod graphics; @@ -10,6 +11,7 @@ pub mod light; pub mod material; pub mod monitor; pub mod render; +pub mod shader_value; pub mod sketch; pub mod surface; pub mod time; @@ -1416,7 +1418,7 @@ pub fn material_create_pbr() -> error::Result { pub fn material_set( entity: Entity, name: impl Into, - value: material::MaterialValue, + value: shader_value::ShaderValue, ) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1633,3 +1635,169 @@ pub fn gltf_light(gltf_entity: Entity, index: usize) -> error::Result { .unwrap() }) } + +pub fn buffer_create(size: u64) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer, size) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_create_with_data(data: Vec) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer_with_data, data) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_size(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size) + }) +} + +pub fn buffer_write(entity: Entity, data: Vec) -> error::Result<()> { + buffer_write_range(entity, 0, data, true) +} + +pub fn buffer_write_element(entity: Entity, offset: u64, data: Vec) -> error::Result<()> { + buffer_write_range(entity, offset, data, false) +} + +fn buffer_write_range( + entity: Entity, + offset: u64, + data: Vec, + exact_size: bool, +) -> error::Result<()> { + app_mut(|app| { + let (handle, size) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + (buf.handle.clone(), buf.size) + }; + let end = offset.checked_add(data.len() as u64).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if exact_size && (offset != 0 || end != size) { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer_write data length {} does not match buffer size {size}; \ + destroy and re-create to resize, or use buffer_write_element for partial writes", + data.len() + ))); + } + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer write out of bounds: offset {offset} + len {} > size {size}", + data.len() + ))); + } + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(compute::write_buffer_gpu, (handle, offset, data)) + .unwrap() + }) +} + +pub fn buffer_read_element(entity: Entity, offset: u64, len: u64) -> error::Result> { + buffer_read_range(entity, offset, len) +} + +pub fn buffer_read(entity: Entity) -> error::Result> { + let size = buffer_size(entity)?; + buffer_read_range(entity, 0, size) +} + +fn buffer_read_range(entity: Entity, offset: u64, len: u64) -> error::Result> { + app_mut(|app| { + let (handle, readback_buffer, size) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + (buf.handle.clone(), buf.readback_buffer.clone(), buf.size) + }; + let end = offset.checked_add(len).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer read out of bounds: offset {offset} + len {len} > size {size}" + ))); + } + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with( + compute::read_buffer_gpu, + (handle, readback_buffer, offset, len), + ) + .unwrap() + }) +} + +pub fn buffer_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_buffer, entity) + .unwrap() + }) +} + +pub fn compute_create(shader_entity: Entity) -> error::Result { + app_mut(|app| compute::create_compute(app, shader_entity)) +} + +pub fn compute_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::set_compute_property, (entity, name.into(), value)) + .unwrap() + }) +} + +pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { + app_mut(|app| { + let c = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + let args = ( + c.pipeline_id, + c.bind_group_layout_descriptors.clone(), + c.shader.clone(), + x, + y, + z, + ); + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(compute::dispatch, args) + .unwrap() + }) +} + +pub fn compute_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_compute, entity) + .unwrap() + }) +} diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 04080b8..19f4263 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -51,8 +51,8 @@ use bevy_naga_reflect::dynamic_shader::DynamicShader; use bevy::shader::Shader as ShaderAsset; -use crate::material::MaterialValue; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use processing_core::config::{Config, ConfigKey}; use processing_core::error::{ProcessingError, Result}; @@ -265,52 +265,63 @@ pub fn create_custom( Ok(commands.spawn(UntypedMaterial(handle.untyped())).id()) } -pub fn set_property( - material: &mut CustomMaterial, +pub fn set_property(material: &mut CustomMaterial, name: &str, value: &ShaderValue) -> Result<()> { + let reflect_value: Box = shader_value_to_reflect(value)?; + apply_reflect_field(&mut material.shader, name, &*reflect_value) +} + +pub(crate) fn apply_reflect_field( + shader: &mut DynamicShader, name: &str, - value: &MaterialValue, + value: &dyn PartialReflect, ) -> Result<()> { - let reflect_value: Box = material_value_to_reflect(value)?; - - if let Some(field) = material.shader.field_mut(name) { - field.apply(&*reflect_value); + if let Some(field) = shader.field_mut(name) { + field.apply(value); return Ok(()); } - let param_name = find_param_containing_field(&material.shader, name); + let param_name = find_param_containing_field(shader, name); if let Some(param_name) = param_name - && let Some(param) = material.shader.field_mut(¶m_name) + && let Some(param) = shader.field_mut(¶m_name) && let ReflectMut::Struct(s) = param.reflect_mut() && let Some(field) = s.field_mut(name) { - field.apply(&*reflect_value); + field.apply(value); return Ok(()); } - Err(ProcessingError::UnknownMaterialProperty(name.to_string())) + Err(ProcessingError::UnknownShaderProperty(name.to_string())) } -fn material_value_to_reflect(value: &MaterialValue) -> Result> { +pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result> { Ok(match value { - MaterialValue::Float(v) => Box::new(*v), - MaterialValue::Float2(v) => Box::new(Vec2::from_array(*v)), - MaterialValue::Float3(v) => Box::new(Vec3::from_array(*v)), - MaterialValue::Float4(v) => Box::new(Vec4::from_array(*v)), - MaterialValue::Int(v) => Box::new(*v), - MaterialValue::Int2(v) => Box::new(IVec2::from_array(*v)), - MaterialValue::Int3(v) => Box::new(IVec3::from_array(*v)), - MaterialValue::Int4(v) => Box::new(IVec4::from_array(*v)), - MaterialValue::UInt(v) => Box::new(*v), - MaterialValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), - MaterialValue::Texture(_) => { - return Err(ProcessingError::UnknownMaterialProperty( + ShaderValue::Float(v) => Box::new(*v), + ShaderValue::Float2(v) => Box::new(Vec2::from_array(*v)), + ShaderValue::Float3(v) => Box::new(Vec3::from_array(*v)), + ShaderValue::Float4(v) => Box::new(Vec4::from_array(*v)), + ShaderValue::Int(v) => Box::new(*v), + ShaderValue::Int2(v) => Box::new(IVec2::from_array(*v)), + ShaderValue::Int3(v) => Box::new(IVec3::from_array(*v)), + ShaderValue::Int4(v) => Box::new(IVec4::from_array(*v)), + ShaderValue::UInt(v) => Box::new(*v), + ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), + ShaderValue::Texture(_) => { + return Err(ProcessingError::UnknownShaderProperty( "Texture properties not yet supported for custom materials".to_string(), )); } + ShaderValue::Buffer(_) => { + return Err(ProcessingError::UnknownShaderProperty( + "Buffer properties not supported for custom materials".to_string(), + )); + } }) } -fn find_param_containing_field(shader: &DynamicShader, field_name: &str) -> Option { +pub(crate) fn find_param_containing_field( + shader: &DynamicShader, + field_name: &str, +) -> Option { for i in 0..shader.field_len() { if let Some(field) = shader.field_at(i) && let ReflectRef::Struct(s) = field.reflect_ref() diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index be48836..b24f33b 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -2,6 +2,7 @@ pub mod custom; pub mod pbr; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use bevy::material::descriptor::RenderPipelineDescriptor; use bevy::material::specialize::SpecializedMeshPipelineError; use bevy::mesh::MeshVertexBufferLayoutRef; @@ -38,21 +39,6 @@ impl Plugin for ProcessingMaterialPlugin { #[derive(Resource)] pub struct DefaultMaterial(pub Entity); -#[derive(Debug, Clone)] -pub enum MaterialValue { - Float(f32), - Float2([f32; 2]), - Float3([f32; 3]), - Float4([f32; 4]), - Int(i32), - Int2([i32; 2]), - Int3([i32; 3]), - Int4([i32; 4]), - UInt(u32), - Mat4([f32; 16]), - Texture(Entity), -} - pub fn create_pbr( mut commands: Commands, mut materials: ResMut>>, @@ -69,7 +55,7 @@ pub fn create_pbr( } pub fn set_property( - In((entity, name, value)): In<(Entity, String, MaterialValue)>, + In((entity, name, value)): In<(Entity, String, ShaderValue)>, material_handles: Query<&UntypedMaterial>, mut extended_materials: ResMut>>, mut custom_materials: ResMut>, diff --git a/crates/processing_render/src/material/pbr.rs b/crates/processing_render/src/material/pbr.rs index df3df23..c4f7a37 100644 --- a/crates/processing_render/src/material/pbr.rs +++ b/crates/processing_render/src/material/pbr.rs @@ -1,17 +1,17 @@ use bevy::prelude::*; -use super::MaterialValue; +use crate::shader_value::ShaderValue; use processing_core::error::{ProcessingError, Result}; /// Set a property on a StandardMaterial by name. pub fn set_property( material: &mut StandardMaterial, name: &str, - value: &MaterialValue, + value: &ShaderValue, ) -> Result<()> { match name { "base_color" | "color" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); @@ -19,7 +19,7 @@ pub fn set_property( material.base_color = Color::srgba(c[0], c[1], c[2], c[3]); } "metallic" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -27,7 +27,7 @@ pub fn set_property( material.metallic = *v; } "roughness" | "perceptual_roughness" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -35,7 +35,7 @@ pub fn set_property( material.perceptual_roughness = *v; } "reflectance" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -43,7 +43,7 @@ pub fn set_property( material.reflectance = *v; } "emissive" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); @@ -51,7 +51,7 @@ pub fn set_property( material.emissive = LinearRgba::new(c[0], c[1], c[2], c[3]); } "unlit" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -59,7 +59,7 @@ pub fn set_property( material.unlit = *v > 0.5; } "double_sided" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -67,7 +67,7 @@ pub fn set_property( material.double_sided = *v > 0.5; } "alpha_mode" => { - let MaterialValue::Int(v) = value else { + let ShaderValue::Int(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Int, got {value:?}" ))); @@ -88,7 +88,7 @@ pub fn set_property( }; } _ => { - return Err(ProcessingError::UnknownMaterialProperty(name.to_string())); + return Err(ProcessingError::UnknownShaderProperty(name.to_string())); } } Ok(()) diff --git a/crates/processing_render/src/shader_value.rs b/crates/processing_render/src/shader_value.rs new file mode 100644 index 0000000..9d2c77d --- /dev/null +++ b/crates/processing_render/src/shader_value.rs @@ -0,0 +1,82 @@ +use bevy::prelude::*; + +#[derive(Debug, Clone)] +pub enum ShaderValue { + Float(f32), + Float2([f32; 2]), + Float3([f32; 3]), + Float4([f32; 4]), + Int(i32), + Int2([i32; 2]), + Int3([i32; 3]), + Int4([i32; 4]), + UInt(u32), + Mat4([f32; 16]), + Texture(Entity), + Buffer(Entity), +} + +impl ShaderValue { + pub fn to_bytes(&self) -> Option> { + match self { + ShaderValue::Float(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Float2(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Float3(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Float4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Int(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Int2(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::Int3(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::Int4(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::UInt(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Mat4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } + + pub fn byte_size(&self) -> Option { + match self { + ShaderValue::Float(_) | ShaderValue::Int(_) | ShaderValue::UInt(_) => Some(4), + ShaderValue::Float2(_) | ShaderValue::Int2(_) => Some(8), + ShaderValue::Float3(_) | ShaderValue::Int3(_) => Some(12), + ShaderValue::Float4(_) | ShaderValue::Int4(_) => Some(16), + ShaderValue::Mat4(_) => Some(64), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } + + pub fn read_from_bytes(&self, bytes: &[u8]) -> Option { + fn f32s(bytes: &[u8]) -> Option<[f32; N]> { + let mut arr = [0f32; N]; + for i in 0..N { + arr[i] = f32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().ok()?); + } + Some(arr) + } + fn i32s(bytes: &[u8]) -> Option<[i32; N]> { + let mut arr = [0i32; N]; + for i in 0..N { + arr[i] = i32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().ok()?); + } + Some(arr) + } + match self { + ShaderValue::Float(_) => Some(ShaderValue::Float(f32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Float2(_) => Some(ShaderValue::Float2(f32s::<2>(bytes)?)), + ShaderValue::Float3(_) => Some(ShaderValue::Float3(f32s::<3>(bytes)?)), + ShaderValue::Float4(_) => Some(ShaderValue::Float4(f32s::<4>(bytes)?)), + ShaderValue::Int(_) => Some(ShaderValue::Int(i32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Int2(_) => Some(ShaderValue::Int2(i32s::<2>(bytes)?)), + ShaderValue::Int3(_) => Some(ShaderValue::Int3(i32s::<3>(bytes)?)), + ShaderValue::Int4(_) => Some(ShaderValue::Int4(i32s::<4>(bytes)?)), + ShaderValue::UInt(_) => Some(ShaderValue::UInt(u32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Mat4(_) => Some(ShaderValue::Mat4(f32s::<16>(bytes)?)), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } +} diff --git a/crates/processing_wasm/src/lib.rs b/crates/processing_wasm/src/lib.rs index 120a9fe..0372ce4 100644 --- a/crates/processing_wasm/src/lib.rs +++ b/crates/processing_wasm/src/lib.rs @@ -744,7 +744,7 @@ pub fn js_material_set_float(mat_id: u64, name: &str, value: f32) -> Result<(), check(material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float(value), + shader_value::ShaderValue::Float(value), )) } @@ -760,7 +760,7 @@ pub fn js_material_set_float4( check(material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float4([r, g, b, a]), + shader_value::ShaderValue::Float4([r, g, b, a]), )) } diff --git a/examples/compute_readback.rs b/examples/compute_readback.rs new file mode 100644 index 0000000..f470274 --- /dev/null +++ b/examples/compute_readback.rs @@ -0,0 +1,90 @@ +use processing::prelude::*; + +fn main() { + match run() { + Ok(_) => { + eprintln!("Compute readback test passed!"); + exit(0).unwrap(); + } + Err(e) => { + eprintln!("Compute readback error: {:?}", e); + exit(1).unwrap(); + } + } +} + +fn run() -> error::Result<()> { + init(Config::default())?; + + let surface = surface_create_offscreen(1, 1, 1.0, TextureFormat::Rgba8Unorm)?; + let _graphics = graphics_create(surface, 1, 1, TextureFormat::Rgba8Unorm)?; + + let buf = buffer_create(16)?; + + let shader_src = r#" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(1) +fn main() { + output[0] = 1u; + output[1] = 2u; + output[2] = 3u; + output[3] = 4u; +} +"#; + let shader = shader_create(shader_src)?; + let compute = compute_create(shader)?; + compute_set(compute, "output", shader_value::ShaderValue::Buffer(buf))?; + + compute_dispatch(compute, 1, 1, 1)?; + + let data = buffer_read(buf)?; + let values: Vec = data + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + + assert_eq!(values, vec![1, 2, 3, 4], "Compute readback mismatch!"); + eprintln!("PASS"); + + let double_src = r#" +@group(0) @binding(0) +var data: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + data[id.x] = data[id.x] * 2.0; +} +"#; + let buf2_data: Vec = [1.0f32, 2.0, 3.0, 4.0] + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + let buf2 = buffer_create_with_data(buf2_data)?; + let shader2 = shader_create(double_src)?; + let compute2 = compute_create(shader2)?; + compute_set(compute2, "data", shader_value::ShaderValue::Buffer(buf2))?; + compute_dispatch(compute2, 1, 1, 1)?; + + let data2 = buffer_read(buf2)?; + let floats: Vec = data2 + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + assert_eq!( + floats, + vec![2.0, 4.0, 6.0, 8.0], + "In-place double mismatch!" + ); + eprintln!("PASS"); + + compute_destroy(compute)?; + compute_destroy(compute2)?; + shader_destroy(shader)?; + shader_destroy(shader2)?; + buffer_destroy(buf)?; + buffer_destroy(buf2)?; + + Ok(()) +} diff --git a/examples/custom_material.rs b/examples/custom_material.rs index b29feed..aea1b34 100644 --- a/examples/custom_material.rs +++ b/examples/custom_material.rs @@ -36,7 +36,7 @@ fn sketch() -> error::Result<()> { material_set( mat, "color", - material::MaterialValue::Float4([1.0, 0.2, 0.4, 1.0]), + shader_value::ShaderValue::Float4([1.0, 0.2, 0.4, 1.0]), )?; let mut angle = 0.0; diff --git a/examples/gltf_load.rs b/examples/gltf_load.rs index b261539..8d86958 100644 --- a/examples/gltf_load.rs +++ b/examples/gltf_load.rs @@ -2,8 +2,8 @@ use processing_glfw::GlfwContext; use bevy::math::Vec3; use processing::prelude::*; -use processing_render::material::MaterialValue; use processing_render::render::command::DrawCommand; +use processing_render::shader_value::ShaderValue; fn main() { match sketch() { @@ -50,11 +50,7 @@ fn sketch() -> error::Result<()> { let r = (t * 8.0).sin() * 0.5 + 0.5; let g = (t * 8.0 + 2.0).sin() * 0.5 + 0.5; let b = (t * 8.0 + 4.0).sin() * 0.5 + 0.5; - material_set( - duck_mat, - "base_color", - MaterialValue::Float4([r, g, b, 1.0]), - )?; + material_set(duck_mat, "base_color", ShaderValue::Float4([r, g, b, 1.0]))?; graphics_begin_draw(graphics)?; diff --git a/examples/lights.rs b/examples/lights.rs index b2d0f21..b111591 100644 --- a/examples/lights.rs +++ b/examples/lights.rs @@ -27,7 +27,7 @@ fn sketch() -> error::Result<()> { let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; let box_geo = geometry_box(100.0, 100.0, 100.0)?; let pbr_mat = material_create_pbr()?; - material_set(pbr_mat, "roughness", material::MaterialValue::Float(0.0))?; + material_set(pbr_mat, "roughness", shader_value::ShaderValue::Float(0.0))?; // We will only declare lights in `setup` // rather than calling some sort of `light()` method inside of `draw` diff --git a/examples/materials.rs b/examples/materials.rs index 5ec92d8..306ac6f 100644 --- a/examples/materials.rs +++ b/examples/materials.rs @@ -51,8 +51,12 @@ fn sketch() -> error::Result<()> { let roughness = col as f32 / (cols - 1) as f32; let metallic = row as f32 / (rows - 1) as f32; - material_set(mat, "roughness", material::MaterialValue::Float(roughness))?; - material_set(mat, "metallic", material::MaterialValue::Float(metallic))?; + material_set( + mat, + "roughness", + shader_value::ShaderValue::Float(roughness), + )?; + material_set(mat, "metallic", shader_value::ShaderValue::Float(metallic))?; materials.push(mat); } } diff --git a/examples/primitives_3d.rs b/examples/primitives_3d.rs index adadc6e..1de2bbc 100644 --- a/examples/primitives_3d.rs +++ b/examples/primitives_3d.rs @@ -24,7 +24,7 @@ fn sketch() -> error::Result<()> { light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; let pbr = material_create_pbr()?; - material_set(pbr, "roughness", material::MaterialValue::Float(0.35))?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.35))?; let mut t: f32 = 0.0; From 5cb139adc7953aab6413e7dc3d0971c26e52d416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Mon, 27 Apr 2026 10:09:30 -0700 Subject: [PATCH 02/11] . --- crates/processing_ffi/src/lib.rs | 5 - crates/processing_render/src/compute.rs | 68 ++++++++---- crates/processing_render/src/graphics.rs | 30 ++++++ crates/processing_render/src/lib.rs | 128 +++++++++++++++++------ 4 files changed, 173 insertions(+), 58 deletions(-) diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index eff9470..a26d233 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -1912,11 +1912,6 @@ pub extern "C" fn processing_buffer_size(buf_id: u64) -> u64 { /// Read buffer contents into a caller-provided buffer. /// -/// Returns the buffer's byte length. If the returned size is `<= out_len`, the -/// data has been written to `out`; otherwise `out` is left untouched and the -/// caller should reallocate and retry. Returns 0 if the buffer does not exist -/// or the GPU readback failed (in which case the error is set). -/// /// # Safety /// - `out` must be valid for writes of `out_len` bytes (may be null if /// `out_len == 0`, in which case this acts as a size query). diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index dcf88fc..2759ea0 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use bevy::asset::RenderAssetUsages; use bevy::reflect::PartialReflect; @@ -30,6 +30,10 @@ pub struct Buffer { pub handle: Handle, pub readback_buffer: WgpuBuffer, pub size: u64, + /// True when `ShaderBuffer.data` reflects current GPU contents. Cleared + /// when a pipeline that may write to the buffer runs; the next read or + /// write must readback first. + pub synced: bool, } fn readback_buffer(device: &RenderDevice, size: u64) -> WgpuBuffer { @@ -47,8 +51,8 @@ pub fn create_buffer( mut buffers: ResMut>, render_device: Res, ) -> Entity { - let handle = buffers.add(ShaderBuffer::with_size( - size as usize, + let handle = buffers.add(ShaderBuffer::new( + &vec![0u8; size as usize], RenderAssetUsages::all(), )); commands @@ -56,6 +60,7 @@ pub fn create_buffer( handle, readback_buffer: readback_buffer(&render_device, size), size, + synced: true, }) .id() } @@ -73,30 +78,37 @@ pub fn create_buffer_with_data( handle, readback_buffer: readback_buffer(&render_device, size), size, + synced: true, }) .id() } -pub fn write_buffer_gpu( +/// Mutate the CPU-side data of a `ShaderBuffer` in place. Fires +/// `AssetEvent::Modified` so Bevy's render-asset extract uploads the new +/// contents to the GPU at the next sync point. +pub fn write_buffer_cpu( In((handle, offset, data)): In<(Handle, u64, Vec)>, - gpu_buffers: Res>, - render_queue: Res, + mut buffers: ResMut>, ) -> Result<()> { - let gpu_buffer = &gpu_buffers - .get(&handle) - .ok_or(ProcessingError::BufferNotFound)? - .buffer; - render_queue.write_buffer(gpu_buffer, offset, &data); + let mut asset = buffers + .get_mut(&handle) + .ok_or(ProcessingError::BufferNotFound)?; + let dst = asset + .data + .as_mut() + .ok_or(ProcessingError::BufferNotFound)?; + let start = offset as usize; + let end = start + data.len(); + dst[start..end].copy_from_slice(&data); Ok(()) } +/// Copy the GPU buffer back to CPU and return its full contents. Runs in the +/// render world; the caller is responsible for writing the bytes back into +/// `ShaderBuffer.data` via `Assets::get_mut_untracked` (avoiding spurious +/// `AssetEvent::Modified`s, since this is a readback, not a stage-for-upload). pub fn read_buffer_gpu( - In((handle, readback_buffer, src_offset, len)): In<( - Handle, - WgpuBuffer, - u64, - u64, - )>, + In((handle, readback_buffer, size)): In<(Handle, WgpuBuffer, u64)>, gpu_buffers: Res>, render_device: Res, render_queue: Res, @@ -107,10 +119,10 @@ pub fn read_buffer_gpu( .buffer; let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); - encoder.copy_buffer_to_buffer(gpu_buffer, src_offset, &readback_buffer, 0, len); + encoder.copy_buffer_to_buffer(gpu_buffer, 0, &readback_buffer, 0, size); render_queue.submit(std::iter::once(encoder.finish())); - let buffer_slice = readback_buffer.slice(0..len); + let buffer_slice = readback_buffer.slice(0..size); let (s, r) = crossbeam_channel::bounded(1); buffer_slice.map_async(MapMode::Read, move |result| { let _ = s.send(result); @@ -122,10 +134,9 @@ pub fn read_buffer_gpu( .map_err(|e| ProcessingError::BufferMapError(format!("map channel closed: {e}")))? .map_err(|e| ProcessingError::BufferMapError(format!("map failed: {e}")))?; - let data = buffer_slice.get_mapped_range().to_vec(); + let bytes = buffer_slice.get_mapped_range().to_vec(); readback_buffer.unmap(); - - Ok(data) + Ok(bytes) } pub fn destroy_buffer(In(entity): In, mut commands: Commands) -> Result<()> { @@ -139,6 +150,11 @@ pub struct Compute { pub entry_point: String, pub pipeline_id: CachedComputePipelineId, pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, + /// Buffer entities bound to this compute on a `read_write` storage param. + /// Their CPU view of GPU data is invalidated after each dispatch so the + /// next read/write does a readback. Read-only bindings don't need this + /// since the dispatch can't mutate them. + pub rw_buffers: HashMap, } fn queue_pipeline( @@ -240,6 +256,7 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { entry_point, pipeline_id, bind_group_layout_descriptors, + rw_buffers: HashMap::new(), }) .id()); } @@ -267,11 +284,16 @@ pub fn set_compute_property( .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; match (&value, category) { - (ShaderValue::Buffer(buf_entity), ParameterCategory::Storage { .. }) => { + (ShaderValue::Buffer(buf_entity), ParameterCategory::Storage { read_only }) => { let buffer = p_buffers .get(*buf_entity) .map_err(|_| ProcessingError::BufferNotFound)?; compute.shader.insert(&name, buffer.handle.clone()); + if read_only { + compute.rw_buffers.remove(&name); + } else { + compute.rw_buffers.insert(name.clone(), *buf_entity); + } Ok(()) } (ShaderValue::Texture(img_entity), ParameterCategory::Texture) diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index f885750..72bb027 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -453,6 +453,36 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { Ok(()) } +/// Flush all graphics with pending commands and run a frame so any other +/// pending GPU state (asset writes, etc.) is extracted and uploaded. Used as +/// a sync boundary before operations like compute dispatch that may bind +/// graphics targets or recently-mutated assets. +pub fn flush_all(app: &mut App) { + let mut to_flush = Vec::new(); + let world = app.world_mut(); + let mut q = world.query::<(Entity, &CommandBuffer, &Graphics)>(); + for (e, cb, _) in q.iter(world) { + if !cb.commands.is_empty() { + to_flush.push(e); + } + } + + for e in &to_flush { + if let Ok(mut em) = world.get_entity_mut(*e) { + em.insert(Flush); + } + } + + app.update(); + + let world = app.world_mut(); + for e in &to_flush { + if let Ok(mut em) = world.get_entity_mut(*e) { + em.remove::(); + } + } +} + 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 f6d3e96..6c2dbd4 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1676,6 +1676,49 @@ pub fn buffer_write_element(entity: Entity, offset: u64, data: Vec) -> error buffer_write_range(entity, offset, data, false) } +/// Ensure `ShaderBuffer.data` reflects current GPU contents by reading the +/// buffer back into the asset if it was invalidated by a prior dispatch. +/// Subsequent reads/writes can then operate on the in-asset bytes directly. +fn ensure_buffer_synced(app: &mut App, entity: Entity) -> error::Result<()> { + let (handle, readback_buffer, size, synced) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + ( + buf.handle.clone(), + buf.readback_buffer.clone(), + buf.size, + buf.synced, + ) + }; + if synced { + return Ok(()); + } + let bytes = app + .sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with( + compute::read_buffer_gpu, + (handle.clone(), readback_buffer, size), + ) + .unwrap()?; + + let world = app.world_mut(); + let mut buffers = world.resource_mut::>(); + let asset = buffers + .get_mut_untracked(handle.id()) + .ok_or(error::ProcessingError::BufferNotFound)?; + asset.data = Some(bytes); + drop(buffers); + + let mut buf = world + .get_mut::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + buf.synced = true; + Ok(()) +} + fn buffer_write_range( entity: Entity, offset: u64, @@ -1706,9 +1749,9 @@ fn buffer_write_range( data.len() ))); } - app.sub_app_mut(bevy::render::RenderApp) - .world_mut() - .run_system_cached_with(compute::write_buffer_gpu, (handle, offset, data)) + ensure_buffer_synced(app, entity)?; + app.world_mut() + .run_system_cached_with(compute::write_buffer_cpu, (handle, offset, data)) .unwrap() }) } @@ -1724,13 +1767,11 @@ pub fn buffer_read(entity: Entity) -> error::Result> { fn buffer_read_range(entity: Entity, offset: u64, len: u64) -> error::Result> { app_mut(|app| { - let (handle, readback_buffer, size) = { - let buf = app - .world() - .get::(entity) - .ok_or(error::ProcessingError::BufferNotFound)?; - (buf.handle.clone(), buf.readback_buffer.clone(), buf.size) - }; + let size = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size; let end = offset.checked_add(len).ok_or_else(|| { error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) })?; @@ -1739,13 +1780,21 @@ fn buffer_read_range(entity: Entity, offset: u64, len: u64) -> error::Result size {size}" ))); } - app.sub_app_mut(bevy::render::RenderApp) - .world_mut() - .run_system_cached_with( - compute::read_buffer_gpu, - (handle, readback_buffer, offset, len), - ) - .unwrap() + ensure_buffer_synced(app, entity)?; + let handle = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let buffers = app + .world() + .resource::>(); + let data = buffers + .get(&handle) + .and_then(|a| a.data.as_ref()) + .ok_or(error::ProcessingError::BufferNotFound)?; + Ok(data[offset as usize..(offset + len) as usize].to_vec()) }) } @@ -1775,22 +1824,41 @@ pub fn compute_set( pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { app_mut(|app| { - let c = app - .world() - .get::(entity) - .ok_or(error::ProcessingError::ComputeNotFound)?; - let args = ( - c.pipeline_id, - c.bind_group_layout_descriptors.clone(), - c.shader.clone(), - x, - y, - z, - ); + // Flush any pending graphics work and let Bevy's render-asset extract + // upload any CPU-side buffer mutations to the GPU before the dispatch + // runs. This is the sync boundary for compute inputs. + crate::graphics::flush_all(app); + + let (args, rw_entities) = { + let c = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + let args = ( + c.pipeline_id, + c.bind_group_layout_descriptors.clone(), + c.shader.clone(), + x, + y, + z, + ); + let rw_entities: Vec = c.rw_buffers.values().copied().collect(); + (args, rw_entities) + }; app.sub_app_mut(bevy::render::RenderApp) .world_mut() .run_system_cached_with(compute::dispatch, args) - .unwrap() + .unwrap()?; + + // Invalidate the CPU view of any buffer the dispatch could have + // written. The next read or write on those buffers will readback first. + let world = app.world_mut(); + for e in rw_entities { + if let Some(mut buf) = world.get_mut::(e) { + buf.synced = false; + } + } + Ok(()) }) } From 0bf44b736b89b3e2f27ae6823e0fea137b1d6f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Tue, 28 Apr 2026 15:17:05 -0700 Subject: [PATCH 03/11] Fix sync pattern. --- crates/processing_render/src/compute.rs | 29 ++++++++++++++++-- crates/processing_render/src/lib.rs | 1 + crates/processing_render/src/material/mod.rs | 32 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 2759ea0..ee9fa72 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -25,6 +25,14 @@ use crate::material::custom::{Shader, apply_reflect_field, shader_value_to_refle use crate::shader_value::ShaderValue; use processing_core::error::{ProcessingError, Result}; +pub struct ComputePlugin; + +impl Plugin for ComputePlugin { + fn build(&self, app: &mut App) { + app.add_systems(Last, invalidate_rw_buffers); + } +} + #[derive(Component)] pub struct Buffer { pub handle: Handle, @@ -34,6 +42,10 @@ pub struct Buffer { /// when a pipeline that may write to the buffer runs; the next read or /// write must readback first. pub synced: bool, + /// Set permanently once the buffer is bound to any pipeline as read_write + /// storage. When true, any frame tick could have mutated GPU contents via + /// a render pass, so `synced` must be cleared after each `app.update()`. + pub bound_rw: bool, } fn readback_buffer(device: &RenderDevice, size: u64) -> WgpuBuffer { @@ -61,6 +73,7 @@ pub fn create_buffer( readback_buffer: readback_buffer(&render_device, size), size, synced: true, + bound_rw: false, }) .id() } @@ -79,6 +92,7 @@ pub fn create_buffer_with_data( readback_buffer: readback_buffer(&render_device, size), size, synced: true, + bound_rw: false, }) .id() } @@ -139,6 +153,14 @@ pub fn read_buffer_gpu( Ok(bytes) } +pub fn invalidate_rw_buffers(mut buffers: Query<&mut Buffer>) { + for mut buf in &mut buffers { + if buf.bound_rw { + buf.synced = false; + } + } +} + pub fn destroy_buffer(In(entity): In, mut commands: Commands) -> Result<()> { commands.entity(entity).despawn(); Ok(()) @@ -267,7 +289,7 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { pub fn set_compute_property( In((entity, name, value)): In<(Entity, String, ShaderValue)>, mut computes: Query<&mut Compute>, - p_buffers: Query<&Buffer>, + mut p_buffers: Query<&mut Buffer>, p_images: Query<&PImage>, ) -> Result<()> { use bevy_naga_reflect::reflect::ParameterCategory; @@ -285,14 +307,15 @@ pub fn set_compute_property( match (&value, category) { (ShaderValue::Buffer(buf_entity), ParameterCategory::Storage { read_only }) => { - let buffer = p_buffers - .get(*buf_entity) + let mut buffer = p_buffers + .get_mut(*buf_entity) .map_err(|_| ProcessingError::BufferNotFound)?; compute.shader.insert(&name, buffer.handle.clone()); if read_only { compute.rw_buffers.remove(&name); } else { compute.rw_buffers.insert(name.clone(), *buf_entity); + buffer.bound_rw = true; } Ok(()) } diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 6c2dbd4..0225bad 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -63,6 +63,7 @@ impl Plugin for ProcessingRenderPlugin { material::ProcessingMaterialPlugin, bevy::pbr::wireframe::WireframePlugin::default(), material::custom::CustomMaterialPlugin, + compute::ComputePlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index b24f33b..1100df7 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -1,6 +1,7 @@ pub mod custom; pub mod pbr; +use crate::compute; use crate::render::material::UntypedMaterial; use crate::shader_value::ShaderValue; use bevy::material::descriptor::RenderPipelineDescriptor; @@ -12,6 +13,7 @@ use bevy::pbr::{ use bevy::prelude::*; use bevy::render::render_resource::{AsBindGroup, BlendState}; use bevy::shader::ShaderRef; +use bevy_naga_reflect::reflect::ParameterCategory; use processing_core::error::{self, ProcessingError}; pub struct ProcessingMaterialPlugin; @@ -59,6 +61,7 @@ pub fn set_property( material_handles: Query<&UntypedMaterial>, mut extended_materials: ResMut>>, mut custom_materials: ResMut>, + mut p_buffers: Query<&mut compute::Buffer>, ) -> error::Result<()> { let untyped = material_handles .get(entity) @@ -79,6 +82,35 @@ pub fn set_property( let mut mat = custom_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; + + if let ShaderValue::Buffer(buf_entity) = &value { + let mut buffer = p_buffers + .get_mut(*buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + + let category = mat + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + + match category { + ParameterCategory::Storage { read_only } => { + mat.shader.insert(&name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; + } + return Ok(()); + } + cat => { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {cat:?}, got Buffer" + ))); + } + } + } + return custom::set_property(&mut mat, &name, &value); } From 42d3db35d441614a8031749dd39c833fa0eba0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Tue, 28 Apr 2026 15:28:26 -0700 Subject: [PATCH 04/11] . --- crates/processing_render/src/compute.rs | 19 ++---------- crates/processing_render/src/graphics.rs | 30 ------------------- crates/processing_render/src/lib.rs | 28 ++++------------- .../processing_render/src/material/custom.rs | 12 +++----- 4 files changed, 11 insertions(+), 78 deletions(-) diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index ee9fa72..1c58594 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -38,13 +38,7 @@ pub struct Buffer { pub handle: Handle, pub readback_buffer: WgpuBuffer, pub size: u64, - /// True when `ShaderBuffer.data` reflects current GPU contents. Cleared - /// when a pipeline that may write to the buffer runs; the next read or - /// write must readback first. pub synced: bool, - /// Set permanently once the buffer is bound to any pipeline as read_write - /// storage. When true, any frame tick could have mutated GPU contents via - /// a render pass, so `synced` must be cleared after each `app.update()`. pub bound_rw: bool, } @@ -97,9 +91,6 @@ pub fn create_buffer_with_data( .id() } -/// Mutate the CPU-side data of a `ShaderBuffer` in place. Fires -/// `AssetEvent::Modified` so Bevy's render-asset extract uploads the new -/// contents to the GPU at the next sync point. pub fn write_buffer_cpu( In((handle, offset, data)): In<(Handle, u64, Vec)>, mut buffers: ResMut>, @@ -117,10 +108,8 @@ pub fn write_buffer_cpu( Ok(()) } -/// Copy the GPU buffer back to CPU and return its full contents. Runs in the -/// render world; the caller is responsible for writing the bytes back into -/// `ShaderBuffer.data` via `Assets::get_mut_untracked` (avoiding spurious -/// `AssetEvent::Modified`s, since this is a readback, not a stage-for-upload). +/// Caller must write bytes back via `get_mut_untracked` to avoid triggering +/// a re-upload. pub fn read_buffer_gpu( In((handle, readback_buffer, size)): In<(Handle, WgpuBuffer, u64)>, gpu_buffers: Res>, @@ -172,10 +161,6 @@ pub struct Compute { pub entry_point: String, pub pipeline_id: CachedComputePipelineId, pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, - /// Buffer entities bound to this compute on a `read_write` storage param. - /// Their CPU view of GPU data is invalidated after each dispatch so the - /// next read/write does a readback. Read-only bindings don't need this - /// since the dispatch can't mutate them. pub rw_buffers: HashMap, } diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 72bb027..f885750 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -453,36 +453,6 @@ pub fn flush(app: &mut App, entity: Entity) -> Result<()> { Ok(()) } -/// Flush all graphics with pending commands and run a frame so any other -/// pending GPU state (asset writes, etc.) is extracted and uploaded. Used as -/// a sync boundary before operations like compute dispatch that may bind -/// graphics targets or recently-mutated assets. -pub fn flush_all(app: &mut App) { - let mut to_flush = Vec::new(); - let world = app.world_mut(); - let mut q = world.query::<(Entity, &CommandBuffer, &Graphics)>(); - for (e, cb, _) in q.iter(world) { - if !cb.commands.is_empty() { - to_flush.push(e); - } - } - - for e in &to_flush { - if let Ok(mut em) = world.get_entity_mut(*e) { - em.insert(Flush); - } - } - - app.update(); - - let world = app.world_mut(); - for e in &to_flush { - if let Ok(mut em) = world.get_entity_mut(*e) { - em.remove::(); - } - } -} - 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 0225bad..17336a4 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1677,9 +1677,6 @@ pub fn buffer_write_element(entity: Entity, offset: u64, data: Vec) -> error buffer_write_range(entity, offset, data, false) } -/// Ensure `ShaderBuffer.data` reflects current GPU contents by reading the -/// buffer back into the asset if it was invalidated by a prior dispatch. -/// Subsequent reads/writes can then operate on the in-asset bytes directly. fn ensure_buffer_synced(app: &mut App, entity: Entity) -> error::Result<()> { let (handle, readback_buffer, size, synced) = { let buf = app @@ -1825,41 +1822,26 @@ pub fn compute_set( pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { app_mut(|app| { - // Flush any pending graphics work and let Bevy's render-asset extract - // upload any CPU-side buffer mutations to the GPU before the dispatch - // runs. This is the sync boundary for compute inputs. - crate::graphics::flush_all(app); + app.update(); - let (args, rw_entities) = { + let args = { let c = app .world() .get::(entity) .ok_or(error::ProcessingError::ComputeNotFound)?; - let args = ( + ( c.pipeline_id, c.bind_group_layout_descriptors.clone(), c.shader.clone(), x, y, z, - ); - let rw_entities: Vec = c.rw_buffers.values().copied().collect(); - (args, rw_entities) + ) }; app.sub_app_mut(bevy::render::RenderApp) .world_mut() .run_system_cached_with(compute::dispatch, args) - .unwrap()?; - - // Invalidate the CPU view of any buffer the dispatch could have - // written. The next read or write on those buffers will readback first. - let world = app.world_mut(); - for e in rw_entities { - if let Some(mut buf) = world.get_mut::(e) { - buf.synced = false; - } - } - Ok(()) + .unwrap() }) } diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 19f4263..f8643b3 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -305,14 +305,10 @@ pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result Box::new(IVec4::from_array(*v)), ShaderValue::UInt(v) => Box::new(*v), ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), - ShaderValue::Texture(_) => { - return Err(ProcessingError::UnknownShaderProperty( - "Texture properties not yet supported for custom materials".to_string(), - )); - } - ShaderValue::Buffer(_) => { - return Err(ProcessingError::UnknownShaderProperty( - "Buffer properties not supported for custom materials".to_string(), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => { + return Err(ProcessingError::InvalidArgument( + "Texture/Buffer must be bound via set_property, not as a uniform value" + .to_string(), )); } }) From 0cdf5d18dea6aa4c936f6b33a53932bde89af750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Tue, 28 Apr 2026 15:43:30 -0700 Subject: [PATCH 05/11] Finalize sync. --- crates/processing_ffi/src/lib.rs | 3 ++- crates/processing_render/src/compute.rs | 19 +++++----------- crates/processing_render/src/lib.rs | 13 ++++++----- .../processing_render/src/material/custom.rs | 3 +-- crates/processing_render/src/material/mod.rs | 22 ++++++++----------- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index a26d233..d2b9d24 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -1965,7 +1965,8 @@ pub unsafe extern "C" fn processing_compute_set_float( }); } -/// Set a buffer property on a compute shader. +/// # Safety +/// `name` must be a valid null-terminated C string. #[unsafe(no_mangle)] pub unsafe extern "C" fn processing_compute_set_buffer( compute_id: u64, diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 1c58594..4d0a80e 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; use bevy::asset::RenderAssetUsages; use bevy::reflect::PartialReflect; @@ -98,10 +98,7 @@ pub fn write_buffer_cpu( let mut asset = buffers .get_mut(&handle) .ok_or(ProcessingError::BufferNotFound)?; - let dst = asset - .data - .as_mut() - .ok_or(ProcessingError::BufferNotFound)?; + let dst = asset.data.as_mut().ok_or(ProcessingError::BufferNotFound)?; let start = offset as usize; let end = start + data.len(); dst[start..end].copy_from_slice(&data); @@ -144,7 +141,7 @@ pub fn read_buffer_gpu( pub fn invalidate_rw_buffers(mut buffers: Query<&mut Buffer>) { for mut buf in &mut buffers { - if buf.bound_rw { + if buf.bound_rw && buf.synced { buf.synced = false; } } @@ -161,7 +158,6 @@ pub struct Compute { pub entry_point: String, pub pipeline_id: CachedComputePipelineId, pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, - pub rw_buffers: HashMap, } fn queue_pipeline( @@ -225,8 +221,7 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { .collect(); let max_group = groups.iter().last().copied().map_or(0, |g| g + 1); - let mut layout_descriptors = - vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + let mut layout_descriptors = vec![BindGroupLayoutDescriptor::default(); max_group as usize]; for (group, desc) in &bind_group_layout_descriptors { layout_descriptors[*group as usize] = desc.clone(); } @@ -263,7 +258,6 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { entry_point, pipeline_id, bind_group_layout_descriptors, - rw_buffers: HashMap::new(), }) .id()); } @@ -296,10 +290,7 @@ pub fn set_compute_property( .get_mut(*buf_entity) .map_err(|_| ProcessingError::BufferNotFound)?; compute.shader.insert(&name, buffer.handle.clone()); - if read_only { - compute.rw_buffers.remove(&name); - } else { - compute.rw_buffers.insert(name.clone(), *buf_entity); + if !read_only { buffer.bound_rw = true; } Ok(()) diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 17336a4..de39e41 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1703,12 +1703,13 @@ fn ensure_buffer_synced(app: &mut App, entity: Entity) -> error::Result<()> { .unwrap()?; let world = app.world_mut(); - let mut buffers = world.resource_mut::>(); - let asset = buffers - .get_mut_untracked(handle.id()) - .ok_or(error::ProcessingError::BufferNotFound)?; - asset.data = Some(bytes); - drop(buffers); + { + let mut buffers = world.resource_mut::>(); + let asset = buffers + .get_mut_untracked(handle.id()) + .ok_or(error::ProcessingError::BufferNotFound)?; + asset.data = Some(bytes); + } let mut buf = world .get_mut::(entity) diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index f8643b3..0a3611d 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -307,8 +307,7 @@ pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result Box::new(Mat4::from_cols_array(v)), ShaderValue::Texture(_) | ShaderValue::Buffer(_) => { return Err(ProcessingError::InvalidArgument( - "Texture/Buffer must be bound via set_property, not as a uniform value" - .to_string(), + "Texture/Buffer must be bound via set_property, not as a uniform value".to_string(), )); } }) diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index 1100df7..d7fd029 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -95,20 +95,16 @@ pub fn set_property( .map(|p| p.category()) .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - match category { - ParameterCategory::Storage { read_only } => { - mat.shader.insert(&name, buffer.handle.clone()); - if !read_only { - buffer.bound_rw = true; - } - return Ok(()); - } - cat => { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {cat:?}, got Buffer" - ))); - } + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Buffer" + ))); + }; + mat.shader.insert(&name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; } + return Ok(()); } return custom::set_property(&mut mat, &name, &value); From e4a0101e16ec9b5a62d3bf0651eab90e016d2e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Thu, 30 Apr 2026 21:33:42 -0700 Subject: [PATCH 06/11] . --- Cargo.lock | 136 +++--- Cargo.toml | 34 +- crates/processing_core/src/error.rs | 2 + crates/processing_render/src/compute.rs | 59 ++- .../src/field/field_color.wgsl | 42 ++ .../src/field/field_pbr.wgsl | 51 +++ .../processing_render/src/field/material.rs | 102 +++++ crates/processing_render/src/field/mod.rs | 228 ++++++++++ crates/processing_render/src/field/pack.rs | 417 ++++++++++++++++++ crates/processing_render/src/field/pack.wgsl | 132 ++++++ .../src/geometry/attribute.rs | 57 ++- crates/processing_render/src/graphics.rs | 9 +- crates/processing_render/src/lib.rs | 243 ++++++++++ .../processing_render/src/render/command.rs | 4 + crates/processing_render/src/render/mod.rs | 75 +++- docs/field.md | 278 ++++++++++++ examples/field_animated.rs | 102 +++++ examples/field_basic.rs | 73 +++ examples/field_colored.rs | 79 ++++ examples/field_colored_pbr.rs | 82 ++++ examples/field_emit.rs | 111 +++++ examples/field_from_mesh.rs | 93 ++++ examples/field_lifecycle.rs | 191 ++++++++ examples/field_oriented.rs | 145 ++++++ 24 files changed, 2642 insertions(+), 103 deletions(-) create mode 100644 crates/processing_render/src/field/field_color.wgsl create mode 100644 crates/processing_render/src/field/field_pbr.wgsl create mode 100644 crates/processing_render/src/field/material.rs create mode 100644 crates/processing_render/src/field/mod.rs create mode 100644 crates/processing_render/src/field/pack.rs create mode 100644 crates/processing_render/src/field/pack.wgsl create mode 100644 docs/field.md create mode 100644 examples/field_animated.rs create mode 100644 examples/field_basic.rs create mode 100644 examples/field_colored.rs create mode 100644 examples/field_colored_pbr.rs create mode 100644 examples/field_emit.rs create mode 100644 examples/field_from_mesh.rs create mode 100644 examples/field_lifecycle.rs create mode 100644 examples/field_oriented.rs diff --git a/Cargo.lock b/Cargo.lock index 088c4da..1f4b6c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,7 +180,6 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "simd_cesu8", "thiserror 2.0.18", ] @@ -518,7 +517,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bevy" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_internal", ] @@ -526,7 +525,7 @@ dependencies = [ [[package]] name = "bevy_a11y" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_app", @@ -538,7 +537,7 @@ dependencies = [ [[package]] name = "bevy_android" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "android-activity", ] @@ -546,7 +545,7 @@ dependencies = [ [[package]] name = "bevy_animation" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_animation_macros", "bevy_app", @@ -578,7 +577,7 @@ dependencies = [ [[package]] name = "bevy_animation_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -588,7 +587,7 @@ dependencies = [ [[package]] name = "bevy_anti_alias" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -609,7 +608,7 @@ dependencies = [ [[package]] name = "bevy_app" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_derive", "bevy_ecs", @@ -630,7 +629,7 @@ dependencies = [ [[package]] name = "bevy_asset" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-broadcast", "async-channel", @@ -673,7 +672,7 @@ dependencies = [ [[package]] name = "bevy_asset_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -684,7 +683,7 @@ dependencies = [ [[package]] name = "bevy_audio" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -699,7 +698,7 @@ dependencies = [ [[package]] name = "bevy_camera" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -724,7 +723,7 @@ dependencies = [ [[package]] name = "bevy_camera_controller" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_camera", @@ -741,7 +740,7 @@ dependencies = [ [[package]] name = "bevy_clipboard" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -755,7 +754,7 @@ dependencies = [ [[package]] name = "bevy_color" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_math", "bevy_reflect", @@ -770,7 +769,7 @@ dependencies = [ [[package]] name = "bevy_core_pipeline" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -812,7 +811,7 @@ dependencies = [ [[package]] name = "bevy_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -822,7 +821,7 @@ dependencies = [ [[package]] name = "bevy_dev_tools" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -853,7 +852,7 @@ dependencies = [ [[package]] name = "bevy_diagnostic" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "atomic-waker", "bevy_app", @@ -870,7 +869,7 @@ dependencies = [ [[package]] name = "bevy_ecs" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "arrayvec", "bevy_ecs_macros", @@ -897,7 +896,7 @@ dependencies = [ [[package]] name = "bevy_ecs_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -908,7 +907,7 @@ dependencies = [ [[package]] name = "bevy_encase_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "encase_derive_impl", @@ -917,7 +916,7 @@ dependencies = [ [[package]] name = "bevy_feathers" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -948,7 +947,7 @@ dependencies = [ [[package]] name = "bevy_gilrs" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -963,7 +962,7 @@ dependencies = [ [[package]] name = "bevy_gizmos" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -985,7 +984,7 @@ dependencies = [ [[package]] name = "bevy_gizmos_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -995,7 +994,7 @@ dependencies = [ [[package]] name = "bevy_gizmos_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1023,7 +1022,7 @@ dependencies = [ [[package]] name = "bevy_gltf" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-lock", "base64", @@ -1058,7 +1057,7 @@ dependencies = [ [[package]] name = "bevy_image" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1086,7 +1085,7 @@ dependencies = [ [[package]] name = "bevy_input" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1102,7 +1101,7 @@ dependencies = [ [[package]] name = "bevy_input_focus" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1118,7 +1117,7 @@ dependencies = [ [[package]] name = "bevy_internal" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_a11y", "bevy_android", @@ -1177,7 +1176,7 @@ dependencies = [ [[package]] name = "bevy_light" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1201,7 +1200,7 @@ dependencies = [ [[package]] name = "bevy_log" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "android_log-sys", "bevy_app", @@ -1218,7 +1217,7 @@ dependencies = [ [[package]] name = "bevy_macro_utils" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "proc-macro2", "quote", @@ -1229,7 +1228,7 @@ dependencies = [ [[package]] name = "bevy_material" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_asset", "bevy_derive", @@ -1251,7 +1250,7 @@ dependencies = [ [[package]] name = "bevy_material_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -1261,7 +1260,7 @@ dependencies = [ [[package]] name = "bevy_math" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "approx", "arrayvec", @@ -1280,7 +1279,7 @@ dependencies = [ [[package]] name = "bevy_mesh" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1329,7 +1328,6 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" version = "0.1.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#1d6bfcdddaf44e7a3ed2c4a946e6af2ace2f9f44" dependencies = [ "bevy", "naga", @@ -1338,7 +1336,7 @@ dependencies = [ [[package]] name = "bevy_pbr" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "arrayvec", "bevy_app", @@ -1380,7 +1378,7 @@ dependencies = [ [[package]] name = "bevy_picking" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1403,7 +1401,7 @@ dependencies = [ [[package]] name = "bevy_platform" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "critical-section", "foldhash 0.2.0", @@ -1424,7 +1422,7 @@ dependencies = [ [[package]] name = "bevy_post_process" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1448,12 +1446,12 @@ dependencies = [ [[package]] name = "bevy_ptr" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" [[package]] name = "bevy_reflect" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "assert_type_match", "bevy_platform", @@ -1481,7 +1479,7 @@ dependencies = [ [[package]] name = "bevy_reflect_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "indexmap", @@ -1494,7 +1492,7 @@ dependencies = [ [[package]] name = "bevy_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "bevy_app", @@ -1547,7 +1545,7 @@ dependencies = [ [[package]] name = "bevy_render_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -1558,7 +1556,7 @@ dependencies = [ [[package]] name = "bevy_scene" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1577,7 +1575,7 @@ dependencies = [ [[package]] name = "bevy_scene_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -1588,7 +1586,7 @@ dependencies = [ [[package]] name = "bevy_shader" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_asset", "bevy_platform", @@ -1607,7 +1605,7 @@ dependencies = [ [[package]] name = "bevy_sprite" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1632,7 +1630,7 @@ dependencies = [ [[package]] name = "bevy_sprite_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1664,7 +1662,7 @@ dependencies = [ [[package]] name = "bevy_state" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1679,7 +1677,7 @@ dependencies = [ [[package]] name = "bevy_state_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -1689,7 +1687,7 @@ dependencies = [ [[package]] name = "bevy_tasks" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "async-executor", @@ -1707,7 +1705,7 @@ dependencies = [ [[package]] name = "bevy_text" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1735,7 +1733,7 @@ dependencies = [ [[package]] name = "bevy_time" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1749,7 +1747,7 @@ dependencies = [ [[package]] name = "bevy_transform" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1765,7 +1763,7 @@ dependencies = [ [[package]] name = "bevy_ui" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -1802,7 +1800,7 @@ dependencies = [ [[package]] name = "bevy_ui_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1834,7 +1832,7 @@ dependencies = [ [[package]] name = "bevy_ui_widgets" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -1857,7 +1855,7 @@ dependencies = [ [[package]] name = "bevy_utils" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "bevy_platform", @@ -1869,7 +1867,7 @@ dependencies = [ [[package]] name = "bevy_window" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1887,7 +1885,7 @@ dependencies = [ [[package]] name = "bevy_winit" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "accesskit_winit", @@ -1919,7 +1917,7 @@ dependencies = [ [[package]] name = "bevy_world_serialization" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -3328,7 +3326,7 @@ dependencies = [ "vec_map", "wasm-bindgen", "web-sys", - "windows 0.62.2", + "windows 0.56.0", ] [[package]] @@ -3464,7 +3462,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.62.2", + "windows 0.56.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 234b3c2..f85c12b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ too_many_arguments = "allow" [workspace.dependencies] bevy = { git = "https://github.com/processing/bevy", branch = "main", features = ["file_watcher", "shader_format_wesl", "free_camera", "pan_camera"] } -bevy_naga_reflect = { git = "https://github.com/tychedelia/bevy_naga_reflect" } +bevy_naga_reflect = { path = "../../tychedelia/bevy_naga_reflect" } bevy_cuda = { git = "https://github.com/tychedelia/bevy_cuda" } naga = { version = "29", features = ["wgsl-in"] } wesl = { version = "0.3", default-features = false } @@ -165,6 +165,38 @@ path = "examples/camera_controllers.rs" name = "compute_readback" path = "examples/compute_readback.rs" +[[example]] +name = "field_basic" +path = "examples/field_basic.rs" + +[[example]] +name = "field_animated" +path = "examples/field_animated.rs" + +[[example]] +name = "field_oriented" +path = "examples/field_oriented.rs" + +[[example]] +name = "field_colored" +path = "examples/field_colored.rs" + +[[example]] +name = "field_colored_pbr" +path = "examples/field_colored_pbr.rs" + +[[example]] +name = "field_emit" +path = "examples/field_emit.rs" + +[[example]] +name = "field_lifecycle" +path = "examples/field_lifecycle.rs" + +[[example]] +name = "field_from_mesh" +path = "examples/field_from_mesh.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 2fc9607..05bc0a6 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -56,4 +56,6 @@ pub enum ProcessingError { PipelineCompileError(String), #[error("Pipeline not ready after {0} frames")] PipelineNotReady(u32), + #[error("Field not found")] + FieldNotFound, } diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 4d0a80e..1f3d78a 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -277,17 +277,25 @@ pub fn set_compute_property( .get_mut(entity) .map_err(|_| ProcessingError::ComputeNotFound)?; - let category = compute - .shader - .reflection() - .parameter(&name) - .map(|p| p.category()) - .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - - match (&value, category) { - (ShaderValue::Buffer(buf_entity), ParameterCategory::Storage { read_only }) => { + // Resource values (buffers / textures) bind directly to top-level parameters + // and need a category check. Scalar / vector / matrix values may target + // either a top-level uniform or a nested struct field (e.g. `params.dt`), + // so we let `apply_reflect_field` handle the path resolution itself. + 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) + .get_mut(buf_entity) .map_err(|_| ProcessingError::BufferNotFound)?; compute.shader.insert(&name, buffer.handle.clone()); if !read_only { @@ -295,26 +303,31 @@ pub fn set_compute_property( } Ok(()) } - (ShaderValue::Texture(img_entity), ParameterCategory::Texture) - | (ShaderValue::Texture(img_entity), ParameterCategory::StorageTexture) => { + 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) + .get(img_entity) .map_err(|_| ProcessingError::ImageNotFound)?; compute.shader.insert(&name, image.handle.clone()); Ok(()) } - (ShaderValue::Buffer(_), cat) | (ShaderValue::Texture(_), cat) => { - Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {cat:?}, got {value:?}", - ))) - } - (_, ParameterCategory::Uniform) => { - let reflect_value: Box = shader_value_to_reflect(&value)?; + v => { + let reflect_value: Box = shader_value_to_reflect(&v)?; apply_reflect_field(&mut compute.shader, &name, &*reflect_value) } - (_, cat) => Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {cat:?}, got non-resource value" - ))), } } diff --git a/crates/processing_render/src/field/field_color.wgsl b/crates/processing_render/src/field/field_color.wgsl new file mode 100644 index 0000000..0f3e898 --- /dev/null +++ b/crates/processing_render/src/field/field_color.wgsl @@ -0,0 +1,42 @@ +// Per-particle color material for [`Field`] rasterization. +// +// Reads `mesh.tag` (written by the pack pass as the per-particle slot index) +// and looks up a per-particle color from a storage buffer. Unlit — outputs the +// looked-up color directly. + +#import bevy_pbr::{ + mesh_functions, + view_transformations::position_world_to_clip +} + +@group(#{MATERIAL_BIND_GROUP}) @binding(0) +var particle_colors: array>; + +struct Vertex { + @builtin(instance_index) instance_index: u32, + @location(0) position: vec3, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vertex(vertex: Vertex) -> VertexOutput { + var out: VertexOutput; + let tag = mesh_functions::get_tag(vertex.instance_index); + let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); + let world_position = mesh_functions::mesh_position_local_to_world( + world_from_local, + vec4(vertex.position, 1.0), + ); + out.clip_position = position_world_to_clip(world_position.xyz); + out.color = particle_colors[tag]; + return out; +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + return in.color; +} diff --git a/crates/processing_render/src/field/field_pbr.wgsl b/crates/processing_render/src/field/field_pbr.wgsl new file mode 100644 index 0000000..c36cdb5 --- /dev/null +++ b/crates/processing_render/src/field/field_pbr.wgsl @@ -0,0 +1,51 @@ +// PBR per-particle color material for [`Field`] rasterization. +// +// Composes with `StandardMaterial` via `ExtendedMaterial`. The base material +// supplies all PBR properties (roughness, metallic, etc.); we modulate the +// resulting `base_color` by the per-particle color looked up from a storage +// buffer indexed by `mesh.tag` (the per-instance slot index written by the +// pack pass). + +#import bevy_pbr::{ + pbr_fragment::pbr_input_from_standard_material, + pbr_functions::alpha_discard, + mesh_functions, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, +} +#endif + +@group(#{MATERIAL_BIND_GROUP}) @binding(100) +var particle_colors: array>; + +@fragment +fn fragment( + in: VertexOutput, + @builtin(front_facing) is_front: bool, +) -> FragmentOutput { + var pbr_input = pbr_input_from_standard_material(in, is_front); + + let tag = mesh_functions::get_tag(in.instance_index); + pbr_input.material.base_color = pbr_input.material.base_color * particle_colors[tag]; + + pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + +#ifdef PREPASS_PIPELINE + let out = deferred_output(in, pbr_input); +#else + var out: FragmentOutput; + out.color = apply_pbr_lighting(pbr_input); + out.color = main_pass_post_lighting_processing(pbr_input, out.color); +#endif + + return out; +} diff --git a/crates/processing_render/src/field/material.rs b/crates/processing_render/src/field/material.rs new file mode 100644 index 0000000..3397a8b --- /dev/null +++ b/crates/processing_render/src/field/material.rs @@ -0,0 +1,102 @@ +//! `FieldColorMaterial` — unlit material that reads a per-particle color from a +//! storage buffer indexed by the per-instance tag (set to slot index by the pack pass). + +use std::ops::Deref; + +use bevy::asset::embedded_asset; +use bevy::pbr::{ExtendedMaterial, MaterialExtension, MaterialPlugin}; +use bevy::prelude::*; +use bevy::render::{render_resource::AsBindGroup, storage::ShaderBuffer}; +use bevy::shader::ShaderRef; + +use crate::render::material::UntypedMaterial; + +pub struct FieldColorMaterialPlugin; + +impl Plugin for FieldColorMaterialPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "field_color.wgsl"); + embedded_asset!(app, "field_pbr.wgsl"); + app.add_plugins(MaterialPlugin::::default()); + app.add_plugins(MaterialPlugin::::default()); + } +} + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] +pub struct FieldColorMaterial { + #[storage(0, read_only)] + pub colors: Handle, +} + +impl Material for FieldColorMaterial { + fn vertex_shader() -> ShaderRef { + "embedded://processing_render/field/field_color.wgsl".into() + } + + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/field/field_color.wgsl".into() + } +} + +#[derive(Component, Clone)] +pub struct FieldColorMaterial3d(pub Handle); + +impl bevy::asset::AsAssetId for FieldColorMaterial3d { + type Asset = FieldColorMaterial; + fn as_asset_id(&self) -> AssetId { + self.0.id() + } +} + +/// Sibling to `add_processing_materials` / `add_custom_materials`. Promotes +/// `UntypedMaterial(handle)` entities whose handle is a [`FieldColorMaterial`] +/// to having the typed `MeshMaterial3d` component required +/// by the render pipeline. +pub fn add_field_color_materials( + mut commands: Commands, + meshes: Query<(Entity, &UntypedMaterial)>, +) { + for (entity, handle) in meshes.iter() { + let handle = handle.deref().clone(); + if let Ok(handle) = handle.try_typed::() { + commands + .entity(entity) + .insert(MeshMaterial3d::(handle)); + } + } +} + +/// PBR-lit per-particle color material. Wraps `StandardMaterial` via +/// `ExtendedMaterial` so the user gets standard PBR lighting behavior on top +/// of per-particle albedo from a storage buffer. +pub type FieldPbrMaterial = ExtendedMaterial; + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] +pub struct FieldPbrExtension { + #[storage(100, read_only)] + pub colors: Handle, +} + +impl MaterialExtension for FieldPbrExtension { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/field/field_pbr.wgsl".into() + } + + fn deferred_fragment_shader() -> ShaderRef { + "embedded://processing_render/field/field_pbr.wgsl".into() + } +} + +pub fn add_field_pbr_materials( + mut commands: Commands, + meshes: Query<(Entity, &UntypedMaterial)>, +) { + for (entity, handle) in meshes.iter() { + let handle = handle.deref().clone(); + if let Ok(handle) = handle.try_typed::() { + commands + .entity(entity) + .insert(MeshMaterial3d::(handle)); + } + } +} diff --git a/crates/processing_render/src/field/mod.rs b/crates/processing_render/src/field/mod.rs new file mode 100644 index 0000000..a7fe7ff --- /dev/null +++ b/crates/processing_render/src/field/mod.rs @@ -0,0 +1,228 @@ +//! GPU-resident particle / instancing container. +//! +//! A [`Field`] holds a set of named [`PBuffer`](crate::compute::Buffer)s — one per registered +//! attribute. It is pure storage: it carries no instance shape and no material. The shape is +//! supplied at draw time via the `field` verb, and the material is read from ambient state at +//! that point. Rasterization is layered on later by spawning a transient +//! `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` entity that consumes the Field's PBuffers +//! through the pack pass. +//! +//! See `docs/field.md` for the full design. + +pub mod material; +pub mod pack; + +use bevy::asset::RenderAssetUsages; +use bevy::mesh::VertexAttributeValues; +use bevy::pbr::gpu_instance_batch::GpuInstanceBatchPlugin; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::render_resource::{BufferDescriptor, BufferUsages}; +use bevy::render::renderer::RenderDevice; +use bevy::render::storage::ShaderBuffer; + +use processing_core::error::{ProcessingError, Result}; + +use crate::compute; +use crate::geometry::{Attribute, AttributeFormat, Geometry}; + +pub struct FieldPlugin; + +impl Plugin for FieldPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(GpuInstanceBatchPlugin); + app.add_plugins(pack::FieldPackPlugin); + app.add_plugins(material::FieldColorMaterialPlugin); + } +} + +/// A GPU-resident container of named per-instance attribute buffers. +/// +/// `pbuffers` maps an [`Attribute`](crate::geometry::Attribute) entity to its backing +/// [`compute::Buffer`] entity. The set of registered attributes is fixed at creation. +/// +/// `draw_entity` is the persistent rasterization entity carrying `GpuBatchedMesh3d` and +/// the active material — created lazily on the first `field` draw call and reused on +/// subsequent ones. It must persist across frames because the upstream batching queue +/// processes mesh instance batches one frame after the reservation is created; despawning +/// per-frame would lose the entity before it ever gets queued. +/// +/// `emit_head` is the ring-buffer write cursor used by `field_emit`. New particles are +/// written to slots `[emit_head, emit_head + n) mod capacity` and the head advances by `n`. +/// When the ring wraps, oldest particles are overwritten — capacity is a visible contract. +#[derive(Component)] +pub struct Field { + pub capacity: u32, + pub pbuffers: HashMap, + pub draw_entity: Option, + pub emit_head: u32, +} + +impl Field { + pub fn pbuffer(&self, attribute: Entity) -> Option { + self.pbuffers.get(&attribute).copied() + } +} + +/// Marker on a transient render entity indicating it rasterizes a [`Field`]. +/// +/// The pack pass uses this to look up which Field's PBuffers to read when writing +/// per-instance transforms into the upstream `mesh_input_buffer`. +#[derive(Component, Clone, Copy)] +pub struct FieldDraw { + pub field: Entity, +} + +pub fn create( + In((capacity, attribute_entities)): In<(u32, Vec)>, + mut commands: Commands, + attributes: Query<&Attribute>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let mut pbuffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let byte_size = capacity as u64 * attr.format.byte_size() as u64; + let buffer_entity = make_pbuffer( + &mut commands, + &mut shader_buffers, + &render_device, + &vec![0u8; byte_size as usize], + ); + pbuffers.insert(attr_entity, buffer_entity); + } + + let field_entity = commands + .spawn(Field { + capacity, + pbuffers, + draw_entity: None, + emit_head: 0, + }) + .id(); + Ok(field_entity) +} + +/// Create a Field whose capacity matches the source [`Geometry`]'s vertex count +/// and whose PBuffers are pre-seeded from the geometry's mesh attributes where +/// names line up. Any registered attribute the mesh doesn't supply (or whose +/// format doesn't match) gets zero-initialized — the user fills it in via +/// `buffer_write` or `field_emit`. +pub fn create_from_geometry( + In((geom_entity, attribute_entities)): In<(Entity, Vec)>, + mut commands: Commands, + geometries: Query<&Geometry>, + attributes: Query<&Attribute>, + meshes: Res>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)?; + let capacity = mesh.count_vertices() as u32; + + let mut pbuffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let byte_size = capacity as u64 * attr.format.byte_size() as u64; + + let initial = mesh + .attribute(attr.inner) + .and_then(|values| attribute_values_to_bytes(values, attr.format)) + .filter(|bytes| bytes.len() == byte_size as usize) + .unwrap_or_else(|| vec![0u8; byte_size as usize]); + + let buffer_entity = + make_pbuffer(&mut commands, &mut shader_buffers, &render_device, &initial); + pbuffers.insert(attr_entity, buffer_entity); + } + + let field_entity = commands + .spawn(Field { + capacity, + pbuffers, + draw_entity: None, + emit_head: 0, + }) + .id(); + Ok(field_entity) +} + +fn make_pbuffer( + commands: &mut Commands, + shader_buffers: &mut Assets, + render_device: &RenderDevice, + initial: &[u8], +) -> Entity { + let byte_size = initial.len() as u64; + let handle = shader_buffers.add(ShaderBuffer::new(initial, RenderAssetUsages::all())); + let readback = render_device.create_buffer(&BufferDescriptor { + label: Some("Field PBuffer Readback"), + size: byte_size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + commands + .spawn(compute::Buffer { + handle, + readback_buffer: readback, + size: byte_size, + synced: true, + bound_rw: false, + }) + .id() +} + +fn attribute_values_to_bytes( + values: &VertexAttributeValues, + format: AttributeFormat, +) -> Option> { + match (format, values) { + (AttributeFormat::Float, VertexAttributeValues::Float32(v)) => { + Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()) + } + (AttributeFormat::Float2, VertexAttributeValues::Float32x2(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float3, VertexAttributeValues::Float32x3(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float4, VertexAttributeValues::Float32x4(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + _ => None, + } +} + +pub fn destroy( + In(entity): In, + mut commands: Commands, + fields: Query<&Field>, +) -> Result<()> { + let field = fields + .get(entity) + .map_err(|_| ProcessingError::FieldNotFound)?; + for &buffer_entity in field.pbuffers.values() { + commands.entity(buffer_entity).despawn(); + } + if let Some(draw_entity) = field.draw_entity { + commands.entity(draw_entity).despawn(); + } + commands.entity(entity).despawn(); + Ok(()) +} diff --git a/crates/processing_render/src/field/pack.rs b/crates/processing_render/src/field/pack.rs new file mode 100644 index 0000000..15ba29e --- /dev/null +++ b/crates/processing_render/src/field/pack.rs @@ -0,0 +1,417 @@ +//! Pack pass — bridges a [`Field`]'s `position` / `rotation` / `scale` PBuffers into the +//! upstream `mesh_input_buffer[base..base+capacity].world_from_local` slots reserved by the +//! entity's [`GpuBatchedMesh3d`]. +//! +//! The pack shader is specialized via shader_defs (`HAS_ROTATION`, `HAS_SCALE`) based on +//! which builtin attributes the field carries. Pipelines and bind-group layouts are cached +//! per shader_def combination. + +use std::num::NonZeroU64; + +use bevy::core_pipeline::Core3d; +use bevy::pbr::{ + MeshCullingDataBuffer, MeshInputUniform, MeshUniform, early_gpu_preprocess, + gpu_instance_batch::GpuInstanceBatchReservations, +}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::{ + Extract, ExtractSchedule, Render, RenderApp, RenderSystems, + batching::gpu_preprocessing::BatchedInstanceBuffers, + render_asset::RenderAssets, + render_resource::{ + BindGroup, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, + BufferBindingType, CachedComputePipelineId, CachedPipelineState, ComputePassDescriptor, + ComputePipelineDescriptor, PipelineCache, ShaderStages, ShaderType, UniformBuffer, + }, + renderer::{RenderContext, RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + sync_world::{MainEntity, MainEntityHashMap}, +}; +use bevy::shader::{Shader, ShaderDefVal}; + +use crate::compute; +use crate::geometry::BuiltinAttributes; + +use super::{Field, FieldDraw}; + +const WORKGROUP_SIZE: u32 = 64; + +pub struct FieldPackPlugin; + +impl Plugin for FieldPackPlugin { + fn build(&self, app: &mut App) { + let shader = { + let mut shaders = app.world_mut().resource_mut::>(); + shaders.add(Shader::from_wgsl( + include_str!("pack.wgsl"), + "processing_render/field/pack.wgsl", + )) + }; + app.insert_resource(FieldPackShader(shader.clone())); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(FieldPackShader(shader)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(ExtractSchedule, extract_field_draws) + .add_systems( + Render, + prepare_pack_bind_groups.in_set(RenderSystems::PrepareBindGroups), + ) + .add_systems(Core3d, dispatch_pack.before(early_gpu_preprocess)); + } +} + +#[derive(Resource, Clone)] +pub struct FieldPackShader(pub Handle); + +/// Specialization key — controls which `#ifdef`s are set when compiling the pack shader, +/// and which bindings are present in the bind-group layout. +#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)] +pub struct PackPipelineKey { + pub has_rotation: bool, + pub has_scale: bool, + pub has_dead: bool, +} + +pub struct CachedPackPipeline { + pub bind_group_layout: BindGroupLayoutDescriptor, + pub pipeline: CachedComputePipelineId, +} + +#[derive(Resource, Default)] +pub struct FieldPackPipelines { + pub by_key: HashMap, +} + +#[derive(Copy, Clone, Default, ShaderType)] +struct FieldPackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +pub struct ExtractedFieldData { + pub key: PackPipelineKey, + pub position: Handle, + pub rotation: Option>, + pub scale: Option>, + pub dead: Option>, +} + +#[derive(Resource, Default)] +pub struct ExtractedFieldDraws { + pub by_main: MainEntityHashMap, +} + +#[derive(Resource, Default)] +pub struct FieldPackBindGroups { + per_batch: MainEntityHashMap, +} + +struct PerBatchBindGroup { + bind_group: BindGroup, + pipeline: CachedComputePipelineId, + dispatch_count: u32, +} + +fn pack_layout_entries(key: PackPipelineKey) -> Vec { + let storage_rw = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let storage_r = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let uniform = BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(16), + }; + + let mut entries = vec![ + layout_entry(0, storage_rw), + layout_entry(1, storage_rw), + layout_entry(2, storage_r), + ]; + if key.has_rotation { + entries.push(layout_entry(3, storage_r)); + } + if key.has_scale { + entries.push(layout_entry(4, storage_r)); + } + if key.has_dead { + entries.push(layout_entry(5, storage_r)); + } + entries.push(layout_entry(6, uniform)); + entries +} + +fn layout_entry(binding: u32, ty: BindingType) -> BindGroupLayoutEntry { + BindGroupLayoutEntry { + binding, + visibility: ShaderStages::COMPUTE, + ty, + count: None, + } +} + +fn shader_defs_for(key: PackPipelineKey) -> Vec { + let mut defs = Vec::new(); + if key.has_rotation { + defs.push("HAS_ROTATION".into()); + } + if key.has_scale { + defs.push("HAS_SCALE".into()); + } + if key.has_dead { + defs.push("HAS_DEAD".into()); + } + defs +} + +fn get_or_create_pipeline( + pipelines: &mut FieldPackPipelines, + pipeline_cache: &PipelineCache, + shader: &Handle, + key: PackPipelineKey, +) -> CachedComputePipelineId { + if let Some(cached) = pipelines.by_key.get(&key) { + return cached.pipeline; + } + let bind_group_layout = BindGroupLayoutDescriptor::new( + format!( + "FieldPackBindGroupLayout(rot={},scale={},dead={})", + key.has_rotation, key.has_scale, key.has_dead + ), + &pack_layout_entries(key), + ); + let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some( + format!( + "field_pack_pipeline(rot={},scale={},dead={})", + key.has_rotation, key.has_scale, key.has_dead + ) + .into(), + ), + layout: vec![bind_group_layout.clone()], + shader: shader.clone(), + shader_defs: shader_defs_for(key), + entry_point: Some("pack".into()), + ..default() + }); + pipelines.by_key.insert( + key, + CachedPackPipeline { + bind_group_layout, + pipeline, + }, + ); + pipelines.by_key.get(&key).unwrap().pipeline +} + +fn extract_field_draws( + field_draws: Extract>, + fields: Extract>, + buffers: Extract>, + builtins: Extract>, + mut extracted: ResMut, +) { + extracted.by_main.clear(); + for (entity, field_draw) in field_draws.iter() { + let Ok(field) = fields.get(field_draw.field) else { + continue; + }; + let Some(pos_pbuf) = field.pbuffer(builtins.position) else { + continue; + }; + let Ok(pos_buf) = buffers.get(pos_pbuf) else { + continue; + }; + let rotation = field + .pbuffer(builtins.rotation) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let scale = field + .pbuffer(builtins.scale) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let dead = field + .pbuffer(builtins.dead) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + + let key = PackPipelineKey { + has_rotation: rotation.is_some(), + has_scale: scale.is_some(), + has_dead: dead.is_some(), + }; + extracted.by_main.insert( + MainEntity::from(entity), + ExtractedFieldData { + key, + position: pos_buf.handle.clone(), + rotation, + scale, + dead, + }, + ); + } +} + +fn prepare_pack_bind_groups( + shader: Res, + mut pipelines: ResMut, + pipeline_cache: Res, + extracted: Res, + reservations: Res, + batched_instance_buffers: Res>, + culling_data_buffer: Res, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, + mut bind_groups: ResMut, +) { + bind_groups.per_batch.clear(); + + let Some(input_buffer) = batched_instance_buffers + .current_input_buffer + .buffer() + .buffer() + else { + return; + }; + let Some(culling_buffer) = culling_data_buffer.buffer() else { + return; + }; + + for (main_entity, data) in extracted.by_main.iter() { + let Some(reservation) = reservations.by_entity.get(main_entity) else { + continue; + }; + let Some(gpu_position) = gpu_buffers.get(&data.position) else { + continue; + }; + let gpu_rotation = data + .rotation + .as_ref() + .and_then(|h| gpu_buffers.get(h)); + if data.key.has_rotation && gpu_rotation.is_none() { + continue; + } + let gpu_scale = data.scale.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_scale && gpu_scale.is_none() { + continue; + } + let gpu_dead = data.dead.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_dead && gpu_dead.is_none() { + continue; + } + + let pipeline_id = + get_or_create_pipeline(&mut pipelines, &pipeline_cache, &shader.0, data.key); + if !matches!( + pipeline_cache.get_compute_pipeline_state(pipeline_id), + CachedPipelineState::Ok(_) + ) { + continue; + } + let cached = pipelines.by_key.get(&data.key).unwrap(); + + let params = FieldPackParams { + base_input_index: reservation.input_buffer_base, + count: reservation.max_capacity, + ..default() + }; + let mut uniform = UniformBuffer::from(params); + uniform.write_buffer(&render_device, &render_queue); + + let mut entries: Vec = vec![ + BindGroupEntry { + binding: 0, + resource: input_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 1, + resource: culling_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 2, + resource: gpu_position.buffer.as_entire_binding(), + }, + ]; + if let Some(gpu_rotation) = gpu_rotation { + entries.push(BindGroupEntry { + binding: 3, + resource: gpu_rotation.buffer.as_entire_binding(), + }); + } + if let Some(gpu_scale) = gpu_scale { + entries.push(BindGroupEntry { + binding: 4, + resource: gpu_scale.buffer.as_entire_binding(), + }); + } + if let Some(gpu_dead) = gpu_dead { + entries.push(BindGroupEntry { + binding: 5, + resource: gpu_dead.buffer.as_entire_binding(), + }); + } + entries.push(BindGroupEntry { + binding: 6, + resource: uniform.binding().unwrap(), + }); + + let bind_group = render_device.create_bind_group( + Some("field_pack_bind_group"), + &pipeline_cache.get_bind_group_layout(&cached.bind_group_layout), + &entries, + ); + + let dispatch_count = reservation.max_capacity.div_ceil(WORKGROUP_SIZE); + bind_groups.per_batch.insert( + *main_entity, + PerBatchBindGroup { + bind_group, + pipeline: pipeline_id, + dispatch_count, + }, + ); + } +} + +fn dispatch_pack( + mut render_context: RenderContext, + bind_groups: Res, + pipeline_cache: Res, +) { + if bind_groups.per_batch.is_empty() { + return; + } + + let mut pass = render_context + .command_encoder() + .begin_compute_pass(&ComputePassDescriptor { + label: Some("field_pack"), + timestamp_writes: None, + }); + + for per_batch in bind_groups.per_batch.values() { + let Some(compute_pipeline) = pipeline_cache.get_compute_pipeline(per_batch.pipeline) else { + continue; + }; + pass.set_pipeline(compute_pipeline); + pass.set_bind_group(0, &per_batch.bind_group, &[]); + pass.dispatch_workgroups(per_batch.dispatch_count, 1, 1); + } +} diff --git a/crates/processing_render/src/field/pack.wgsl b/crates/processing_render/src/field/pack.wgsl new file mode 100644 index 0000000..8c07811 --- /dev/null +++ b/crates/processing_render/src/field/pack.wgsl @@ -0,0 +1,132 @@ +// Pack pass — bridges libprocessing Field PBuffers into the upstream +// per-instance MeshInputUniform / MeshCullingData slots reserved by +// `GpuBatchedMesh3d`. +// +// Specialized via shader_defs: +// HAS_ROTATION — bind a `rotation` PBuffer (Float4 quaternion `xyzw`) +// HAS_SCALE — bind a `scale` PBuffer (Float3) +// HAS_DEAD — bind a `dead` PBuffer (Float, 0 = alive, non-zero = dead) +// +// PBuffer formats (CPU-tightly-packed): +// position : 12 bytes per particle (Float3) +// rotation : 16 bytes per particle (Float4 quat) +// scale : 12 bytes per particle (Float3) +// dead : 4 bytes per particle (Float) + +struct MeshInput { + world_from_local: mat3x4, + lightmap_uv_rect: vec2, + flags: u32, + previous_input_index: u32, + first_vertex_index: u32, + first_index_index: u32, + index_count: u32, + current_skin_index: u32, + material_and_lightmap_bind_group_slot: u32, + timestamp: u32, + tag: u32, + morph_descriptor_index: u32, +} + +struct MeshCullingData { + aabb_center: vec3, + _pad: f32, + aabb_half_extents: vec3, + dead: f32, +} + +struct PackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +@group(0) @binding(0) var mesh_input_buffer: array; +@group(0) @binding(1) var mesh_culling_buffer: array; +@group(0) @binding(2) var position: array; +#ifdef HAS_ROTATION +@group(0) @binding(3) var rotation: array; +#endif +#ifdef HAS_SCALE +@group(0) @binding(4) var scale: array; +#endif +#ifdef HAS_DEAD +@group(0) @binding(5) var dead: array; +#endif +@group(0) @binding(6) var params: PackParams; + +// Convert a unit quaternion (x, y, z, w) into a 3x3 rotation matrix expressed +// as three column vectors. +fn quat_to_basis(q: vec4) -> mat3x3 { + let x = q.x; let y = q.y; let z = q.z; let w = q.w; + let xx = x * x; let yy = y * y; let zz = z * z; + let xy = x * y; let xz = x * z; let yz = y * z; + let wx = w * x; let wy = w * y; let wz = w * z; + return mat3x3( + vec3(1.0 - 2.0 * (yy + zz), 2.0 * (xy + wz), 2.0 * (xz - wy)), + vec3(2.0 * (xy - wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz + wx)), + vec3(2.0 * (xz + wy), 2.0 * (yz - wx), 1.0 - 2.0 * (xx + yy)), + ); +} + +@compute @workgroup_size(64) +fn pack(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= params.count { + return; + } + let slot = params.base_input_index + i; + + let pos = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + +#ifdef HAS_ROTATION + let q = vec4( + rotation[i * 4u + 0u], + rotation[i * 4u + 1u], + rotation[i * 4u + 2u], + rotation[i * 4u + 3u], + ); + let basis = quat_to_basis(q); +#else + let basis = mat3x3( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), + ); +#endif + +#ifdef HAS_SCALE + let s = vec3( + scale[i * 3u + 0u], + scale[i * 3u + 1u], + scale[i * 3u + 2u], + ); +#else + let s = vec3(1.0, 1.0, 1.0); +#endif + + // mat3x4: 3 columns of vec4. Each column is one basis (x, y, z) row of the + // affine, with the column's `w` storing the translation component. + let c0 = basis[0] * s.x; + let c1 = basis[1] * s.y; + let c2 = basis[2] * s.z; + mesh_input_buffer[slot].world_from_local = mat3x4( + vec4(c0.x, c1.x, c2.x, pos.x), + vec4(c0.y, c1.y, c2.y, pos.y), + vec4(c0.z, c1.z, c2.z, pos.z), + ); + mesh_input_buffer[slot].tag = i; + + mesh_culling_buffer[slot].aabb_center = vec3(0.0, 0.0, 0.0); + mesh_culling_buffer[slot].aabb_half_extents = vec3(1.0, 1.0, 1.0); +#ifdef HAS_DEAD + mesh_culling_buffer[slot].dead = dead[i]; +#else + mesh_culling_buffer[slot].dead = 0.0; +#endif +} diff --git a/crates/processing_render/src/geometry/attribute.rs b/crates/processing_render/src/geometry/attribute.rs index ecef0b2..6f8391c 100644 --- a/crates/processing_render/src/geometry/attribute.rs +++ b/crates/processing_render/src/geometry/attribute.rs @@ -148,6 +148,15 @@ impl AttributeFormat { } } + pub fn byte_size(self) -> usize { + match self { + Self::Float => 4, + Self::Float2 => 8, + Self::Float3 => 12, + Self::Float4 => 16, + } + } + pub fn from_u8(value: u8) -> Option { match value { 1 => Some(Self::Float), @@ -189,6 +198,22 @@ impl Attribute { } } + /// Like [`Self::from_builtin`] but with a friendly user-facing name. + /// `inner` is the underlying Bevy mesh attribute (used for mesh layout + /// matching); `name` is the identifier the user sees and that custom + /// shaders bind by. + pub fn from_builtin_with_name( + name: &'static str, + inner: MeshVertexAttribute, + format: AttributeFormat, + ) -> Self { + Self { + name, + format, + inner, + } + } + pub fn id(&self) -> u64 { hash_attr_name(self.name) } @@ -200,40 +225,64 @@ pub struct BuiltinAttributes { pub normal: Entity, pub color: Entity, pub uv: Entity, + /// Per-instance rotation as a quaternion `(x, y, z, w)`. Field-only. + pub rotation: Entity, + /// Per-instance scale `(x, y, z)`. Field-only. + pub scale: Entity, + /// Per-particle lifecycle flag: `0.0` = alive, non-zero = dead (skipped in + /// preprocessing). Field-only. The pack pass writes this into + /// `MeshCullingData::dead`. + pub dead: Entity, } impl FromWorld for BuiltinAttributes { fn from_world(world: &mut World) -> Self { let position = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "position", Mesh::ATTRIBUTE_POSITION, AttributeFormat::Float3, )) .id(); let normal = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "normal", Mesh::ATTRIBUTE_NORMAL, AttributeFormat::Float3, )) .id(); let color = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "color", Mesh::ATTRIBUTE_COLOR, AttributeFormat::Float4, )) .id(); let uv = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "uv", Mesh::ATTRIBUTE_UV_0, AttributeFormat::Float2, )) .id(); + let rotation = world + .spawn(Attribute::new("rotation", AttributeFormat::Float4)) + .id(); + let scale = world + .spawn(Attribute::new("scale", AttributeFormat::Float3)) + .id(); + let dead = world + .spawn(Attribute::new("dead", AttributeFormat::Float)) + .id(); Self { position, normal, color, uv, + rotation, + scale, + dead, } } } diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index f885750..001f9f5 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -303,11 +303,12 @@ pub fn mode_3d( let fov = std::f32::consts::PI / 3.0; // 60 degrees let aspect = width / height; let camera_z = (height / 2.0) / (fov / 2.0).tan(); - let near = camera_z / 10.0; + // Processing4 uses near = camera_z / 10, but that clips anything closer + // than ~camera_z/10 to the camera. Since `transform_set_position` lets the + // user move the camera without recomputing the projection, a small fixed + // near is safer and matches most engines' defaults. + let near = 1.0; let far = camera_z * 10.0; - - // TODO: Setting this as a default, but we need to think about API around - // a user defined value let near_clip_plane = vec4(0.0, 0.0, -1.0, -near); let mut projection = projections diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index de39e41..cb73bf1 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -3,6 +3,7 @@ pub mod camera; pub mod color; pub mod compute; +pub mod field; pub mod geometry; pub mod gltf; pub mod graphics; @@ -64,6 +65,7 @@ impl Plugin for ProcessingRenderPlugin { bevy::pbr::wireframe::WireframePlugin::default(), material::custom::CustomMaterialPlugin, compute::ComputePlugin, + field::FieldPlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, @@ -76,6 +78,8 @@ impl Plugin for ProcessingRenderPlugin { flush_draw_commands, add_processing_materials, add_custom_materials, + field::material::add_field_color_materials, + field::material::add_field_pbr_materials, ) .chain() .before(AssetEventSystems), @@ -1078,6 +1082,24 @@ pub fn geometry_attribute_uv() -> Entity { app_mut(|app| Ok(app.world().resource::().uv)).unwrap() } +pub fn geometry_attribute_rotation() -> Entity { + app_mut(|app| { + Ok(app + .world() + .resource::() + .rotation) + }) + .unwrap() +} + +pub fn geometry_attribute_scale() -> Entity { + app_mut(|app| Ok(app.world().resource::().scale)).unwrap() +} + +pub fn geometry_attribute_dead() -> Entity { + app_mut(|app| Ok(app.world().resource::().dead)).unwrap() +} + pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1416,6 +1438,62 @@ pub fn material_create_pbr() -> error::Result { }) } +/// Create an unlit material that reads each particle's color from the given +/// PBuffer. The buffer is expected to hold tightly-packed `Float4` colors +/// (RGBA, 16 bytes per particle). +pub fn material_create_field_color(color_buffer_entity: Entity) -> error::Result { + use crate::field::material::FieldColorMaterial; + use crate::render::material::UntypedMaterial; + app_mut(|app| { + let handle = app + .world() + .get::(color_buffer_entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let world = app.world_mut(); + let asset_handle = world + .resource_mut::>() + .add(FieldColorMaterial { colors: handle }); + Ok(world + .spawn(UntypedMaterial(asset_handle.untyped())) + .id()) + }) +} + +/// PBR-lit version of [`material_create_field_color`]. Particles get standard +/// directional/point/spot lighting; per-particle color from the buffer +/// modulates the StandardMaterial base color (default white). +pub fn material_create_field_pbr(color_buffer_entity: Entity) -> error::Result { + use bevy::pbr::ExtendedMaterial; + use crate::field::material::{FieldPbrExtension, FieldPbrMaterial}; + use crate::render::material::UntypedMaterial; + app_mut(|app| { + let handle = app + .world() + .get::(color_buffer_entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let world = app.world_mut(); + let asset_handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 0.4, + metallic: 0.0, + cull_mode: None, + ..default() + }, + extension: FieldPbrExtension { colors: handle }, + }); + Ok(world + .spawn(UntypedMaterial(asset_handle.untyped())) + .id()) + }) +} + pub fn material_set( entity: Entity, name: impl Into, @@ -1853,3 +1931,168 @@ pub fn compute_destroy(entity: Entity) -> error::Result<()> { .unwrap() }) } + +pub fn field_create(capacity: u32, attribute_entities: Vec) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(field::create, (capacity, attribute_entities)) + .unwrap() + }) +} + +/// Create a Field whose capacity matches `geometry`'s vertex count and whose +/// PBuffers are pre-seeded from the geometry's mesh attributes when names line +/// up (`position`, `normal`, `color`, `uv`). Custom attributes the mesh doesn't +/// supply are zero-initialized — the user fills them via `buffer_write` or +/// `field_emit`. +pub fn field_create_from_geometry( + geometry_entity: Entity, + attribute_entities: Vec, +) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + field::create_from_geometry, + (geometry_entity, attribute_entities), + ) + .unwrap() + }) +} + +pub fn field_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(field::destroy, entity) + .unwrap() + }) +} + +pub fn field_capacity(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::FieldNotFound)? + .capacity) + }) +} + +pub fn field_pbuffer(entity: Entity, attribute_entity: Entity) -> error::Result> { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::FieldNotFound)? + .pbuffer(attribute_entity)) + }) +} + +/// Emit `n` particles into a Field, writing per-attribute byte payloads into the +/// next `n` slots starting at the field's ring-buffer head. Each entry in +/// `attribute_data` must match the registered attribute's `byte_size * n`. +/// On wrap, oldest particles in the ring are overwritten. +pub fn field_emit( + field_entity: Entity, + n: u32, + attribute_data: Vec<(Entity, Vec)>, +) -> error::Result<()> { + if n == 0 { + return Ok(()); + } + + let (capacity, head, attr_specs) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(field_entity) + .ok_or(error::ProcessingError::FieldNotFound)?; + if n > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "field_emit n={} exceeds field capacity {}", + n, field.capacity + ))); + } + let mut specs: Vec<(Entity, u32, Entity)> = Vec::with_capacity(attribute_data.len()); + for (attr_entity, _) in &attribute_data { + let attr = world + .get::(*attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + let pbuf = field.pbuffer(*attr_entity).ok_or_else(|| { + error::ProcessingError::InvalidArgument(format!( + "field has no PBuffer for attribute {:?}", + attr_entity + )) + })?; + specs.push((*attr_entity, attr.format.byte_size() as u32, pbuf)); + } + Ok((field.capacity, field.emit_head, specs)) + })?; + + for ((_, bytes), &(_, byte_size, pbuf)) in attribute_data.iter().zip(attr_specs.iter()) { + let expected = (n as usize) * (byte_size as usize); + if bytes.len() != expected { + return Err(error::ProcessingError::InvalidArgument(format!( + "expected {} bytes ({} particles * {} bytes), got {}", + expected, + n, + byte_size, + bytes.len() + ))); + } + let first_chunk_n = (capacity - head).min(n); + let split = (first_chunk_n as usize) * (byte_size as usize); + let first_offset = (head as u64) * (byte_size as u64); + buffer_write_element(pbuf, first_offset, bytes[..split].to_vec())?; + if first_chunk_n < n { + buffer_write_element(pbuf, 0, bytes[split..].to_vec())?; + } + } + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(field_entity) + .ok_or(error::ProcessingError::FieldNotFound)?; + field.emit_head = (field.emit_head + n) % field.capacity; + Ok(()) + }) +} + +/// Dispatch a compute pass against a Field's PBuffers. Each PBuffer is bound +/// by its attribute's name; bindings the shader doesn't declare are skipped. +/// Workgroup size is fixed at 64 — the shader must declare `@workgroup_size(64)`. +/// +/// Any non-PBuffer parameters (uniforms, etc.) on the compute should be set via +/// `compute_set` before calling this. +pub fn field_apply(field_entity: Entity, compute_entity: Entity) -> error::Result<()> { + const WORKGROUP_SIZE: u32 = 64; + + let (capacity, pbuffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(field_entity) + .ok_or(error::ProcessingError::FieldNotFound)?; + let mut pbuffers: Vec<(String, Entity)> = Vec::with_capacity(field.pbuffers.len()); + for (&attr_entity, &pbuf_entity) in &field.pbuffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + pbuffers.push((attr.name.to_string(), pbuf_entity)); + } + Ok((field.capacity, pbuffers)) + })?; + + for (name, pbuf_entity) in pbuffers { + match compute_set( + compute_entity, + name, + shader_value::ShaderValue::Buffer(pbuf_entity), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + let workgroup_count = capacity.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1) +} diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 94c79fc..c468a1f 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -424,6 +424,10 @@ pub enum DrawCommand { angle: f32, }, Geometry(Entity), + Field { + field: Entity, + geometry: Entity, + }, BlendMode(Option), Material(Entity), Box { diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 4f73922..89cdcdf 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -5,9 +5,10 @@ pub mod primitive; pub mod transform; use bevy::{ - camera::visibility::RenderLayers, + camera::{primitives::Aabb, visibility::RenderLayers}, ecs::system::SystemParam, - math::{Affine3A, Mat4, Vec4}, + math::{Affine3A, Mat4, Vec3A, Vec4}, + pbr::gpu_instance_batch::GpuBatchedMesh3d, prelude::*, render::render_resource::BlendState, }; @@ -23,6 +24,7 @@ use transform::TransformStack; use crate::{ Flush, + field::{Field, FieldDraw}, geometry::Geometry, gltf::GltfNodeTransform, image::Image, @@ -158,6 +160,7 @@ pub fn flush_draw_commands( p_images: Query<&Image>, p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, + mut p_fields: Query<&mut Field>, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() @@ -896,6 +899,74 @@ pub fn flush_draw_commands( batch.draw_index += 1; } + DrawCommand::Field { field, geometry } => { + let Some((geometry_data, _)) = p_geometries.get(geometry).ok() else { + warn!("Could not find Geometry for entity {:?}", geometry); + continue; + }; + let Ok(mut field_data) = p_fields.get_mut(field) else { + warn!("Could not find Field for entity {:?}", field); + continue; + }; + + let material_key = material_key_with_fill(&state); + let material_handle = match &material_key { + MaterialKey::Custom { + entity: mat_entity, + blend_state, + } => { + let Some(untyped) = p_material_handles.get(*mat_entity).ok() else { + warn!("Could not find material for entity {:?}", mat_entity); + continue; + }; + clone_custom_material_with_blend( + &mut res.custom_materials, + &untyped.0, + *blend_state, + ) + } + _ => material_key.to_material(&mut res.materials), + }; + + flush_batch(&mut res, &mut batch, &p_material_handles); + + let mesh_handle = geometry_data.handle.clone(); + let capacity = field_data.capacity; + let render_layers = batch.render_layers.clone(); + match field_data.draw_entity { + Some(e) => { + res.commands.entity(e).insert(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + render_layers, + )); + } + None => { + let e = res + .commands + .spawn(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + Aabb { + center: Vec3A::ZERO, + half_extents: Vec3A::splat(1000.0), + }, + FieldDraw { field }, + render_layers, + )) + .id(); + field_data.draw_entity = Some(e); + } + } + + batch.draw_index += 1; + } DrawCommand::BlendMode(blend_state) => { state.blend_state = blend_state; } diff --git a/docs/field.md b/docs/field.md new file mode 100644 index 0000000..9ab7e36 --- /dev/null +++ b/docs/field.md @@ -0,0 +1,278 @@ +# Field — GPU-resident particle and instancing + +A `Field` is a GPU-resident container of named attribute buffers, drawn by instancing a +geometry once per element. It is the libprocessing analogue of a Houdini point cloud: a +collection of points carrying arbitrary named attributes, where storage is contextual and +attributes are first-class. + +The high-level model is built on two existing libprocessing systems and one upstream +contribution: + +- The `compute::Buffer` infrastructure (`crates/processing_render/src/compute.rs`) + provides typed GPU storage buffers, CPU-side write, GPU readback, compute dispatch, + and a Python wrapper that tracks element type for validation. +- The `Attribute` system (`crates/processing_render/src/geometry/attribute.rs`) provides + named, typed attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) + and a `BuiltinAttributes` resource holding stable entity IDs for `position`, `normal`, + `color`, `uv`. The same identities flow through Geometries (per-vertex) and Fields + (per-instance). +- Upstream `processing/bevy` commit `ee443e51` adds `GpuBatchedMesh3d` and the + `GpuInstanceBatchReservations` machinery — a fixed-capacity batch where compute can + write per-instance transforms into the upstream input buffer before + `early_gpu_preprocess` consumes them. + +## Concepts + +### Field + +The top-level container. Holds a set of named PBuffers (one per registered attribute), +the upstream reservation handle, and lifecycle metadata (capacity, emission head). Does +not carry geometry — it is the GPU compute context, not the shape that gets drawn. + +### PBuffer + +A single typed GPU storage buffer holding the values for one attribute across all +elements of a Field. Backed by `compute::Buffer`. Indexed by particle slot. + +### Attribute + +The naming and type identity for a buffer of values. Already exists. A `Field` registers +PBuffers against `Attribute` entities; lookups are typed entity comparisons, never string +matches. Format is declared at attribute creation and is the source of truth for element +size and shader-side type. + +### Draw verb: `field` + +`field(f, shape)` is the rasterization verb, analogous to `shape()`. Consumes ambient +material/fill/stroke state at call time and instances `shape` once per slot in `f`. + +## Lifecycle + +### Construction + +``` +let f = createField(|| { + sphere(1.0); // immediate-mode shape API + // ... +}, capacity: 10_000); +``` + +The closure runs once and seeds initial attribute values via the existing immediate-mode +shape API (`beginShape`/`vertex`/`endShape`, `sphere`, `box`, etc.) The mapping is 1:1 +from emitted vertices to particle slots. `capacity` is the upstream reservation size; if +omitted, it defaults to the closure's emitted vertex count. + +`createField` called inside `draw()` emits a warning (hard error in strict mode), since +re-uploading every frame defeats the point of GPU residence. + +### Apply + +``` +f.apply(NOISE, target: builtins.position, scale: 0.5) + .apply(CURL, target: builtins.position, strength: 1.0) + .apply(custom_kernel(my_shader)); +``` + +`apply()` dispatches a compute pass against the field. It is **chainable** — returns the +field. Built-in kernels (`NOISE`, `CURL`, `TURBULENCE`, etc.) are named constants; +custom WGSL is a separate constructor that takes a `Shader` and declares which +attributes it reads/writes. + +`apply()` calls placed in `setup()` run once. `apply()` calls in `draw()` run every +frame — the retained-vs-dynamic distinction is purely about placement, not API. + +`apply()` only ever touches PBuffers and uniforms. It has no knowledge of upstream +mesh-input buffers or render-side state. This keeps user-authored kernels free of +upstream coupling. + +### Draw + +``` +fill(255, 100, 50); +field(f, sphere_shape); +``` + +The draw verb reads ambient material state at call time, dispatches the pack pass for +this field if not already packed this frame, and issues the instanced raster. + +### Read / write + +``` +let positions = f.read(builtins.position); // CPU readback as typed values +f.write(builtins.velocity, [...]); // CPU upload +``` + +Inherited from the `compute::Buffer` Python surface (typed `__getitem__`/`__setitem__`, +`read()`, `write()`). + +## Compute model + +### apply() is PBuffer-only + +A compute dispatch from `apply()` binds the field's PBuffers (those the kernel +declares it needs) plus any uniforms. It does not bind the upstream +`mesh_input_buffer`, `MeshCullingDataBuffer`, or any other upstream-managed resource. +This means kernel authors — including users writing CUSTOM WGSL — never need to know +upstream internals. + +### Pack pass + +The pack pass is the only code that bridges to the upstream batch infrastructure. It +runs once per `(frame, field)`, lazily, **only when** `field(f, shape)` is called this +frame. If a Field is used purely offline (apply + read), pack never runs and the +upstream input slots stay untouched. + +Pack reads the current `position` / `rotation` / `scale` / lifecycle PBuffers, builds +the `world_from_local` `mat3x4`, and writes: + +- `mesh_input_buffer[base + i].world_from_local` +- `mesh_input_buffer[base + i].tag = i` (the side-channel index for material shaders) +- `MeshCullingData[base + i].dead` from the field's lifecycle PBuffer if present + +A CPU-side dirty flag on the Field component prevents redundant packing when the field +is drawn multiple times in one frame (e.g. shadow + main, multiple cameras). + +### In-place vs ping-pong + +The default for `apply()` is **in-place mutation**. The kernel reads `state[i]`, writes +back to `state[i]`. This is correct for every kernel that doesn't read other particles' +slots — which covers the overwhelming majority of creative-coding particle work +(noise/curl/drag/integration/attractor/repulsor). It is what the upstream +`gpu_particles` example does. + +Kernels that read neighbor slots (smoothing, SPH-style fluid, sort steps) must opt into +ping-pong. Built-in kernels declare their access pattern; CUSTOM kernels accept a +`mode: PingPong` argument: + +``` +f.apply(custom_kernel(my_shader), mode: PingPong); +``` + +When ping-pong is requested, libprocessing transparently allocates the shadow buffer per +affected attribute and swaps after the dispatch. The user sees a single logical PBuffer +per Attribute regardless. + +Between distinct `apply()` calls, no swap is needed — render-graph barriers between +sequential dispatches make in-place chaining correct. + +## Material integration + +### Ambient state + +`field(f, shape)` participates in the same ambient material/fill/stroke state machine as +`shape()`. No new public material API. + +### Default material — `ProcessingMaterial` + +`ProcessingMaterial` is extended to consume tag-indexed PBuffers for the common +per-particle cases — at minimum **per-particle color**, so a `color` PBuffer and a +default `fill()` together produce per-particle tinting with the default material. This +is implemented as a tag-indexed storage-buffer read inside the material's fragment path: +`color_buffer[in.tag]` if the field declared a `color` PBuffer; fall through to ambient +fill otherwise. + +### Custom material + +Anything richer than what `ProcessingMaterial` consumes requires a `CustomMaterial`. The +user's WGSL declares storage bindings for the PBuffers it cares about and reads them +indexed by `in.tag`. The framework wires the bindings; the user writes the shader. + +The asymmetry must be honest in the docs: per-particle color works with the default +material; per-particle UV / scale / arbitrary scalar attributes require a custom +material. + +PBuffers do not bind to `@location(N)` per-instance vertex inputs. The upstream batch +infrastructure does not support per-instance attributes beyond the transform; the tag +side-channel is the route. + +## Capacity, emission, lifecycle + +Capacity is fixed at field creation. Two distinct emission patterns are supported, only +one of which needs new API. + +### Continuous self-recycling — no new API + +A field set up with a fixed population, where particles respawn on death within a +single user-authored kernel. The upstream `gpu_particles` example uses `pos.w` as a +lifecycle counter and a respawn branch in the simulate shader. This works on a regular +Field with a CUSTOM apply — the user's WGSL handles birth and death internally. No +emit primitive is needed; document the pattern, ship nothing. + +### Discrete emission — ring buffer + +When user code says "spawn N particles right now," use a ring buffer: + +``` +on_mouse_pressed: + f.emit(50, |w| { + w[builtins.position] = mouse_world_pos(); + w[builtins.velocity] = random_unit_vec3() * 5.0; + }); +``` + +Field carries a CPU-side `emit_head: u32`. `emit(n, init)` writes attribute values to +slots `[head, head + n) mod capacity` via `compute::Buffer::write_buffer_cpu`, then +advances head. No GPU-side allocator, no atomics, no compaction. + +When the ring wraps, oldest particles are overwritten — graceful degradation if +emission outruns lifespan. Capacity is therefore a visible contract: +`capacity >= peak_emission_rate * longest_lifespan`. + +Aliveness for raster: a particle is considered alive if its lifecycle PBuffer says so. +The pack pass writes `MeshCullingData[slot].dead` accordingly. The user is responsible +for setting the lifecycle PBuffer in their apply (typically `dead = age >= lifespan ? +1.0 : 0.0`). + +## Immediate-mode compatibility + +The settled "automatic instancing of repeated draw calls with the same material" +remains the immediate-mode escape hatch. A user looping `translate; sphere()` gets +auto-instancing for free, no Field needed. Field is for cases where compute matters or +populations are large and dynamic. + +`createField` inside `draw()` warns (hard error in strict mode). There is no separate +"ephemeral field" API — the warning is the educational nudge. Most rebuild-every-frame +intentions should be a static Field with a per-frame `apply(CUSTOM, ...)`. + +## Upstream bridge + +A Field is backed by a single entity carrying: + +- `GpuBatchedMesh3d { mesh, max_capacity }` — upstream +- `MeshMaterial3d` — upstream, ambient material handle +- `Field` — libprocessing component holding the PBuffer map, ring-buffer head, dirty + flag, and other lifecycle metadata +- An `Aabb` for culling + +`GpuBatchedMesh3d` and `Mesh3d` are mutually exclusive on one entity by upstream design; +the immediate-mode `Mesh3d` path is not available on a Field entity, and vice versa. + +The pack pass schedules its work to land in the render world before +`early_gpu_preprocess`. It does not register as a `Render` system; it is called inline +from the Field draw-command processor. + +## Non-goals (v1) + +- **GPU-driven emission.** No GPU-side atomic counter / dead-slot allocator. Emission + is CPU-driven only. +- **Sparse alive set / compaction.** Every reserved slot is part of the rendered batch; + cull happens via the per-slot `dead` flag. +- **Per-instance attributes beyond the tag side-channel.** Upstream does not support + per-instance vertex inputs other than the transform; the tag plus storage-buffer + lookup is the only mechanism. +- **Multi-emitter pools.** A Field is one ring buffer. Use multiple Fields if logical + separation is needed. +- **Cross-field operations.** No `apply()` that reads from one field and writes to + another. Single-field kernels only. + +## Open questions + +- **Rotation format ambiguity.** `Float3` rotation = euler, `Float4` = quat is decided + at attribute registration. Worth re-examining if users frequently want one and get + the other; alternatively, ship a typed wrapper helper. +- **Multiple cameras / shadow path.** Pack-once-per-frame assumes the upstream input is + the same across all views. If a future camera-specific pass needs different + per-instance state, the dirty model needs to grow per-view. +- **Custom material binding declaration.** How a `CustomMaterial` declares which Field + PBuffers it needs as storage bindings is unsettled. Likely an explicit + `material.bind("color", attribute)` call at material creation time. diff --git a/examples/field_animated.rs b/examples/field_animated.rs new file mode 100644 index 0000000..4181d71 --- /dev/null +++ b/examples/field_animated.rs @@ -0,0 +1,102 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const SPIN_SHADER: &str = r#" +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 8.0, 25.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + let capacity: u32 = 1000; + let mut floats: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + floats.push((x as f32 - 4.5) * 1.0); + floats.push((y as f32 - 4.5) * 1.0); + floats.push((z as f32 - 4.5) * 1.0); + } + } + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + + let position_attr = geometry_attribute_position(); + let field = field_create(capacity, vec![position_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write(position_buf, bytes)?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + + compute_set(spin, "dt", shader_value::ShaderValue::Float(0.01))?; + field_apply(field, spin)?; + } + + Ok(()) +} diff --git a/examples/field_basic.rs b/examples/field_basic.rs new file mode 100644 index 0000000..9cd365f --- /dev/null +++ b/examples/field_basic.rs @@ -0,0 +1,73 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 0.0, 25.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + // 10x10x10 grid of positions in a 9-unit cube centered at the origin. + let capacity: u32 = 1000; + let mut floats: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + floats.push((x as f32 - 4.5) * 1.0); + floats.push((y as f32 - 4.5) * 1.0); + floats.push((z as f32 - 4.5) * 1.0); + } + } + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + + let position_attr = geometry_attribute_position(); + let field = field_create(capacity, vec![position_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write(position_buf, bytes)?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/field_colored.rs b/examples/field_colored.rs new file mode 100644 index 0000000..65d97ee --- /dev/null +++ b/examples/field_colored.rs @@ -0,0 +1,79 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + // 10x10x10 grid with per-particle position + color (RGB gradient by index). + let capacity: u32 = 1000; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + positions.push((x as f32 - 4.5) * 1.0); + positions.push((y as f32 - 4.5) * 1.0); + positions.push((z as f32 - 4.5) * 1.0); + colors.push(x as f32 / 9.0); + colors.push(y as f32 / 9.0); + colors.push(z as f32 / 9.0); + colors.push(1.0); + } + } + } + + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let field = field_create(capacity, vec![position_attr, color_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = material_create_field_color(color_buf)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/field_colored_pbr.rs b/examples/field_colored_pbr.rs new file mode 100644 index 0000000..1100c46 --- /dev/null +++ b/examples/field_colored_pbr.rs @@ -0,0 +1,82 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + let sphere = geometry_sphere(0.3, 16, 12)?; + + // 8x8x8 grid with per-particle color (RGB gradient by index). + let capacity: u32 = 512; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + for x in 0..8 { + for y in 0..8 { + for z in 0..8 { + positions.push((x as f32 - 3.5) * 1.4); + positions.push((y as f32 - 3.5) * 1.4); + positions.push((z as f32 - 3.5) * 1.4); + colors.push(x as f32 / 7.0); + colors.push(y as f32 / 7.0); + colors.push(z as f32 / 7.0); + colors.push(1.0); + } + } + } + + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let field = field_create(capacity, vec![position_attr, color_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = material_create_field_pbr(color_buf)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/field_emit.rs b/examples/field_emit.rs new file mode 100644 index 0000000..dff6623 --- /dev/null +++ b/examples/field_emit.rs @@ -0,0 +1,111 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 14.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.08, 8, 6)?; + + let capacity: u32 = 2000; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let field = field_create(capacity, vec![position_attr, color_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + + // Push unemitted slots far off-screen so they don't all render at the + // origin while the ring buffer is still filling. + let init_positions: Vec = (0..capacity * 3).map(|_| 1.0e6).collect(); + buffer_write( + position_buf, + init_positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = material_create_field_color(color_buf)?; + + let mut frame: u32 = 0; + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + + // Emit 4 particles per frame in an outward-spiraling ring; once the ring + // buffer fills (~500 frames at 4/frame for capacity 2000), oldest get + // overwritten and the swirl continues without bound. + let burst = 4u32; + let mut positions: Vec = Vec::with_capacity(burst as usize * 3); + let mut colors: Vec = Vec::with_capacity(burst as usize * 4); + for k in 0..burst { + let i = frame * burst + k; + let t = i as f32 * 0.05; + let radius = 1.5 + (t * 0.02).min(3.0); + let height = ((t * 0.1).sin()) * 2.0; + positions.push(t.cos() * radius); + positions.push(height); + positions.push(t.sin() * radius); + // Hue sweep based on emission index. + let h = (i as f32 * 0.012) % 1.0; + let (r, g, b) = hsv_to_rgb(h, 0.85, 1.0); + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(1.0); + } + + let position_bytes: Vec = positions.iter().flat_map(|f| f.to_le_bytes()).collect(); + let color_bytes: Vec = colors.iter().flat_map(|f| f.to_le_bytes()).collect(); + field_emit( + field, + burst, + vec![(position_attr, position_bytes), (color_attr, color_bytes)], + )?; + frame += 1; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32) % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/field_from_mesh.rs b/examples/field_from_mesh.rs new file mode 100644 index 0000000..28bff34 --- /dev/null +++ b/examples/field_from_mesh.rs @@ -0,0 +1,93 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + // Source mesh whose vertices become the particle positions. UVs come along + // for free and we'll use them to paint each particle a unique color. + let source = geometry_sphere(5.0, 32, 24)?; + + let position_attr = geometry_attribute_position(); + let uv_attr = geometry_attribute_uv(); + let color_attr = geometry_attribute_color(); + + // Position + uv come straight from the source sphere; color is allocated + // empty and we fill it from uv values. + let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + let uv_buf = + field_pbuffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = + field_pbuffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + + // Read uvs back, build per-particle colors from them, write to color PBuffer. + let uv_bytes = buffer_read(uv_buf)?; + let mut colors: Vec = Vec::with_capacity(uv_bytes.len() * 2); + for chunk in uv_bytes.chunks_exact(8) { + let u = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let v = f32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]); + let (r, g, b) = hsv_to_rgb(u, 0.85, 1.0); + for f in [r, g, b, 1.0] { + colors.extend_from_slice(&f.to_le_bytes()); + } + let _ = v; + } + buffer_write(color_buf, colors)?; + + let particle = geometry_sphere(0.18, 10, 8)?; + let mat = material_create_field_pbr(color_buf)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: particle, + }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32).rem_euclid(6) { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/field_lifecycle.rs b/examples/field_lifecycle.rs new file mode 100644 index 0000000..13e8c09 --- /dev/null +++ b/examples/field_lifecycle.rs @@ -0,0 +1,191 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::geometry::AttributeFormat; +use processing_render::render::command::DrawCommand; + +const AGING_SHADER: &str = r#" +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var dead: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var params: vec4; // x = dt, y = ttl + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { + return; + } + let dt = params.x; + let ttl = params.y; + + if dead[i] != 0.0 { + return; + } + + age[i] = age[i] + dt; + // gravity-ish drop + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + // Shrink toward zero as age approaches ttl so dying is visible. + let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = life * life; // ease out + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + dead[i] = 1.0; + } +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 2.0, 14.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.1, 8, 6)?; + + let capacity: u32 = 800; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let scale_attr = geometry_attribute_scale(); + let dead_attr = geometry_attribute_dead(); + let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; + + let field = field_create( + capacity, + vec![ + position_attr, + color_attr, + scale_attr, + dead_attr, + age_attr, + ], + )?; + let dead_buf = field_pbuffer(field, dead_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + + // Mark all slots dead initially so the unemitted ring slots don't render. + let init_dead: Vec = (0..capacity) + .flat_map(|_| 1.0_f32.to_le_bytes()) + .collect(); + buffer_write(dead_buf, init_dead)?; + + let mat = material_create_field_color(color_buf)?; + let aging_shader = shader_create(AGING_SHADER)?; + let aging = compute_create(aging_shader)?; + + // burst × (ttl × 60) ≈ steady-state alive count (~360 here, well under capacity 800). + let burst: u32 = 6; + let dt: f32 = 1.0 / 60.0; + let ttl: f32 = 1.0; + let mut frame: u32 = 0; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: sphere, + }, + )?; + graphics_end_draw(graphics)?; + + // Spawn `burst` new particles per frame in a small fountain. + let mut positions: Vec = Vec::with_capacity(burst as usize * 3); + let mut colors: Vec = Vec::with_capacity(burst as usize * 4); + for k in 0..burst { + let i = frame * burst + k; + // Cheap pseudo-random offset. + let u = ((i.wrapping_mul(2654435761) >> 8) & 0xFFFF) as f32 / 65535.0; + let v = ((i.wrapping_mul(40503) >> 8) & 0xFFFF) as f32 / 65535.0; + let theta = u * std::f32::consts::TAU; + let r = v * 0.6; + positions.push(theta.cos() * r); + positions.push(2.5); + positions.push(theta.sin() * r); + let h = (i as f32 * 0.013) % 1.0; + let (cr, cg, cb) = hsv_to_rgb(h, 0.85, 1.0); + colors.push(cr); + colors.push(cg); + colors.push(cb); + colors.push(1.0); + } + let position_bytes: Vec = positions.iter().flat_map(|f| f.to_le_bytes()).collect(); + let color_bytes: Vec = colors.iter().flat_map(|f| f.to_le_bytes()).collect(); + let zero_floats: Vec = (0..burst).flat_map(|_| 0.0_f32.to_le_bytes()).collect(); + // Reset scale to 1 for newly emitted particles (the aging shader will + // shrink them as age progresses). + let one_scale: Vec = (0..burst) + .flat_map(|_| { + [1.0_f32, 1.0, 1.0] + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect::>() + }) + .collect(); + field_emit( + field, + burst, + vec![ + (position_attr, position_bytes), + (color_attr, color_bytes), + (scale_attr, one_scale), + (age_attr, zero_floats.clone()), + (dead_attr, zero_floats), + ], + )?; + + // Age + drop + kill. + compute_set( + aging, + "params", + shader_value::ShaderValue::Float4([dt, ttl, 0.0, 0.0]), + )?; + field_apply(field, aging)?; + + frame += 1; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32) % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/field_oriented.rs b/examples/field_oriented.rs new file mode 100644 index 0000000..054d562 --- /dev/null +++ b/examples/field_oriented.rs @@ -0,0 +1,145 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const SPIN_SHADER: &str = r#" +@group(0) @binding(0) var rotation: array; +@group(0) @binding(1) var params: vec4; // x = dt + +fn quat_mul(a: vec4, b: vec4) -> vec4 { + return vec4( + a.w * b.xyz + b.w * a.xyz + cross(a.xyz, b.xyz), + a.w * b.w - dot(a.xyz, b.xyz), + ); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&rotation) / 4u; + if i >= count { + return; + } + let dt = params.x; + let q = vec4( + rotation[i * 4u + 0u], + rotation[i * 4u + 1u], + rotation[i * 4u + 2u], + rotation[i * 4u + 3u], + ); + let half_angle = dt * 0.5; + let dq = vec4(0.0, sin(half_angle), 0.0, cos(half_angle)); + let q_new = quat_mul(q, dq); + rotation[i * 4u + 0u] = q_new.x; + rotation[i * 4u + 1u] = q_new.y; + rotation[i * 4u + 2u] = q_new.z; + rotation[i * 4u + 3u] = q_new.w; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let cube = geometry_box(0.6, 0.6, 0.6)?; + + let capacity: u32 = 125; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut rotations: Vec = Vec::with_capacity(capacity as usize * 4); + let mut scales: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..5 { + for y in 0..5 { + for z in 0..5 { + positions.push((x as f32 - 2.0) * 1.6); + positions.push((y as f32 - 2.0) * 1.6); + positions.push((z as f32 - 2.0) * 1.6); + // identity quat + rotations.push(0.0); + rotations.push(0.0); + rotations.push(0.0); + rotations.push(1.0); + // scale varies per position + let s = 0.5 + ((x + y + z) as f32 * 0.06); + scales.push(s); + scales.push(s); + scales.push(s); + } + } + } + + let position_attr = geometry_attribute_position(); + let rotation_attr = geometry_attribute_rotation(); + let scale_attr = geometry_attribute_scale(); + let field = field_create(capacity, vec![position_attr, rotation_attr, scale_attr])?; + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let rotation_buf = field_pbuffer(field, rotation_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let scale_buf = field_pbuffer(field, scale_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + rotation_buf, + rotations.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + scale_buf, + scales.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: cube, + }, + )?; + graphics_end_draw(graphics)?; + + compute_set( + spin, + "params", + shader_value::ShaderValue::Float4([0.015, 0.0, 0.0, 0.0]), + )?; + field_apply(field, spin)?; + } + + Ok(()) +} From 5ae5558f099b7a95d8440f2d7385eadbffae16bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 1 May 2026 14:30:02 -0700 Subject: [PATCH 07/11] . --- Cargo.lock | 1 + Cargo.toml | 12 + crates/processing_core/src/lib.rs | 12 +- crates/processing_pyo3/Cargo.toml | 1 + .../examples/field_animated.py | 71 +++ .../processing_pyo3/examples/field_basic.py | 59 +++ crates/processing_pyo3/src/compute.rs | 22 + crates/processing_pyo3/src/field.rs | 250 +++++++++++ crates/processing_pyo3/src/graphics.rs | 32 ++ crates/processing_pyo3/src/lib.rs | 37 +- crates/processing_pyo3/src/material.rs | 18 + .../src/field/kernels/mod.rs | 16 + .../src/field/kernels/noise.wgsl | 69 +++ crates/processing_render/src/field/mod.rs | 2 + crates/processing_render/src/lib.rs | 106 ++++- .../processing_render/src/material/custom.rs | 20 +- docs/field.md | 424 +++++++++--------- examples/field_emit_gpu.rs | 223 +++++++++ examples/field_noise.rs | 97 ++++ examples/field_stress.rs | 145 ++++++ 20 files changed, 1399 insertions(+), 218 deletions(-) create mode 100644 crates/processing_pyo3/examples/field_animated.py create mode 100644 crates/processing_pyo3/examples/field_basic.py create mode 100644 crates/processing_pyo3/src/field.rs create mode 100644 crates/processing_render/src/field/kernels/mod.rs create mode 100644 crates/processing_render/src/field/kernels/noise.wgsl create mode 100644 examples/field_emit_gpu.rs create mode 100644 examples/field_noise.rs create mode 100644 examples/field_stress.rs diff --git a/Cargo.lock b/Cargo.lock index 1f4b6c4..827bfd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5732,6 +5732,7 @@ dependencies = [ "processing", "processing_cuda", "processing_glfw", + "processing_render", "processing_webcam", "pyo3", "rand 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index f85c12b..b076897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -197,6 +197,18 @@ path = "examples/field_lifecycle.rs" name = "field_from_mesh" path = "examples/field_from_mesh.rs" +[[example]] +name = "field_noise" +path = "examples/field_noise.rs" + +[[example]] +name = "field_emit_gpu" +path = "examples/field_emit_gpu.rs" + +[[example]] +name = "field_stress" +path = "examples/field_stress.rs" + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/crates/processing_core/src/lib.rs b/crates/processing_core/src/lib.rs index 2a9118b..4f9ad4f 100644 --- a/crates/processing_core/src/lib.rs +++ b/crates/processing_core/src/lib.rs @@ -14,7 +14,10 @@ thread_local! { } pub fn app_mut(cb: impl FnOnce(&mut App) -> error::Result) -> error::Result { - let res = APP.with(|app_cell| { + // `try_with` rather than `with` so callers (especially `Drop`s running + // during pyo3 module teardown) get a graceful error instead of a panic + // when the thread-local has already been destroyed. + let res = APP.try_with(|app_cell| { let mut app_borrow = app_cell .try_borrow_mut() .map_err(|_| error::ProcessingError::AppAccess)?; @@ -22,8 +25,11 @@ pub fn app_mut(cb: impl FnOnce(&mut App) -> error::Result) -> error::Resul .as_mut() .ok_or(error::ProcessingError::AppAccess)?; cb(app) - })?; - Ok(res) + }); + match res { + Ok(inner) => inner, + Err(_) => Err(error::ProcessingError::AppAccess), + } } pub fn is_already_init() -> error::Result { diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 13b7423..f719f5c 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -21,6 +21,7 @@ cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] [dependencies] pyo3 = { workspace = true, features = ["experimental-inspect", "multiple-pymethods"] } processing = { workspace = true } +processing_render = { workspace = true } processing_webcam = { workspace = true, optional = true } processing_glfw = { workspace = true } bevy = { workspace = true, features = ["file_watcher"] } diff --git a/crates/processing_pyo3/examples/field_animated.py b/crates/processing_pyo3/examples/field_animated.py new file mode 100644 index 0000000..17137ad --- /dev/null +++ b/crates/processing_pyo3/examples/field_animated.py @@ -0,0 +1,71 @@ +from mewnala import * + +field_obj = None +sphere = None +mat = None +spin = None + +SPIN_SHADER = """ +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +""" + + +def setup(): + global field_obj, sphere, mat, spin + + size(900, 700) + mode_3d() + + create_directional_light((0.9, 0.85, 0.8), 300.0) + + sphere = Geometry.sphere(0.25, 12, 8) + + capacity = 1000 + positions = [] + for x in range(10): + for y in range(10): + for z in range(10): + positions.extend([x - 4.5, y - 4.5, z - 4.5]) + + field_obj = Field(capacity=capacity, attributes=[Attribute.position()]) + pos_buf = field_obj.pbuffer(Attribute.position()) + pos_buf.write(positions) + + mat = Material(roughness=0.4) + spin = Compute(Shader(SPIN_SHADER)) + + +def draw(): + camera_position(0.0, 8.0, 25.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + fill(230, 128, 75) + + use_material(mat) + draw_field(field_obj, sphere) + + spin.set(dt=0.01) + field_obj.apply(spin) + + +run() diff --git a/crates/processing_pyo3/examples/field_basic.py b/crates/processing_pyo3/examples/field_basic.py new file mode 100644 index 0000000..3fcf5bd --- /dev/null +++ b/crates/processing_pyo3/examples/field_basic.py @@ -0,0 +1,59 @@ +from mewnala import * + +field_obj = None +particle = None +mat = None + + +def setup(): + global field_obj, particle, mat + + size(900, 700) + mode_3d() + + create_directional_light((0.95, 0.9, 0.85), 600.0) + + # Source mesh whose vertices become particle positions; uvs come along for + # free and we use them to color each particle. + source = Geometry.sphere(5.0, 32, 24) + field_obj = Field( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + # Read uvs back, build per-particle colors, write to color PBuffer. + color_buf = field_obj.pbuffer(Attribute.color()) + uv_buf = field_obj.pbuffer(Attribute.uv()) + colors = [] + for uv in uv_buf.read(): + u = uv[0] + h = u * 6.0 + c = h - int(h) + if h < 1: + colors.append([1.0, c, 0.0, 1.0]) + elif h < 2: + colors.append([1.0 - c, 1.0, 0.0, 1.0]) + elif h < 3: + colors.append([0.0, 1.0, c, 1.0]) + elif h < 4: + colors.append([0.0, 1.0 - c, 1.0, 1.0]) + elif h < 5: + colors.append([c, 0.0, 1.0, 1.0]) + else: + colors.append([1.0, 0.0, 1.0 - c, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.field_pbr(color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + draw_field(field_obj, particle) + + +run() diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index 83e6f28..85d7794 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -18,6 +18,20 @@ pub struct Buffer { size: u64, } +impl Buffer { + /// Wrap an existing buffer entity (e.g., one owned by a Field's PBuffer). + /// `size` is queried from the buffer; `element_type` is supplied so typed + /// reads / `__getitem__` work correctly. + pub(crate) fn from_entity(entity: Entity, element_type: Option) -> Self { + let size = buffer_size(entity).unwrap_or(0); + Self { + entity, + element_type, + size, + } + } +} + #[pymethods] impl Buffer { #[new] @@ -245,6 +259,14 @@ pub struct Compute { pub(crate) entity: Entity, } +impl Compute { + /// Wrap an existing compute entity (e.g., one created by a Rust-side + /// factory like `field_kernel_noise`). Not exposed to Python directly. + pub(crate) fn from_entity(entity: Entity) -> Self { + Self { entity } + } +} + #[pymethods] impl Compute { #[new] diff --git a/crates/processing_pyo3/src/field.rs b/crates/processing_pyo3/src/field.rs new file mode 100644 index 0000000..0214395 --- /dev/null +++ b/crates/processing_pyo3/src/field.rs @@ -0,0 +1,250 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use processing_render::geometry as geometry; +use pyo3::types::PyDict; +use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use std::collections::HashMap; + +use crate::compute::{Buffer, Compute}; +use crate::graphics::Geometry; + +/// Per-element format of a field attribute / mesh vertex attribute. +#[pyclass(eq, eq_int)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AttributeFormat { + Float = 1, + Float2 = 2, + Float3 = 3, + Float4 = 4, +} + +impl AttributeFormat { + pub(crate) fn to_inner(self) -> geometry::AttributeFormat { + match self { + Self::Float => geometry::AttributeFormat::Float, + Self::Float2 => geometry::AttributeFormat::Float2, + Self::Float3 => geometry::AttributeFormat::Float3, + Self::Float4 => geometry::AttributeFormat::Float4, + } + } + + pub(crate) fn from_inner(inner: geometry::AttributeFormat) -> Self { + match inner { + geometry::AttributeFormat::Float => Self::Float, + geometry::AttributeFormat::Float2 => Self::Float2, + geometry::AttributeFormat::Float3 => Self::Float3, + geometry::AttributeFormat::Float4 => Self::Float4, + } + } + + pub(crate) fn float_count(self) -> usize { + match self { + Self::Float => 1, + Self::Float2 => 2, + Self::Float3 => 3, + Self::Float4 => 4, + } + } +} + +/// Named typed attribute identity. Returned from the builtin `position()` / +/// `color()` / etc. classmethods, or constructed directly for custom attributes. +#[pyclass(unsendable, frozen, hash, eq)] +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Attribute { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Attribute { + #[new] + pub fn new(name: &str, format: AttributeFormat) -> PyResult { + let entity = geometry_attribute_create(name, format.to_inner()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[staticmethod] + pub fn position() -> Self { Self { entity: geometry_attribute_position() } } + #[staticmethod] + pub fn normal() -> Self { Self { entity: geometry_attribute_normal() } } + #[staticmethod] + pub fn color() -> Self { Self { entity: geometry_attribute_color() } } + #[staticmethod] + pub fn uv() -> Self { Self { entity: geometry_attribute_uv() } } + #[staticmethod] + pub fn rotation() -> Self { Self { entity: geometry_attribute_rotation() } } + #[staticmethod] + pub fn scale() -> Self { Self { entity: geometry_attribute_scale() } } + #[staticmethod] + pub fn dead() -> Self { Self { entity: geometry_attribute_dead() } } + + #[getter] + pub fn name(&self) -> PyResult { + let (name, _) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(name) + } + + #[getter] + pub fn format(&self) -> PyResult { + let (_, fmt) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(AttributeFormat::from_inner(fmt)) + } +} + +#[pyclass(unsendable)] +pub struct Field { + pub(crate) entity: Entity, + /// Cached attribute metadata indexed by name, used to convert kwarg payloads + /// in `emit()` into the byte format the underlying `field_emit` expects. + name_to_attr: HashMap, +} + +impl Field { + fn build_name_index(attrs: &[Attribute]) -> PyResult> { + let mut map = HashMap::with_capacity(attrs.len()); + for attr in attrs { + let (name, fmt) = geometry_attribute_info(attr.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + map.insert(name, (attr.entity, AttributeFormat::from_inner(fmt))); + } + Ok(map) + } +} + +#[pymethods] +impl Field { + /// Construct a Field. Provide either `capacity` (allocates empty PBuffers) + /// or `geometry` (capacity = vertex count, PBuffers seeded from matching + /// mesh attributes), but not both. + #[new] + #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] + pub fn new( + capacity: Option, + attributes: Option>>, + geometry: Option<&Geometry>, + ) -> PyResult { + let attrs: Vec = attributes + .unwrap_or_default() + .iter() + .map(|a| (**a).clone()) + .collect(); + let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); + + let entity = match (capacity, geometry) { + (Some(cap), None) => field_create(cap, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, Some(g)) => field_create_from_geometry(g.entity, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, None) => { + return Err(PyRuntimeError::new_err( + "Field requires either capacity or geometry", + )); + } + (Some(_), Some(_)) => { + return Err(PyRuntimeError::new_err( + "Field accepts capacity or geometry, not both", + )); + } + }; + + Ok(Self { + entity, + name_to_attr: Field::build_name_index(&attrs)?, + }) + } + + /// Number of slots reserved for this Field. + #[getter] + pub fn capacity(&self) -> PyResult { + field_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Get the underlying `Buffer` for a registered attribute, or `None` if the + /// attribute isn't part of this Field. The returned buffer's element type + /// matches the attribute's format so `read()` / `__getitem__` return typed + /// values (e.g. lists of vec3 components for a Float3 attribute). + pub fn pbuffer(&self, attribute: &Attribute) -> PyResult> { + let pbuf = field_pbuffer(self.entity, attribute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (_, fmt) = geometry_attribute_info(attribute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let element_type = match AttributeFormat::from_inner(fmt) { + AttributeFormat::Float => shader_value::ShaderValue::Float(0.0), + AttributeFormat::Float2 => shader_value::ShaderValue::Float2([0.0; 2]), + AttributeFormat::Float3 => shader_value::ShaderValue::Float3([0.0; 3]), + AttributeFormat::Float4 => shader_value::ShaderValue::Float4([0.0; 4]), + }; + Ok(pbuf.map(|e| Buffer::from_entity(e, Some(element_type)))) + } + + /// Run a compute kernel against this Field's PBuffers. Each PBuffer is + /// auto-bound by its attribute name; uniforms must be set on the compute + /// beforehand via `compute.set(...)`. + pub fn apply(&self, compute: &Compute) -> PyResult<()> { + field_apply(self.entity, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// CPU-driven emission. Per-attribute data is provided as kwargs keyed by + /// the attribute's name. Each value is a flat list of f32 values (the + /// length must equal `n * format.float_count()`). + /// + /// ```python + /// f.emit(50, position=[x0,y0,z0, x1,y1,z1, ...], color=[r0,g0,b0,a0, ...]) + /// ``` + #[pyo3(signature = (n, **kwargs))] + pub fn emit(&self, n: u32, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return field_emit(self.entity, n, vec![]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + }; + let mut data: Vec<(Entity, Vec)> = Vec::new(); + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let (attr_entity, fmt) = self.name_to_attr.get(&name).copied().ok_or_else(|| { + PyRuntimeError::new_err(format!( + "field has no attribute named '{name}' (registered: {:?})", + self.name_to_attr.keys().collect::>() + )) + })?; + let floats: Vec = value.extract()?; + let expected = (n as usize) * fmt.float_count(); + if floats.len() != expected { + return Err(PyRuntimeError::new_err(format!( + "attribute '{name}': expected {expected} floats ({} per particle × {n}), got {}", + fmt.float_count(), + floats.len(), + ))); + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + data.push((attr_entity, bytes)); + } + field_emit(self.entity, n, data) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// GPU-driven emission. Dispatches `compute` over `n` invocations to + /// initialize the next `n` ring-buffer slots. The compute's PBuffer + /// bindings are auto-set; the `emit_range: vec4` uniform is auto-set + /// to `(base_slot, n, capacity, 0)`. User-set uniforms (spawn position, + /// velocity hint, etc.) must be assigned to the compute beforehand. + pub fn emit_gpu(&self, n: u32, compute: &Compute) -> PyResult<()> { + field_emit_gpu(self.entity, n, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Field { + fn drop(&mut self) { + let _ = field_destroy(self.entity); + } +} + +/// Built-in noise compute kernel. Configure via `compute.set(scale=..., strength=..., time=...)`. +pub fn kernel_noise() -> PyResult { + let entity = field_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index cafd305..2c5be66 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -279,6 +279,23 @@ impl Geometry { pub fn vertex_count(&self) -> PyResult { geometry_vertex_count(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + + /// Retained sphere mesh. + #[staticmethod] + #[pyo3(signature = (radius, sectors=32, stacks=18))] + pub fn sphere(radius: f32, sectors: u32, stacks: u32) -> PyResult { + let entity = geometry_sphere(radius, sectors, stacks) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + /// Retained box mesh. + #[staticmethod] + pub fn r#box(width: f32, height: f32, depth: f32) -> PyResult { + let entity = geometry_box(width, height, depth) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } } #[pyclass(unsendable)] @@ -970,6 +987,21 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn draw_field( + &self, + field: &crate::field::Field, + geometry: &Geometry, + ) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Field { + field: field.entity, + geometry: geometry.entity, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn use_material(&self, material: &crate::material::Material) -> PyResult<()> { graphics_record_command(self.entity, DrawCommand::Material(material.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 fcb9dc8..bfc9e65 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -12,6 +12,7 @@ pub(crate) mod color; pub(crate) mod compute; #[cfg(feature = "cuda")] pub(crate) mod cuda; +pub(crate) mod field; mod glfw; mod gltf; mod graphics; @@ -332,6 +333,12 @@ mod mewnala { #[pymodule_export] use super::Compute; #[pymodule_export] + use super::field::Attribute; + #[pymodule_export] + use super::field::AttributeFormat; + #[pymodule_export] + use super::field::Field; + #[pymodule_export] use super::Geometry; #[pymodule_export] use super::Gltf; @@ -1010,7 +1017,7 @@ mod mewnala { return Ok(()); } - Python::attach(|py| { + let result: PyResult<()> = Python::attach(|py| { let builtins = PyModule::import(py, "builtins")?; let locals = builtins.getattr("locals")?.call0()?; @@ -1131,7 +1138,15 @@ mod mewnala { } Ok(()) - }) + }); + + // Tear the App down gracefully while the thread-local is still alive, + // matching what `processing::exit()` does in Rust sketches. Without + // this the App falls to its eager thread-local destructor at process + // exit and a Bevy resource panics inside its own Drop, aborting. + let _ = ::processing::exit(0); + + result } #[pyfunction] @@ -1254,6 +1269,24 @@ mod mewnala { graphics!(module).draw_geometry(&*geometry.extract::>()?) } + #[pyfunction] + #[pyo3(pass_module, signature = (field, geometry))] + fn draw_field( + module: &Bound<'_, PyModule>, + field: &Bound<'_, super::field::Field>, + geometry: &Bound<'_, Geometry>, + ) -> PyResult<()> { + graphics!(module).draw_field( + &*field.extract::>()?, + &*geometry.extract::>()?, + ) + } + + #[pyfunction] + fn kernel_noise() -> PyResult { + super::field::kernel_noise() + } + #[pyfunction(name = "color")] #[pyo3(pass_module, signature = (*args))] fn create_color( diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index b7e9bc5..2211514 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -89,6 +89,24 @@ impl Material { } Ok(()) } + + /// Unlit per-particle color material. Each particle samples its color from + /// the given buffer indexed by per-instance tag. + #[staticmethod] + pub fn field_color(buffer: &Buffer) -> PyResult { + let entity = material_create_field_color(buffer.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + /// PBR-lit per-particle color material. Same tag-indexed lookup as + /// `field_color`, but composed with `StandardMaterial` for proper lighting. + #[staticmethod] + pub fn field_pbr(buffer: &Buffer) -> PyResult { + let entity = material_create_field_pbr(buffer.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } } impl Drop for Material { diff --git a/crates/processing_render/src/field/kernels/mod.rs b/crates/processing_render/src/field/kernels/mod.rs new file mode 100644 index 0000000..30fdc17 --- /dev/null +++ b/crates/processing_render/src/field/kernels/mod.rs @@ -0,0 +1,16 @@ +//! Built-in compute kernels for [`Field`](super::Field). Each kernel is a small WGSL +//! shader packaged with libprocessing as an embedded asset. Use them via `field_apply` +//! after configuring parameters via `compute_set`. + +use bevy::asset::embedded_asset; +use bevy::prelude::*; + +pub struct FieldKernelsPlugin; + +impl Plugin for FieldKernelsPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "noise.wgsl"); + } +} + +pub const NOISE_PATH: &str = "embedded://processing_render/field/kernels/noise.wgsl"; diff --git a/crates/processing_render/src/field/kernels/noise.wgsl b/crates/processing_render/src/field/kernels/noise.wgsl new file mode 100644 index 0000000..6331b9c --- /dev/null +++ b/crates/processing_render/src/field/kernels/noise.wgsl @@ -0,0 +1,69 @@ +// Built-in noise kernel — perturbs particle positions by sampled 3D value +// noise. Configure via `compute_set`: +// scale : f32 — input position scale (low = broad pattern) +// strength : f32 — output displacement magnitude per dispatch +// time : f32 — animation phase (offset the noise field) + +struct Params { + scale: f32, + strength: f32, + time: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +fn hash(p: vec3) -> f32 { + let q = fract(p * 0.3183099) + vec3(0.1, 0.2, 0.3); + let r = q + dot(q, q.yzx + 19.19); + return fract(r.x * r.y * r.z); +} + +fn value_noise(p: vec3) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(hash(i + vec3(0.0, 0.0, 0.0)), + hash(i + vec3(1.0, 0.0, 0.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 0.0)), + hash(i + vec3(1.0, 1.0, 0.0)), u.x), + u.y), + mix( + mix(hash(i + vec3(0.0, 0.0, 1.0)), + hash(i + vec3(1.0, 0.0, 1.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 1.0)), + hash(i + vec3(1.0, 1.0, 1.0)), u.x), + u.y), + u.z); +} + +fn noise3(p: vec3) -> vec3 { + return vec3( + value_noise(p), + value_noise(p + vec3(31.4, 0.0, 0.0)), + value_noise(p + vec3(0.0, 71.7, 0.0)), + ) * 2.0 - 1.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + let sample = p * params.scale + vec3(params.time, params.time * 0.7, params.time * 1.3); + let n = noise3(sample); + let new_p = p + n * params.strength; + position[i * 3u + 0u] = new_p.x; + position[i * 3u + 1u] = new_p.y; + position[i * 3u + 2u] = new_p.z; +} diff --git a/crates/processing_render/src/field/mod.rs b/crates/processing_render/src/field/mod.rs index a7fe7ff..2849f74 100644 --- a/crates/processing_render/src/field/mod.rs +++ b/crates/processing_render/src/field/mod.rs @@ -9,6 +9,7 @@ //! //! See `docs/field.md` for the full design. +pub mod kernels; pub mod material; pub mod pack; @@ -33,6 +34,7 @@ impl Plugin for FieldPlugin { app.add_plugins(GpuInstanceBatchPlugin); app.add_plugins(pack::FieldPackPlugin); app.add_plugins(material::FieldColorMaterialPlugin); + app.add_plugins(kernels::FieldKernelsPlugin); } } diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index cb73bf1..88f09f3 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1109,6 +1109,16 @@ pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { }) } +pub fn geometry_attribute_info(entity: Entity) -> error::Result<(String, AttributeFormat)> { + app_mut(|app| { + let attr = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + Ok((attr.name.to_string(), attr.format)) + }) +} + pub fn geometry_create(topology: geometry::Topology) -> error::Result { app_mut(|app| { Ok(app @@ -1403,9 +1413,10 @@ pub fn shader_create(source: &str) -> error::Result { }) } -/// Load a shader from a file path. +/// Load a shader. Accepts either an asset-relative path (`"shaders/foo.wgsl"`) +/// or a URL-scheme asset path (`"embedded://crate/file.wgsl"`). pub fn shader_load(path: &str) -> error::Result { - let path = std::path::PathBuf::from(path); + let path = path.to_string(); app_mut(|app| { app.world_mut() .run_system_cached_with(material::custom::load_shader, path) @@ -1987,6 +1998,89 @@ pub fn field_pbuffer(entity: Entity, attribute_entity: Entity) -> error::Result< }) } +/// GPU-side emission. Dispatches `compute_entity` over `count` invocations to +/// initialize the next `count` ring-buffer slots. The framework auto-binds the +/// field's PBuffers (same convention as `field_apply`) and sets a `vec4` +/// uniform named `emit_range` to `(base_slot, count, capacity, 0.0)` — the +/// kernel reads it to compute its target slot: +/// +/// ```wgsl +/// @group(0) @binding(N) var emit_range: vec4; +/// // ... +/// let local_i = gid.x; +/// if local_i >= u32(emit_range.y) { return; } +/// let slot = (u32(emit_range.x) + local_i) % u32(emit_range.z); +/// ``` +/// +/// Use this when per-particle initial state should be computed on the GPU +/// (random velocities, hashed colors, etc.). Use [`field_emit`] when the CPU +/// already has the per-particle data. +pub fn field_emit_gpu( + field_entity: Entity, + count: u32, + compute_entity: Entity, +) -> error::Result<()> { + if count == 0 { + return Ok(()); + } + const WORKGROUP_SIZE: u32 = 64; + + let (capacity, head, pbuffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(field_entity) + .ok_or(error::ProcessingError::FieldNotFound)?; + if count > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "field_emit_gpu count={} exceeds field capacity {}", + count, field.capacity + ))); + } + let mut pbuffers: Vec<(String, Entity)> = Vec::with_capacity(field.pbuffers.len()); + for (&attr_entity, &pbuf_entity) in &field.pbuffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + pbuffers.push((attr.name.to_string(), pbuf_entity)); + } + Ok((field.capacity, field.emit_head, pbuffers)) + })?; + + for (name, pbuf_entity) in pbuffers { + match compute_set( + compute_entity, + name, + shader_value::ShaderValue::Buffer(pbuf_entity), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + match compute_set( + compute_entity, + "emit_range", + shader_value::ShaderValue::Float4([head as f32, count as f32, capacity as f32, 0.0]), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + + let workgroup_count = count.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1)?; + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(field_entity) + .ok_or(error::ProcessingError::FieldNotFound)?; + field.emit_head = (field.emit_head + count) % field.capacity; + Ok(()) + }) +} + /// Emit `n` particles into a Field, writing per-attribute byte payloads into the /// next `n` slots starting at the field's ring-buffer head. Each entry in /// `attribute_data` must match the registered attribute's `byte_size * n`. @@ -2057,6 +2151,14 @@ pub fn field_emit( }) } +/// Built-in noise kernel — perturbs each particle's `position` by sampled 3D +/// value noise. Configure via `compute_set("scale", Float(...))`, +/// `compute_set("strength", Float(...))`, `compute_set("time", Float(...))`. +pub fn field_kernel_noise() -> error::Result { + let shader = shader_load(field::kernels::NOISE_PATH)?; + compute_create(shader) +} + /// Dispatch a compute pass against a Field's PBuffers. Each PBuffer is bound /// by its attribute's name; bindings the shader doesn't declare are skipped. /// Workgroup size is fixed at 64 — the shader must declare `@workgroup_size(64)`. diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 0a3611d..23065ce 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -174,19 +174,27 @@ pub fn create_shader( .id()) } -pub fn load_shader(In(path): In, world: &mut World) -> Result { +pub fn load_shader(In(path): In, world: &mut World) -> Result { use bevy::asset::{ AssetPath, LoadState, handle_internal_asset_events, io::{AssetSourceId, embedded::GetAssetServer}, }; use bevy::ecs::system::RunSystemOnce; - let config = world.resource::(); - let asset_path: AssetPath = match config.get(ConfigKey::AssetRootPath) { - Some(_) => { - AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + // URL-scheme paths (e.g. `embedded://crate/file.wgsl`) parse as-is — they + // already specify their asset source. Otherwise treat as a relative path + // and fall through to the configured asset directory if any. + let asset_path: AssetPath = if path.contains("://") { + AssetPath::parse(&path).into_owned() + } else { + let config = world.resource::(); + let path = std::path::PathBuf::from(path); + match config.get(ConfigKey::AssetRootPath) { + Some(_) => { + AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + } + None => AssetPath::from_path_buf(path), } - None => AssetPath::from_path_buf(path), }; let handle: Handle = world.get_asset_server().load(asset_path); diff --git a/docs/field.md b/docs/field.md index 9ab7e36..e2f4ccc 100644 --- a/docs/field.md +++ b/docs/field.md @@ -5,274 +5,288 @@ geometry once per element. It is the libprocessing analogue of a Houdini point c collection of points carrying arbitrary named attributes, where storage is contextual and attributes are first-class. -The high-level model is built on two existing libprocessing systems and one upstream -contribution: - -- The `compute::Buffer` infrastructure (`crates/processing_render/src/compute.rs`) - provides typed GPU storage buffers, CPU-side write, GPU readback, compute dispatch, - and a Python wrapper that tracks element type for validation. -- The `Attribute` system (`crates/processing_render/src/geometry/attribute.rs`) provides - named, typed attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) - and a `BuiltinAttributes` resource holding stable entity IDs for `position`, `normal`, - `color`, `uv`. The same identities flow through Geometries (per-vertex) and Fields - (per-instance). -- Upstream `processing/bevy` commit `ee443e51` adds `GpuBatchedMesh3d` and the - `GpuInstanceBatchReservations` machinery — a fixed-capacity batch where compute can - write per-instance transforms into the upstream input buffer before +The implementation rests on two existing libprocessing systems and one upstream contribution: + +- **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU storage + buffers with CPU-side write, GPU readback, compute dispatch, and a Python wrapper that + tracks element type for validation. This is what backs every PBuffer. +- **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — named typed + attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) shared between + Geometries (per-vertex) and Fields (per-instance). `BuiltinAttributes` exposes + `position`, `normal`, `color`, `uv`, `rotation` (Float4 quat), `scale` (Float3), `dead` + (Float, 0=alive). The last three are field-only. +- **Upstream `processing/bevy`** commit `ee443e51` adds `GpuBatchedMesh3d` and the + `GpuInstanceBatchReservations` machinery — a fixed-capacity batch where a compute pass + can write per-instance transforms into the upstream input buffer before `early_gpu_preprocess` consumes them. ## Concepts ### Field -The top-level container. Holds a set of named PBuffers (one per registered attribute), -the upstream reservation handle, and lifecycle metadata (capacity, emission head). Does -not carry geometry — it is the GPU compute context, not the shape that gets drawn. +The top-level container. Holds a set of named PBuffers (one per registered attribute), an +optional persistent rasterization entity, a ring-buffer emit cursor, and per-Field render +state. Does not carry geometry — that's supplied at draw time. ### PBuffer A single typed GPU storage buffer holding the values for one attribute across all -elements of a Field. Backed by `compute::Buffer`. Indexed by particle slot. +elements. Backed by `compute::Buffer`. Indexed by particle slot. ### Attribute -The naming and type identity for a buffer of values. Already exists. A `Field` registers -PBuffers against `Attribute` entities; lookups are typed entity comparisons, never string -matches. Format is declared at attribute creation and is the source of truth for element -size and shader-side type. +The naming + type identity. A Field maps `Attribute` entities to `compute::Buffer` +entities. Lookups are typed entity comparisons, never strings. The Format declared at +attribute creation is the source of truth for element byte size and shader-side semantics +(Float4 rotation = quat, Float3 = position/scale, etc.). ### Draw verb: `field` -`field(f, shape)` is the rasterization verb, analogous to `shape()`. Consumes ambient -material/fill/stroke state at call time and instances `shape` once per slot in `f`. +`field(f, shape)` (`DrawCommand::Field { field, geometry }`) is the rasterization verb. +Reads ambient material at call time and instances `shape` once per slot in `f`. -## Lifecycle +## Construction -### Construction +### Empty Field +```rust +let position = geometry_attribute_position(); +let velocity = geometry_attribute_create("velocity", AttributeFormat::Float3)?; +let f = field_create(10_000, vec![position, velocity])?; ``` -let f = createField(|| { - sphere(1.0); // immediate-mode shape API - // ... -}, capacity: 10_000); -``` - -The closure runs once and seeds initial attribute values via the existing immediate-mode -shape API (`beginShape`/`vertex`/`endShape`, `sphere`, `box`, etc.) The mapping is 1:1 -from emitted vertices to particle slots. `capacity` is the upstream reservation size; if -omitted, it defaults to the closure's emitted vertex count. -`createField` called inside `draw()` emits a warning (hard error in strict mode), since -re-uploading every frame defeats the point of GPU residence. +Allocates one zero-initialized PBuffer per requested attribute, sized by `capacity * +attr.format.byte_size()`. -### Apply +### Mesh-seeded Field -``` -f.apply(NOISE, target: builtins.position, scale: 0.5) - .apply(CURL, target: builtins.position, strength: 1.0) - .apply(custom_kernel(my_shader)); +```rust +let source = geometry_sphere(5.0, 32, 24)?; +let f = field_create_from_geometry( + source, + vec![position_attr, uv_attr, color_attr], +)?; ``` -`apply()` dispatches a compute pass against the field. It is **chainable** — returns the -field. Built-in kernels (`NOISE`, `CURL`, `TURBULENCE`, etc.) are named constants; -custom WGSL is a separate constructor that takes a `Shader` and declares which -attributes it reads/writes. +Capacity = mesh vertex count. Each registered attribute is pre-seeded from the matching +mesh attribute when names + formats line up: -`apply()` calls placed in `setup()` run once. `apply()` calls in `draw()` run every -frame — the retained-vs-dynamic distinction is purely about placement, not API. +| Field attribute | Mesh attribute (Bevy) | +|----|----| +| `position` (Float3) | `Mesh::ATTRIBUTE_POSITION` | +| `normal` (Float3) | `Mesh::ATTRIBUTE_NORMAL` | +| `color` (Float4) | `Mesh::ATTRIBUTE_COLOR` | +| `uv` (Float2) | `Mesh::ATTRIBUTE_UV_0` | -`apply()` only ever touches PBuffers and uniforms. It has no knowledge of upstream -mesh-input buffers or render-side state. This keeps user-authored kernels free of -upstream coupling. +Field-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero-init +(meshes don't carry them). -### Draw +## Apply (PBuffer-only compute) -``` -fill(255, 100, 50); -field(f, sphere_shape); +```rust +let shader = shader_create(SPIN_WGSL)?; +let spin = compute_create(shader)?; +compute_set(spin, "dt", ShaderValue::Float(0.016))?; +field_apply(field, spin)?; ``` -The draw verb reads ambient material state at call time, dispatches the pack pass for -this field if not already packed this frame, and issues the instanced raster. +`field_apply` iterates the field's PBuffers and calls `compute_set(compute, attr.name, +ShaderValue::Buffer(pbuf_entity))` for each. Unknown shader properties are silently +skipped, so the kernel only declares the attributes it needs. Workgroup size is fixed at +64 — kernels must declare `@workgroup_size(64)`. -### Read / write +The kernel's bind group only ever contains the field's PBuffers + uniforms. The kernel +never touches upstream input/culling buffers — that's the pack pass's job. -``` -let positions = f.read(builtins.position); // CPU readback as typed values -f.write(builtins.velocity, [...]); // CPU upload -``` - -Inherited from the `compute::Buffer` Python surface (typed `__getitem__`/`__setitem__`, -`read()`, `write()`). +In `setup()` apply runs once; in `draw()` it runs every frame. The retained-vs-dynamic +distinction is purely about placement. -## Compute model +## Pack pass -### apply() is PBuffer-only +The pack pass is the only code that bridges to the upstream batch infrastructure. It runs +as standard render-schedule systems: -A compute dispatch from `apply()` binds the field's PBuffers (those the kernel -declares it needs) plus any uniforms. It does not bind the upstream -`mesh_input_buffer`, `MeshCullingDataBuffer`, or any other upstream-managed resource. -This means kernel authors — including users writing CUSTOM WGSL — never need to know -upstream internals. +- **`extract_field_draws`** (`ExtractSchedule`) — reads `FieldDraw` markers from main + world, copies (Field, position/rotation/scale/dead PBuffer handles) into render world. +- **`prepare_pack_bind_groups`** (`RenderSystems::PrepareBindGroups`) — looks up or + creates the pack pipeline for the field's specialization key, builds a bind group with + the field's PBuffers + the upstream input/culling buffers + a uniform with `(base_index, + count)`. +- **`dispatch_pack`** (`Core3d`, `before(early_gpu_preprocess)`) — dispatches the compute + pass. -### Pack pass +The pack shader (`field/pack.wgsl`) is specialized via shader_defs: -The pack pass is the only code that bridges to the upstream batch infrastructure. It -runs once per `(frame, field)`, lazily, **only when** `field(f, shape)` is called this -frame. If a Field is used purely offline (apply + read), pack never runs and the -upstream input slots stay untouched. +- `HAS_ROTATION` — bind a `rotation` PBuffer (Float4 quat). Otherwise identity. +- `HAS_SCALE` — bind a `scale` PBuffer (Float3). Otherwise unit. +- `HAS_DEAD` — bind a `dead` PBuffer (Float). Otherwise alive. -Pack reads the current `position` / `rotation` / `scale` / lifecycle PBuffers, builds -the `world_from_local` `mat3x4`, and writes: +For each particle slot the pack writes: -- `mesh_input_buffer[base + i].world_from_local` -- `mesh_input_buffer[base + i].tag = i` (the side-channel index for material shaders) -- `MeshCullingData[base + i].dead` from the field's lifecycle PBuffer if present +- `mesh_input_buffer[base+i].world_from_local` — `mat3x4` from rotation × scale + + position translation. +- `mesh_input_buffer[base+i].tag = i` — slot index, available to material shaders via + `mesh_functions::get_tag(instance_index)`. +- `MeshCullingData[base+i].dead` — from the dead PBuffer if present, else 0. -A CPU-side dirty flag on the Field component prevents redundant packing when the field -is drawn multiple times in one frame (e.g. shadow + main, multiple cameras). +Pipelines are cached per `PackPipelineKey { has_rotation, has_scale, has_dead }`. -### In-place vs ping-pong +## Materials -The default for `apply()` is **in-place mutation**. The kernel reads `state[i]`, writes -back to `state[i]`. This is correct for every kernel that doesn't read other particles' -slots — which covers the overwhelming majority of creative-coding particle work -(noise/curl/drag/integration/attractor/repulsor). It is what the upstream -`gpu_particles` example does. +Two material types support per-particle color via tag-indexed lookup. Both bind a +`colors: Handle` storage buffer and read `particle_colors[mesh.tag]`. -Kernels that read neighbor slots (smoothing, SPH-style fluid, sort steps) must opt into -ping-pong. Built-in kernels declare their access pattern; CUSTOM kernels accept a -`mode: PingPong` argument: +### `FieldColorMaterial` (unlit) +```rust +let mat = material_create_field_color(color_buffer_entity)?; +graphics_record_command(g, DrawCommand::Material(mat))?; +graphics_record_command(g, DrawCommand::Field { field, geometry: shape })?; ``` -f.apply(custom_kernel(my_shader), mode: PingPong); -``` - -When ping-pong is requested, libprocessing transparently allocates the shadow buffer per -affected attribute and swaps after the dispatch. The user sees a single logical PBuffer -per Attribute regardless. -Between distinct `apply()` calls, no swap is needed — render-graph barriers between -sequential dispatches make in-place chaining correct. +Outputs the per-particle color directly. Use for emissive / no-lighting particle effects. -## Material integration +### `FieldPbrMaterial` (PBR-lit) -### Ambient state - -`field(f, shape)` participates in the same ambient material/fill/stroke state machine as -`shape()`. No new public material API. - -### Default material — `ProcessingMaterial` - -`ProcessingMaterial` is extended to consume tag-indexed PBuffers for the common -per-particle cases — at minimum **per-particle color**, so a `color` PBuffer and a -default `fill()` together produce per-particle tinting with the default material. This -is implemented as a tag-indexed storage-buffer read inside the material's fragment path: -`color_buffer[in.tag]` if the field declared a `color` PBuffer; fall through to ambient -fill otherwise. - -### Custom material +```rust +let mat = material_create_field_pbr(color_buffer_entity)?; +``` -Anything richer than what `ProcessingMaterial` consumes requires a `CustomMaterial`. The -user's WGSL declares storage bindings for the PBuffers it cares about and reads them -indexed by `in.tag`. The framework wires the bindings; the user writes the shader. +`ExtendedMaterial`. Composes via +`pbr_input_from_standard_material` + `apply_pbr_lighting` — modulates the StandardMaterial +base color (default white) by the per-particle color. Standard PBR lighting (directional / +point / spot lights) applies normally. Default roughness 0.4, metallic 0.0; not yet +user-configurable via `material_set` (would need a dispatch arm for the new material +type). + +### Anything richer + +Per-particle UV, custom scalars, etc. require a `CustomMaterial` where the user writes +WGSL that reads `mesh.tag` and indexes into their own storage buffer. + +## Emit (ring buffer) + +```rust +field_emit( + field, + n, + vec![ + (position_attr, position_bytes), // n * 12 bytes + (color_attr, color_bytes), // n * 16 bytes + (dead_attr, vec![0u8; n * 4]), // alive + ], +)?; +``` -The asymmetry must be honest in the docs: per-particle color works with the default -material; per-particle UV / scale / arbitrary scalar attributes require a custom -material. +Writes to slots `[head, head+n) mod capacity` via `compute::Buffer::write_buffer_cpu`, +then advances the field's `emit_head`. Two writes when wrapping. No GPU-side allocator, +no atomics, no compaction. -PBuffers do not bind to `@location(N)` per-instance vertex inputs. The upstream batch -infrastructure does not support per-instance attributes beyond the transform; the tag -side-channel is the route. +When the ring wraps, oldest particles are overwritten — capacity is a visible contract: +`>= peak_emission_rate × longest_lifespan`. -## Capacity, emission, lifecycle +The user supplies bytes explicitly per attribute. There is no auto-default — if the field +has a `dead` attribute, the user must include it (typically as `n` zero-floats) or new +slots inherit the previous occupant's death. -Capacity is fixed at field creation. Two distinct emission patterns are supported, only -one of which needs new API. +## Lifecycle -### Continuous self-recycling — no new API +`dead` is a builtin Float attribute (0=alive, non-zero=dead). When the field has it +registered, the pack pass reads it and writes `MeshCullingData::dead` — non-zero means +the slot is skipped in preprocessing and never rendered. -A field set up with a fixed population, where particles respawn on death within a -single user-authored kernel. The upstream `gpu_particles` example uses `pos.w` as a -lifecycle counter and a respawn branch in the simulate shader. This works on a regular -Field with a CUSTOM apply — the user's WGSL handles birth and death internally. No -emit primitive is needed; document the pattern, ship nothing. +Aging is user-managed: write an apply() shader that increments an age attribute and sets +`dead = 1.0` when age exceeds a threshold. The canonical pattern (`field_lifecycle.rs`): -### Discrete emission — ring buffer +```wgsl +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&age) { return; } + if dead[i] != 0.0 { return; } -When user code says "spawn N particles right now," use a ring buffer: + age[i] = age[i] + dt; + let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = life * life; + scale[i*3+0] = s; scale[i*3+1] = s; scale[i*3+2] = s; // shrink -``` -on_mouse_pressed: - f.emit(50, |w| { - w[builtins.position] = mouse_world_pos(); - w[builtins.velocity] = random_unit_vec3() * 5.0; - }); + if age[i] > ttl { dead[i] = 1.0; } +} ``` -Field carries a CPU-side `emit_head: u32`. `emit(n, init)` writes attribute values to -slots `[head, head + n) mod capacity` via `compute::Buffer::write_buffer_cpu`, then -advances head. No GPU-side allocator, no atomics, no compaction. +For unemitted ring-buffer slots, seed `dead = 1.0` at field-create time so they don't +render before being emitted into. + +## Compute model -When the ring wraps, oldest particles are overwritten — graceful degradation if -emission outruns lifespan. Capacity is therefore a visible contract: -`capacity >= peak_emission_rate * longest_lifespan`. +Default mutation mode is **in-place**. Most particle kernels (NOISE, CURL, drag, +integration) only read their own slot; in-place is correct and 2× cheaper than +ping-pong. Ping-pong (for kernels that read neighbor slots) is not yet shipped. -Aliveness for raster: a particle is considered alive if its lifecycle PBuffer says so. -The pack pass writes `MeshCullingData[slot].dead` accordingly. The user is responsible -for setting the lifecycle PBuffer in their apply (typically `dead = age >= lifespan ? -1.0 : 0.0`). +Between sequential `apply()` calls, no buffer swap is needed — render-graph barriers +handle ordering. ## Immediate-mode compatibility -The settled "automatic instancing of repeated draw calls with the same material" -remains the immediate-mode escape hatch. A user looping `translate; sphere()` gets -auto-instancing for free, no Field needed. Field is for cases where compute matters or -populations are large and dynamic. - -`createField` inside `draw()` warns (hard error in strict mode). There is no separate -"ephemeral field" API — the warning is the educational nudge. Most rebuild-every-frame -intentions should be a static Field with a per-frame `apply(CUSTOM, ...)`. - -## Upstream bridge - -A Field is backed by a single entity carrying: - -- `GpuBatchedMesh3d { mesh, max_capacity }` — upstream -- `MeshMaterial3d` — upstream, ambient material handle -- `Field` — libprocessing component holding the PBuffer map, ring-buffer head, dirty - flag, and other lifecycle metadata -- An `Aabb` for culling - -`GpuBatchedMesh3d` and `Mesh3d` are mutually exclusive on one entity by upstream design; -the immediate-mode `Mesh3d` path is not available on a Field entity, and vice versa. - -The pack pass schedules its work to land in the render world before -`early_gpu_preprocess`. It does not register as a `Render` system; it is called inline -from the Field draw-command processor. - -## Non-goals (v1) - -- **GPU-driven emission.** No GPU-side atomic counter / dead-slot allocator. Emission - is CPU-driven only. -- **Sparse alive set / compaction.** Every reserved slot is part of the rendered batch; - cull happens via the per-slot `dead` flag. -- **Per-instance attributes beyond the tag side-channel.** Upstream does not support - per-instance vertex inputs other than the transform; the tag plus storage-buffer - lookup is the only mechanism. -- **Multi-emitter pools.** A Field is one ring buffer. Use multiple Fields if logical - separation is needed. -- **Cross-field operations.** No `apply()` that reads from one field and writes to - another. Single-field kernels only. - -## Open questions - -- **Rotation format ambiguity.** `Float3` rotation = euler, `Float4` = quat is decided - at attribute registration. Worth re-examining if users frequently want one and get - the other; alternatively, ship a typed wrapper helper. -- **Multiple cameras / shadow path.** Pack-once-per-frame assumes the upstream input is - the same across all views. If a future camera-specific pass needs different - per-instance state, the dirty model needs to grow per-view. -- **Custom material binding declaration.** How a `CustomMaterial` declares which Field - PBuffers it needs as storage bindings is unsettled. Likely an explicit - `material.bind("color", attribute)` call at material creation time. +The "automatic instancing of repeated draw calls with the same material" path remains the +non-Field instancing escape hatch. A user looping `translate; sphere()` gets +auto-instancing via `Mesh3d` for free, no Field needed. Field is for cases where compute +matters or populations are large + dynamic. + +`GpuBatchedMesh3d` (used by Field's transient draw entity) and `Mesh3d` are mutually +exclusive on one entity by upstream design. + +## v1 non-goals + +- **Chainable `apply()`** — currently flat function call. Quality of life. +- **Stateful builder methods on Field** (`field.color() / field.vertex()`) — the + mesh-seeding path covers most cases. +- **Closure-based `createField(|| { sphere(); ... })` recording mode** — would need + shape-API recording infrastructure (sphere/box dispatching into a Geometry instead of + drawing). +- **GPU-driven emission**, sparse alive set / compaction, multi-emitter pools, cross-field + operations. +- **Per-instance attributes via `@location`** — upstream supports only the transform; the + tag side-channel into a storage buffer is the only path for non-transform per-instance + data. +- **Auto-default attribute reset on `field_emit`**. +- **User-configurable PBR properties** on `FieldPbrMaterial` (roughness, metallic) via + `material_set`. +- **Built-in compute kernels** (NOISE, CURL, etc.) — packaged WGSL. +- **Ping-pong apply**. + +## Architectural notes + +- **Pack pass schedule.** The original design intent was to tie pack to the `field(f, + shape)` draw verb call (lazy, one-shot). The implementation runs pack as standard + render-schedule systems triggered by the `FieldDraw` marker on transient draw entities. + Same effect (pack only fires when there's something to draw), simpler integration. +- **Per-particle color material.** The original design intent was to extend + `ProcessingMaterial`. The implementation is two standalone material types + (`FieldColorMaterial`, `FieldPbrMaterial`). Standalone was cleaner; ambient `fill()` + doesn't auto-tint particles, but the user explicitly opts in via the dedicated factory. +- **Persistent draw entity.** The Field's `draw_entity` must persist across frames — the + upstream batching queue processes mesh instance batches one frame after the reservation + is created, so despawning per-frame would lose the entity before queueing. + +## Examples + +- `field_basic` — 1000 spheres on a 10×10×10 grid, static positions, default material. +- `field_animated` — same grid, rotating around Y via per-frame compute apply. +- `field_oriented` — 125 cubes with per-particle quaternion rotation + per-particle scale. +- `field_colored` — RGB-gradient cube via `FieldColorMaterial` (unlit). +- `field_colored_pbr` — same, lit with `FieldPbrMaterial`. +- `field_emit` — continuous ring-buffer emission in a spiral. +- `field_lifecycle` — fountain that emits particles with aging + shrink-on-death. +- `field_from_mesh` — particles positioned at the vertices of a source sphere mesh. + +## Fixed bugs (during development) + +- **`bevy_naga_reflect` struct uniform encoding.** `type_size` previously aligned every + struct member to 16 bytes (so 4 f32s claimed 64 bytes). `write_to_buffer` used + `encase::UniformBuffer::write` which resets to offset 0 each call — only the last + member's bytes survived. Both fixed in the local checkout at + `~/src/github.com/tychedelia/bevy_naga_reflect`. libprocessing's `Cargo.toml` points at + the local checkout via `path =` until the fix is pushed back. +- **`mode_3d` near-plane.** Was `camera_z / 10` (~60 units), which clipped particles when + the camera was moved closer via `transform_set_position`. Changed to fixed `near = 1.0`. diff --git a/examples/field_emit_gpu.rs b/examples/field_emit_gpu.rs new file mode 100644 index 0000000..5c8edc6 --- /dev/null +++ b/examples/field_emit_gpu.rs @@ -0,0 +1,223 @@ +use processing_glfw::GlfwContext; +use std::time::Instant; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::geometry::AttributeFormat; +use processing_render::render::command::DrawCommand; + +const SPAWN_SHADER: &str = r#" +struct Spawn { + pos: vec4, + speed: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var color: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var dead: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_range: vec4; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= u32(emit_range.y) { return; } + let base = u32(emit_range.x); + let cap = u32(emit_range.z); + let slot = (base + local_i) % cap; + + let seed = base + local_i; + + // Random unit-disc direction with some upward bias. + let theta = hash_unit(seed) * 6.2831853; + let r = sqrt(hash_unit(seed * 2u + 1u)); + let dirxz = vec2(cos(theta), sin(theta)) * r; + let dy = 0.7 + 0.3 * hash_unit(seed * 3u + 7u); + let v = vec3(dirxz.x, dy, dirxz.y) * spawn.speed.x; + + position[slot * 3u + 0u] = spawn.pos.x; + position[slot * 3u + 1u] = spawn.pos.y; + position[slot * 3u + 2u] = spawn.pos.z; + + velocity[slot * 3u + 0u] = v.x; + velocity[slot * 3u + 1u] = v.y; + velocity[slot * 3u + 2u] = v.z; + + let h = fract(hash_unit(seed * 5u + 11u)); + color[slot * 4u + 0u] = 0.5 + 0.5 * sin(h * 6.28); + color[slot * 4u + 1u] = 0.5 + 0.5 * sin(h * 6.28 + 2.094); + color[slot * 4u + 2u] = 0.5 + 0.5 * sin(h * 6.28 + 4.189); + color[slot * 4u + 3u] = 1.0; + + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + + age[slot] = 0.0; + dead[slot] = 0.0; +} +"#; + +const MOTION_SHADER: &str = r#" +struct Params { + dt: f32, + ttl: f32, + gravity: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var scale: array; +@group(0) @binding(3) var age: array; +@group(0) @binding(4) var dead: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + if dead[i] != 0.0 { return; } + + age[i] = age[i] + params.dt; + + velocity[i * 3u + 1u] = velocity[i * 3u + 1u] - params.gravity * params.dt; + + position[i * 3u + 0u] = position[i * 3u + 0u] + velocity[i * 3u + 0u] * params.dt; + position[i * 3u + 1u] = position[i * 3u + 1u] + velocity[i * 3u + 1u] * params.dt; + position[i * 3u + 2u] = position[i * 3u + 2u] + velocity[i * 3u + 2u] * params.dt; + + let life = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { dead[i] = 1.0; } +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 16.0))?; + transform_look_at(graphics, Vec3::new(0.0, 2.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 800.0)?; + + let particle = geometry_sphere(0.12, 8, 6)?; + + let capacity: u32 = 40000; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let scale_attr = geometry_attribute_scale(); + let dead_attr = geometry_attribute_dead(); + let velocity_attr = geometry_attribute_create("velocity", AttributeFormat::Float3)?; + let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; + + let field = field_create( + capacity, + vec![ + position_attr, + color_attr, + scale_attr, + dead_attr, + velocity_attr, + age_attr, + ], + )?; + + // Mark all unemitted slots dead so they don't render at origin. + let dead_buf = field_pbuffer(field, dead_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let init_dead: Vec = (0..capacity) + .flat_map(|_| 1.0_f32.to_le_bytes()) + .collect(); + buffer_write(dead_buf, init_dead)?; + + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let mat = material_create_field_pbr(color_buf)?; + + let spawn_shader = shader_create(SPAWN_SHADER)?; + let spawn = compute_create(spawn_shader)?; + + let motion_shader = shader_create(MOTION_SHADER)?; + let motion = compute_create(motion_shader)?; + + let burst: u32 = 120; + let dt: f32 = 1.0 / 60.0; + let ttl: f32 = 2.5; + let gravity: f32 = 9.8; + let speed: f32 = 5.0; + let start = Instant::now(); + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: particle, + }, + )?; + graphics_end_draw(graphics)?; + + // Animate spawn point in a small circle so the fountain meanders. + let t = start.elapsed().as_secs_f32(); + let sx = t.cos() * 0.4; + let sz = t.sin() * 0.4; + compute_set( + spawn, + "pos", + shader_value::ShaderValue::Float4([sx, 7.0, sz, 0.0]), + )?; + compute_set( + spawn, + "speed", + shader_value::ShaderValue::Float4([speed, 0.0, 0.0, 0.0]), + )?; + field_emit_gpu(field, burst, spawn)?; + + compute_set(motion, "dt", shader_value::ShaderValue::Float(dt))?; + compute_set(motion, "ttl", shader_value::ShaderValue::Float(ttl))?; + compute_set(motion, "gravity", shader_value::ShaderValue::Float(gravity))?; + field_apply(field, motion)?; + } + + Ok(()) +} diff --git a/examples/field_noise.rs b/examples/field_noise.rs new file mode 100644 index 0000000..2e5a7d8 --- /dev/null +++ b/examples/field_noise.rs @@ -0,0 +1,97 @@ +use processing_glfw::GlfwContext; +use std::time::Instant; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + // Seed positions from a sphere mesh; noise will jitter them around their + // initial sphere shape over time. + let source = geometry_sphere(5.0, 32, 24)?; + let position_attr = geometry_attribute_position(); + let uv_attr = geometry_attribute_uv(); + let color_attr = geometry_attribute_color(); + let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + + let uv_buf = + field_pbuffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = + field_pbuffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + + // Color each particle by hue from its U coord. + let uv_bytes = buffer_read(uv_buf)?; + let mut colors: Vec = Vec::with_capacity(uv_bytes.len() * 2); + for chunk in uv_bytes.chunks_exact(8) { + let u = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let (r, g, b) = hsv_to_rgb(u, 0.85, 1.0); + for f in [r, g, b, 1.0] { + colors.extend_from_slice(&f.to_le_bytes()); + } + } + buffer_write(color_buf, colors)?; + + let particle = geometry_sphere(0.18, 10, 8)?; + let mat = material_create_field_pbr(color_buf)?; + let noise = field_kernel_noise()?; + + let start = Instant::now(); + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: particle, + }, + )?; + graphics_end_draw(graphics)?; + + let t = start.elapsed().as_secs_f32(); + compute_set(noise, "scale", shader_value::ShaderValue::Float(0.25))?; + compute_set(noise, "strength", shader_value::ShaderValue::Float(0.02))?; + compute_set(noise, "time", shader_value::ShaderValue::Float(t * 0.5))?; + field_apply(field, noise)?; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32).rem_euclid(6) { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/field_stress.rs b/examples/field_stress.rs new file mode 100644 index 0000000..f085b8c --- /dev/null +++ b/examples/field_stress.rs @@ -0,0 +1,145 @@ +//! Stress test: a "silly" number of PBR-lit cubes slowly rotating. Mostly here +//! to feel out the practical upper bound — change `GRID` to push it harder. + +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const GRID: u32 = 100; // GRID^3 = 1,000,000 particles +const SPACING: f32 = 1.0; + +const SPIN_SHADER: &str = r#" +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn hash_u32(mut x: u32) -> u32 { + x = (x ^ 61).wrapping_add(x >> 16); + x = x.wrapping_add(x << 3); + x ^= x >> 4; + x = x.wrapping_mul(0x27d4eb2d); + x ^= x >> 15; + x +} + +fn hash_unit(seed: u32) -> f32 { + (hash_u32(seed) as f32) / (u32::MAX as f32) +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + let extent = (GRID as f32) * SPACING * 0.5; + transform_set_position(graphics, Vec3::new(0.0, extent * 0.6, extent * 2.5))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + graphics_orbit_camera(graphics)?; + + // Three directional R/G/B lights from cardinal axes — each cube face picks + // up the closest light's color so the lighting variation is obvious. + let red = light_create_directional(graphics, bevy::color::Color::srgb(1.0, 0.0, 0.0), 1000.0)?; + transform_set_position(red, Vec3::new(1.0, 0.0, 0.0))?; + transform_look_at(red, Vec3::ZERO)?; + + let green = + light_create_directional(graphics, bevy::color::Color::srgb(0.0, 1.0, 0.0), 1000.0)?; + transform_set_position(green, Vec3::new(0.0, 1.0, 0.0))?; + transform_look_at(green, Vec3::ZERO)?; + + let blue = light_create_directional(graphics, bevy::color::Color::srgb(0.0, 0.0, 1.0), 1000.0)?; + transform_set_position(blue, Vec3::new(0.0, 0.0, 1.0))?; + transform_look_at(blue, Vec3::ZERO)?; + + let cube = geometry_box(0.35, 0.35, 0.35)?; + + let capacity = GRID * GRID * GRID; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let field = field_create(capacity, vec![position_attr, color_attr])?; + + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + let extent_half = (GRID as f32) * SPACING * 0.5; + for i in 0..capacity { + // Three independent hash streams give us pseudo-random uniform values. + let rx = hash_unit(i.wrapping_mul(2654435761).wrapping_add(0x9E37)); + let ry = hash_unit(i.wrapping_mul(40503).wrapping_add(0x68E1)); + let rz = hash_unit(i.wrapping_mul(2246822519).wrapping_add(0xC2B2)); + positions.push((rx * 2.0 - 1.0) * extent_half); + positions.push((ry * 2.0 - 1.0) * extent_half); + positions.push((rz * 2.0 - 1.0) * extent_half); + // Color from the same random samples — stable per particle. + colors.push(rx); + colors.push(ry); + colors.push(rz); + colors.push(1.0); + } + let position_buf = field_pbuffer(field, position_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = field_pbuffer(field, color_attr)? + .ok_or(error::ProcessingError::FieldNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = material_create_field_pbr(color_buf)?; + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + eprintln!("field_stress: {capacity} particles"); + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Field { + field, + geometry: cube, + }, + )?; + graphics_end_draw(graphics)?; + + compute_set(spin, "dt", shader_value::ShaderValue::Float(0.003))?; + field_apply(field, spin)?; + } + + Ok(()) +} From f2ade7eacfa50e111cb2063aabac8052b6f757c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 1 May 2026 17:00:30 -0700 Subject: [PATCH 08/11] . --- .../examples/field_animated.py | 2 +- .../processing_pyo3/examples/field_basic.py | 23 +-- crates/processing_pyo3/examples/field_emit.py | 61 ++++++ .../examples/field_emit_gpu.py | 181 ++++++++++++++++++ .../examples/field_from_mesh.py | 46 +++++ .../examples/field_lifecycle.py | 126 ++++++++++++ .../processing_pyo3/examples/field_noise.py | 51 +++++ .../processing_pyo3/examples/field_stress.py | 75 ++++++++ crates/processing_pyo3/mewnala/__init__.py | 6 +- crates/processing_pyo3/src/compute.rs | 24 ++- crates/processing_pyo3/src/field.rs | 24 ++- crates/processing_pyo3/src/graphics.rs | 12 ++ crates/processing_pyo3/src/lib.rs | 123 ++++++------ .../src/field/{field_pbr.wgsl => field.wgsl} | 0 .../src/field/field_color.wgsl | 42 ---- .../src/field/kernels/mod.rs | 2 + .../src/field/kernels/transform.wgsl | 52 +++++ crates/processing_render/src/field/mod.rs | 38 ++-- crates/processing_render/src/field/pack.rs | 12 +- crates/processing_render/src/field/pack.wgsl | 10 +- crates/processing_render/src/geometry/mod.rs | 21 +- crates/processing_render/src/lib.rs | 86 ++++++--- .../src/render/primitive/mod.rs | 4 +- .../src/render/primitive/shape3d.rs | 37 ++++ docs/field.md | 38 ++-- examples/field_animated.rs | 2 +- examples/field_basic.rs | 2 +- examples/field_colored.rs | 4 +- examples/field_colored_pbr.rs | 4 +- examples/field_emit.rs | 4 +- examples/field_emit_gpu.rs | 4 +- examples/field_from_mesh.rs | 6 +- examples/field_lifecycle.rs | 4 +- examples/field_noise.rs | 4 +- examples/field_oriented.rs | 6 +- examples/field_stress.rs | 4 +- 36 files changed, 905 insertions(+), 235 deletions(-) create mode 100644 crates/processing_pyo3/examples/field_emit.py create mode 100644 crates/processing_pyo3/examples/field_emit_gpu.py create mode 100644 crates/processing_pyo3/examples/field_from_mesh.py create mode 100644 crates/processing_pyo3/examples/field_lifecycle.py create mode 100644 crates/processing_pyo3/examples/field_noise.py create mode 100644 crates/processing_pyo3/examples/field_stress.py rename crates/processing_render/src/field/{field_pbr.wgsl => field.wgsl} (100%) delete mode 100644 crates/processing_render/src/field/field_color.wgsl create mode 100644 crates/processing_render/src/field/kernels/transform.wgsl diff --git a/crates/processing_pyo3/examples/field_animated.py b/crates/processing_pyo3/examples/field_animated.py index 17137ad..6a2e952 100644 --- a/crates/processing_pyo3/examples/field_animated.py +++ b/crates/processing_pyo3/examples/field_animated.py @@ -48,7 +48,7 @@ def setup(): positions.extend([x - 4.5, y - 4.5, z - 4.5]) field_obj = Field(capacity=capacity, attributes=[Attribute.position()]) - pos_buf = field_obj.pbuffer(Attribute.position()) + pos_buf = field_obj.buffer(Attribute.position()) pos_buf.write(positions) mat = Material(roughness=0.4) diff --git a/crates/processing_pyo3/examples/field_basic.py b/crates/processing_pyo3/examples/field_basic.py index 3fcf5bd..3a975d6 100644 --- a/crates/processing_pyo3/examples/field_basic.py +++ b/crates/processing_pyo3/examples/field_basic.py @@ -21,26 +21,13 @@ def setup(): attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - # Read uvs back, build per-particle colors, write to color PBuffer. - color_buf = field_obj.pbuffer(Attribute.color()) - uv_buf = field_obj.pbuffer(Attribute.uv()) + uv_buf = field_obj.buffer(Attribute.uv()) + color_buf = field_obj.buffer(Attribute.color()) + colors = [] for uv in uv_buf.read(): - u = uv[0] - h = u * 6.0 - c = h - int(h) - if h < 1: - colors.append([1.0, c, 0.0, 1.0]) - elif h < 2: - colors.append([1.0 - c, 1.0, 0.0, 1.0]) - elif h < 3: - colors.append([0.0, 1.0, c, 1.0]) - elif h < 4: - colors.append([0.0, 1.0 - c, 1.0, 1.0]) - elif h < 5: - colors.append([c, 0.0, 1.0, 1.0]) - else: - colors.append([1.0, 0.0, 1.0 - c, 1.0]) + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) diff --git a/crates/processing_pyo3/examples/field_emit.py b/crates/processing_pyo3/examples/field_emit.py new file mode 100644 index 0000000..3b6d8fe --- /dev/null +++ b/crates/processing_pyo3/examples/field_emit.py @@ -0,0 +1,61 @@ +from mewnala import * +import math + +field_obj = None +sphere = None +mat = None +frame = 0 + + +def setup(): + global field_obj, sphere, mat + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.08, 8, 6) + + capacity = 2000 + field_obj = Field( + capacity=capacity, + attributes=[Attribute.position(), Attribute.color()], + ) + + # Push unemitted slots far off-screen so they don't all render at the + # origin while the ring buffer is still filling. + pos_buf = field_obj.buffer(Attribute.position()) + pos_buf.write([1.0e6] * (capacity * 3)) + + color_buf = field_obj.buffer(Attribute.color()) + mat = Material.field_color(color_buf) + + +def draw(): + global frame + camera_position(0.0, 4.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + draw_field(field_obj, sphere) + + # Emit 4 particles per frame in an outward-spiraling ring; once the ring + # buffer fills (~500 frames at 4/frame for capacity 2000), oldest get + # overwritten and the swirl continues without bound. + burst = 4 + positions = [] + colors = [] + for k in range(burst): + i = frame * burst + k + t = i * 0.05 + radius = 1.5 + min(t * 0.02, 3.0) + height = math.sin(t * 0.1) * 2.0 + positions.extend([math.cos(t) * radius, height, math.sin(t) * radius]) + c = hsva((i * 4.32) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + field_obj.emit(burst, position=positions, color=colors) + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/field_emit_gpu.py b/crates/processing_pyo3/examples/field_emit_gpu.py new file mode 100644 index 0000000..59ef1b0 --- /dev/null +++ b/crates/processing_pyo3/examples/field_emit_gpu.py @@ -0,0 +1,181 @@ +from mewnala import * +import math + +field_obj = None +particle = None +mat = None +spawn = None +motion = None + +CAPACITY = 40000 +BURST = 120 +DT = 1.0 / 60.0 +TTL = 2.5 +GRAVITY = 9.8 +SPEED = 5.0 + +SPAWN_SHADER = """ +struct Spawn { + pos: vec4, + speed: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var color: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var dead: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_range: vec4; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= u32(emit_range.y) { return; } + let base = u32(emit_range.x); + let cap = u32(emit_range.z); + let slot = (base + local_i) % cap; + + let seed = base + local_i; + + let theta = hash_unit(seed) * 6.2831853; + let r = sqrt(hash_unit(seed * 2u + 1u)); + let dirxz = vec2(cos(theta), sin(theta)) * r; + let dy = 0.7 + 0.3 * hash_unit(seed * 3u + 7u); + let v = vec3(dirxz.x, dy, dirxz.y) * spawn.speed.x; + + position[slot * 3u + 0u] = spawn.pos.x; + position[slot * 3u + 1u] = spawn.pos.y; + position[slot * 3u + 2u] = spawn.pos.z; + + velocity[slot * 3u + 0u] = v.x; + velocity[slot * 3u + 1u] = v.y; + velocity[slot * 3u + 2u] = v.z; + + let h = fract(hash_unit(seed * 5u + 11u)); + color[slot * 4u + 0u] = 0.5 + 0.5 * sin(h * 6.28); + color[slot * 4u + 1u] = 0.5 + 0.5 * sin(h * 6.28 + 2.094); + color[slot * 4u + 2u] = 0.5 + 0.5 * sin(h * 6.28 + 4.189); + color[slot * 4u + 3u] = 1.0; + + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + + age[slot] = 0.0; + dead[slot] = 0.0; +} +""" + +MOTION_SHADER = """ +struct Params { + dt: f32, + ttl: f32, + gravity: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var scale: array; +@group(0) @binding(3) var age: array; +@group(0) @binding(4) var dead: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + if dead[i] != 0.0 { return; } + + age[i] = age[i] + params.dt; + + velocity[i * 3u + 1u] = velocity[i * 3u + 1u] - params.gravity * params.dt; + + position[i * 3u + 0u] = position[i * 3u + 0u] + velocity[i * 3u + 0u] * params.dt; + position[i * 3u + 1u] = position[i * 3u + 1u] + velocity[i * 3u + 1u] * params.dt; + position[i * 3u + 2u] = position[i * 3u + 2u] + velocity[i * 3u + 2u] * params.dt; + + let life = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { dead[i] = 1.0; } +} +""" + + +def setup(): + global field_obj, particle, mat, spawn, motion + + size(900, 700) + mode_3d() + + create_directional_light((0.95, 0.9, 0.85), 800.0) + + particle = Geometry.sphere(0.12, 8, 6) + + velocity_attr = Attribute("velocity", AttributeFormat.Float3) + age_attr = Attribute("age", AttributeFormat.Float) + + field_obj = Field( + capacity=CAPACITY, + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute.scale(), + Attribute.dead(), + velocity_attr, + age_attr, + ], + ) + + # Mark all unemitted slots dead so they don't render at origin. + dead_buf = field_obj.buffer(Attribute.dead()) + dead_buf.write([1.0] * CAPACITY) + + color_buf = field_obj.buffer(Attribute.color()) + mat = Material.field_pbr(color_buf) + + spawn = Compute(Shader(SPAWN_SHADER)) + motion = Compute(Shader(MOTION_SHADER)) + + +def draw(): + camera_position(0.0, 4.0, 16.0) + camera_look_at(0.0, 2.0, 0.0) + background(10, 10, 18) + + use_material(mat) + draw_field(field_obj, particle) + + # Animate spawn point in a small circle so the fountain meanders. + t = elapsed_time + sx = math.cos(t) * 0.4 + sz = math.sin(t) * 0.4 + spawn.set(pos=[sx, 7.0, sz, 0.0], speed=[SPEED, 0.0, 0.0, 0.0]) + field_obj.emit_gpu(BURST, spawn) + + motion.set(dt=DT, ttl=TTL, gravity=GRAVITY) + field_obj.apply(motion) + + +run() diff --git a/crates/processing_pyo3/examples/field_from_mesh.py b/crates/processing_pyo3/examples/field_from_mesh.py new file mode 100644 index 0000000..43642a2 --- /dev/null +++ b/crates/processing_pyo3/examples/field_from_mesh.py @@ -0,0 +1,46 @@ +from mewnala import * + +field_obj = None +particle = None +mat = None + + +def setup(): + global field_obj, particle, mat + + size(900, 700) + mode_3d() + + create_directional_light((0.95, 0.9, 0.85), 200.0) + + # Source mesh whose vertices become the particle positions. UVs come along + # for free and we'll use them to paint each particle a unique color. + source = Geometry.sphere(5.0, 32, 24) + field_obj = Field( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = field_obj.buffer(Attribute.uv()) + color_buf = field_obj.buffer(Attribute.color()) + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.field_pbr(color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + draw_field(field_obj, particle) + + +run() diff --git a/crates/processing_pyo3/examples/field_lifecycle.py b/crates/processing_pyo3/examples/field_lifecycle.py new file mode 100644 index 0000000..b36a7eb --- /dev/null +++ b/crates/processing_pyo3/examples/field_lifecycle.py @@ -0,0 +1,126 @@ +from mewnala import * +import math + +field_obj = None +sphere = None +mat = None +aging = None +position_attr = None +color_attr = None +scale_attr = None +dead_attr = None +age_attr = None +frame = 0 + +BURST = 6 +DT = 1.0 / 60.0 +TTL = 1.0 + +AGING_SHADER = """ +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var dead: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var params: vec4; // x = dt, y = ttl + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { + return; + } + let dt = params.x; + let ttl = params.y; + + if dead[i] != 0.0 { + return; + } + + age[i] = age[i] + dt; + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + dead[i] = 1.0; + } +} +""" + + +def setup(): + global field_obj, sphere, mat, aging + global position_attr, color_attr, scale_attr, dead_attr, age_attr + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.1, 8, 6) + + capacity = 800 + position_attr = Attribute.position() + color_attr = Attribute.color() + scale_attr = Attribute.scale() + dead_attr = Attribute.dead() + age_attr = Attribute("age", AttributeFormat.Float) + + field_obj = Field( + capacity=capacity, + attributes=[position_attr, color_attr, scale_attr, dead_attr, age_attr], + ) + + # Mark all slots dead initially so unemitted ring slots don't render. + dead_buf = field_obj.buffer(dead_attr) + dead_buf.write([1.0] * capacity) + + color_buf = field_obj.buffer(color_attr) + mat = Material.field_color(color_buf) + aging = Compute(Shader(AGING_SHADER)) + + +def draw(): + global frame + camera_position(0.0, 2.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(10, 10, 18) + + use_material(mat) + draw_field(field_obj, sphere) + + # Spawn `BURST` new particles per frame in a small fountain. + positions = [] + colors = [] + for k in range(BURST): + i = frame * BURST + k + # Cheap pseudo-random offset. + u = (((i * 2654435761) >> 8) & 0xFFFF) / 65535.0 + v = (((i * 40503) >> 8) & 0xFFFF) / 65535.0 + theta = u * math.tau + r = v * 0.6 + positions.extend([math.cos(theta) * r, 2.5, math.sin(theta) * r]) + c = hsva((i * 4.68) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + zeros = [0.0] * BURST + ones_scale = [1.0] * (BURST * 3) + field_obj.emit( + BURST, + position=positions, + color=colors, + scale=ones_scale, + age=zeros, + dead=zeros, + ) + + aging.set(params=[DT, TTL, 0.0, 0.0]) + field_obj.apply(aging) + + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/field_noise.py b/crates/processing_pyo3/examples/field_noise.py new file mode 100644 index 0000000..0f71411 --- /dev/null +++ b/crates/processing_pyo3/examples/field_noise.py @@ -0,0 +1,51 @@ +from mewnala import * + +field_obj = None +particle = None +mat = None +noise = None + + +def setup(): + global field_obj, particle, mat, noise + + size(900, 700) + mode_3d() + + create_directional_light((0.95, 0.9, 0.85), 200.0) + + # Seed positions from a sphere mesh; noise will jitter them around their + # initial sphere shape over time. + source = Geometry.sphere(5.0, 32, 24) + field_obj = Field( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = field_obj.buffer(Attribute.uv()) + color_buf = field_obj.buffer(Attribute.color()) + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.field_pbr(color_buf) + noise = kernel_noise() + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + draw_field(field_obj, particle) + + noise.set(scale=0.25, strength=0.02, time=elapsed_time * 0.5) + field_obj.apply(noise) + + +run() diff --git a/crates/processing_pyo3/examples/field_stress.py b/crates/processing_pyo3/examples/field_stress.py new file mode 100644 index 0000000..c865e05 --- /dev/null +++ b/crates/processing_pyo3/examples/field_stress.py @@ -0,0 +1,75 @@ +from mewnala import * + +GRID = 100 # GRID^3 = 1,000,000 particles +SPACING = 1.0 +SPIN_PER_FRAME = 0.003 + +field_obj = None +cube = None +mat = None +spin = None + + +def setup(): + global field_obj, cube, mat, spin + + size(900, 700) + mode_3d() + + extent = GRID * SPACING * 0.5 + camera_position(0.0, extent * 0.6, extent * 2.5) + camera_look_at(0.0, 0.0, 0.0) + orbit_camera() + + # Three directional R/G/B lights from cardinal axes. + red = create_directional_light((1.0, 0.0, 0.0), 1000.0) + red.position(1.0, 0.0, 0.0) + red.look_at(0.0, 0.0, 0.0) + green = create_directional_light((0.0, 1.0, 0.0), 1000.0) + green.position(0.0, 1.0, 0.0) + green.look_at(0.0, 0.0, 0.0) + blue = create_directional_light((0.0, 0.0, 1.0), 1000.0) + blue.position(0.0, 0.0, 1.0) + blue.look_at(0.0, 0.0, 0.0) + + field_obj = Field( + geometry=Geometry.grid(GRID, GRID, GRID, SPACING), + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + # One-shot noise pass to break the regular lattice up. `scale` is the + # input multiplier applied to position before sampling — at < 1 / SPACING + # adjacent grid points sample nearly the same noise cell and get nearly + # identical displacement, leaving the lattice visible. Bumping it past + # 1 / SPACING breaks the grid. + jitter = kernel_noise() + jitter.set(scale=1.0 / SPACING, strength=SPACING * 0.6, time=0.0) + field_obj.apply(jitter) + + # Color each particle by its lattice u-coord. + uv_buf = field_obj.buffer(Attribute.uv()) + color_buf = field_obj.buffer(Attribute.color()) + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + mat = Material.field_pbr(color_buf) + + cube = Geometry.box(0.35, 0.35, 0.35) + + spin = kernel_transform() + spin.set(rotation=[0.0, 1.0, 0.0, SPIN_PER_FRAME]) + + +def draw(): + background(10, 10, 18) + + use_material(mat) + draw_field(field_obj, cube) + + field_obj.apply(spin) + + +run() diff --git a/crates/processing_pyo3/mewnala/__init__.py b/crates/processing_pyo3/mewnala/__init__.py index 13b1b81..e76c60a 100644 --- a/crates/processing_pyo3/mewnala/__init__.py +++ b/crates/processing_pyo3/mewnala/__init__.py @@ -1,11 +1,11 @@ from .mewnala import * # re-export the native submodules as submodules of this module, if they exist -# this allows users to import from `mewnala.math` and `mewnala.color` -# if they exist, without needing to know about the internal structure of the native module +# this allows users to import from `mewnala.math` without needing to know about +# the internal structure of the native module import sys as _sys from . import mewnala as _native -for _name in ("math", "color"): +for _name in ("math",): _sub = getattr(_native, _name, None) if _sub is not None: _sys.modules[f"{__name__}.{_name}"] = _sub diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index 85d7794..11064ca 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -16,18 +16,25 @@ pub struct Buffer { pub(crate) entity: Entity, element_type: Option, size: u64, + /// `false` for buffers we created and own — `Drop` destroys the entity. + /// `true` for buffers we wrap (e.g. a Field's attribute buffer) where the + /// underlying entity belongs to someone else; destroying it would yank the + /// buffer out from under the owner. + borrowed: bool, } impl Buffer { - /// Wrap an existing buffer entity (e.g., one owned by a Field's PBuffer). + /// Wrap an existing buffer entity (e.g., one owned by a Field). /// `size` is queried from the buffer; `element_type` is supplied so typed - /// reads / `__getitem__` work correctly. + /// reads / `__getitem__` work correctly. The wrapper does NOT destroy the + /// entity on drop — ownership stays with whoever produced it. pub(crate) fn from_entity(entity: Entity, element_type: Option) -> Self { let size = buffer_size(entity).unwrap_or(0); Self { entity, element_type, size, + borrowed: true, } } } @@ -53,6 +60,7 @@ impl Buffer { entity, element_type, size, + borrowed: false, }) } @@ -136,6 +144,14 @@ impl Buffer { } pub fn write(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + // Fast path: raw bytes go through unchanged. This is essential for + // large buffers where iterating Python objects would be unworkably + // slow (e.g. 1M-element fields). Element type is preserved if already + // known; otherwise the buffer stays untyped (read() returns bytes). + if let Ok(b) = values.cast::() { + return buffer_write(self.entity, b.as_bytes().to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } let (bytes, element_type) = shader_values_to_bytes(values)?; self.element_type = element_type; buffer_write(self.entity, bytes).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -195,7 +211,9 @@ impl Buffer { impl Drop for Buffer { fn drop(&mut self) { - let _ = buffer_destroy(self.entity); + if !self.borrowed { + let _ = buffer_destroy(self.entity); + } } } diff --git a/crates/processing_pyo3/src/field.rs b/crates/processing_pyo3/src/field.rs index 0214395..736ee6f 100644 --- a/crates/processing_pyo3/src/field.rs +++ b/crates/processing_pyo3/src/field.rs @@ -116,8 +116,8 @@ impl Field { #[pymethods] impl Field { - /// Construct a Field. Provide either `capacity` (allocates empty PBuffers) - /// or `geometry` (capacity = vertex count, PBuffers seeded from matching + /// Construct a Field. Provide either `capacity` (allocates empty buffers) + /// or `geometry` (capacity = vertex count, buffers seeded from matching /// mesh attributes), but not both. #[new] #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] @@ -166,8 +166,8 @@ impl Field { /// attribute isn't part of this Field. The returned buffer's element type /// matches the attribute's format so `read()` / `__getitem__` return typed /// values (e.g. lists of vec3 components for a Float3 attribute). - pub fn pbuffer(&self, attribute: &Attribute) -> PyResult> { - let pbuf = field_pbuffer(self.entity, attribute.entity) + pub fn buffer(&self, attribute: &Attribute) -> PyResult> { + let buf = field_buffer(self.entity, attribute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let (_, fmt) = geometry_attribute_info(attribute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; @@ -177,10 +177,10 @@ impl Field { AttributeFormat::Float3 => shader_value::ShaderValue::Float3([0.0; 3]), AttributeFormat::Float4 => shader_value::ShaderValue::Float4([0.0; 4]), }; - Ok(pbuf.map(|e| Buffer::from_entity(e, Some(element_type)))) + Ok(buf.map(|e| Buffer::from_entity(e, Some(element_type)))) } - /// Run a compute kernel against this Field's PBuffers. Each PBuffer is + /// Run a compute kernel against this Field's buffers. Each buffer is /// auto-bound by its attribute name; uniforms must be set on the compute /// beforehand via `compute.set(...)`. pub fn apply(&self, compute: &Compute) -> PyResult<()> { @@ -227,7 +227,7 @@ impl Field { } /// GPU-driven emission. Dispatches `compute` over `n` invocations to - /// initialize the next `n` ring-buffer slots. The compute's PBuffer + /// initialize the next `n` ring-buffer slots. The compute's buffer /// bindings are auto-set; the `emit_range: vec4` uniform is auto-set /// to `(base_slot, n, capacity, 0)`. User-set uniforms (spawn position, /// velocity hint, etc.) must be assigned to the compute beforehand. @@ -248,3 +248,13 @@ pub fn kernel_noise() -> PyResult { let entity = field_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Compute::from_entity(entity)) } + +/// Built-in transform compute kernel — applies an affine to each particle's +/// position in scale → axis-angle rotation → translate order. Configure via +/// `compute.set(translate=[tx,ty,tz,0], rotation=[ax,ay,az,angle_rad], scale=[sx,sy,sz,0])` +/// (rotation xyz = axis, w = angle in radians). Defaults of zero/one behave as +/// identity, so unset parameters are no-ops. +pub fn kernel_transform() -> PyResult { + let entity = field_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 2c5be66..f0443ca 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -296,6 +296,18 @@ impl Geometry { .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } + + /// 3D lattice of `nx * ny * nz` points centered at the origin, with + /// `spacing` units between adjacent points. Topology is `PointList` — + /// typically used as a position source for `Field(geometry=...)` rather + /// than rasterized directly. + #[staticmethod] + #[pyo3(signature = (nx, ny, nz, spacing=1.0))] + pub fn grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> PyResult { + let entity = geometry_grid(nx, ny, nz, spacing) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } } #[pyclass(unsendable)] diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index bfc9e65..20258ef 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -331,6 +331,8 @@ mod mewnala { #[pymodule_export] use super::Buffer; #[pymodule_export] + use super::color::PyColor; + #[pymodule_export] use super::Compute; #[pymodule_export] use super::field::Attribute; @@ -672,77 +674,73 @@ mod mewnala { } } - #[pymodule] - mod color { - use super::*; + // Color constructors — promoted to top-level so `from mewnala import *` + // exposes `hsva(...)`, `srgb(...)`, etc. directly. Living in a `color` + // submodule conflicted with the Processing-style `color()` function. - #[pymodule_export] - use crate::color::PyColor; - - #[pyfunction] - fn hex(s: &str) -> PyResult { - PyColor::hex(s) - } + #[pyfunction] + fn color_hex(s: &str) -> PyResult { + PyColor::hex(s) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::srgb(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::srgb(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn linear(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::linear(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn linear_rgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::linear(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, l, a=1.0))] - fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { - PyColor::hsla(h, s, l, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, l, a=1.0))] + fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { + PyColor::hsla(h, s, l, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, v, a=1.0))] - fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { - PyColor::hsva(h, s, v, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, v, a=1.0))] + fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { + PyColor::hsva(h, s, v, a) + } - #[pyfunction] - #[pyo3(signature = (h, w, b, a=1.0))] - fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { - PyColor::hwba(h, w, b, a) - } + #[pyfunction] + #[pyo3(signature = (h, w, b, a=1.0))] + fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { + PyColor::hwba(h, w, b, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::oklab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::oklab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::oklch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::oklch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::lab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::lab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::lch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::lch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (x, y, z, a=1.0))] - fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { - PyColor::xyz(x, y, z, a) - } + #[pyfunction] + #[pyo3(signature = (x, y, z, a=1.0))] + fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { + PyColor::xyz(x, y, z, a) } #[cfg(feature = "webcam")] @@ -1287,6 +1285,11 @@ mod mewnala { super::field::kernel_noise() } + #[pyfunction] + fn kernel_transform() -> PyResult { + super::field::kernel_transform() + } + #[pyfunction(name = "color")] #[pyo3(pass_module, signature = (*args))] fn create_color( diff --git a/crates/processing_render/src/field/field_pbr.wgsl b/crates/processing_render/src/field/field.wgsl similarity index 100% rename from crates/processing_render/src/field/field_pbr.wgsl rename to crates/processing_render/src/field/field.wgsl diff --git a/crates/processing_render/src/field/field_color.wgsl b/crates/processing_render/src/field/field_color.wgsl deleted file mode 100644 index 0f3e898..0000000 --- a/crates/processing_render/src/field/field_color.wgsl +++ /dev/null @@ -1,42 +0,0 @@ -// Per-particle color material for [`Field`] rasterization. -// -// Reads `mesh.tag` (written by the pack pass as the per-particle slot index) -// and looks up a per-particle color from a storage buffer. Unlit — outputs the -// looked-up color directly. - -#import bevy_pbr::{ - mesh_functions, - view_transformations::position_world_to_clip -} - -@group(#{MATERIAL_BIND_GROUP}) @binding(0) -var particle_colors: array>; - -struct Vertex { - @builtin(instance_index) instance_index: u32, - @location(0) position: vec3, -}; - -struct VertexOutput { - @builtin(position) clip_position: vec4, - @location(0) color: vec4, -}; - -@vertex -fn vertex(vertex: Vertex) -> VertexOutput { - var out: VertexOutput; - let tag = mesh_functions::get_tag(vertex.instance_index); - let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); - let world_position = mesh_functions::mesh_position_local_to_world( - world_from_local, - vec4(vertex.position, 1.0), - ); - out.clip_position = position_world_to_clip(world_position.xyz); - out.color = particle_colors[tag]; - return out; -} - -@fragment -fn fragment(in: VertexOutput) -> @location(0) vec4 { - return in.color; -} diff --git a/crates/processing_render/src/field/kernels/mod.rs b/crates/processing_render/src/field/kernels/mod.rs index 30fdc17..b11065c 100644 --- a/crates/processing_render/src/field/kernels/mod.rs +++ b/crates/processing_render/src/field/kernels/mod.rs @@ -10,7 +10,9 @@ pub struct FieldKernelsPlugin; impl Plugin for FieldKernelsPlugin { fn build(&self, app: &mut App) { embedded_asset!(app, "noise.wgsl"); + embedded_asset!(app, "transform.wgsl"); } } pub const NOISE_PATH: &str = "embedded://processing_render/field/kernels/noise.wgsl"; +pub const TRANSFORM_PATH: &str = "embedded://processing_render/field/kernels/transform.wgsl"; diff --git a/crates/processing_render/src/field/kernels/transform.wgsl b/crates/processing_render/src/field/kernels/transform.wgsl new file mode 100644 index 0000000..25eed57 --- /dev/null +++ b/crates/processing_render/src/field/kernels/transform.wgsl @@ -0,0 +1,52 @@ +// Built-in transform kernel — applies an affine to each particle's position. +// Order: scale, then rotate around `rotation.xyz` by `rotation.w` radians, +// then translate. Defaults of zero/one behave as identity. +// +// Configure via `compute_set`: +// translate : vec4 — xyz applied last, w ignored +// rotation : vec4 — xyz axis (need not be normalized), w = angle radians +// scale : vec4 — xyz scale factor, w ignored + +struct Params { + translate: vec4, + rotation: vec4, + scale: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +// Rodrigues' rotation. `axis` must be normalized; `angle` is in radians. +fn rotate(p: vec3, axis: vec3, angle: f32) -> vec3 { + let c = cos(angle); + let s = sin(angle); + return p * c + cross(axis, p) * s + axis * dot(axis, p) * (1.0 - c); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + + var p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + + p = p * params.scale.xyz; + + let axis_len = length(params.rotation.xyz); + if axis_len > 1.0e-6 && abs(params.rotation.w) > 1.0e-8 { + p = rotate(p, params.rotation.xyz / axis_len, params.rotation.w); + } + + p = p + params.translate.xyz; + + position[i * 3u + 0u] = p.x; + position[i * 3u + 1u] = p.y; + position[i * 3u + 2u] = p.z; +} diff --git a/crates/processing_render/src/field/mod.rs b/crates/processing_render/src/field/mod.rs index 2849f74..9e4e307 100644 --- a/crates/processing_render/src/field/mod.rs +++ b/crates/processing_render/src/field/mod.rs @@ -1,10 +1,10 @@ //! GPU-resident particle / instancing container. //! -//! A [`Field`] holds a set of named [`PBuffer`](crate::compute::Buffer)s — one per registered +//! A [`Field`] holds a set of named [`compute::Buffer`]s — one per registered //! attribute. It is pure storage: it carries no instance shape and no material. The shape is //! supplied at draw time via the `field` verb, and the material is read from ambient state at //! that point. Rasterization is layered on later by spawning a transient -//! `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` entity that consumes the Field's PBuffers +//! `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` entity that consumes the Field's buffers //! through the pack pass. //! //! See `docs/field.md` for the full design. @@ -40,7 +40,7 @@ impl Plugin for FieldPlugin { /// A GPU-resident container of named per-instance attribute buffers. /// -/// `pbuffers` maps an [`Attribute`](crate::geometry::Attribute) entity to its backing +/// `buffers` maps an [`Attribute`](crate::geometry::Attribute) entity to its backing /// [`compute::Buffer`] entity. The set of registered attributes is fixed at creation. /// /// `draw_entity` is the persistent rasterization entity carrying `GpuBatchedMesh3d` and @@ -55,20 +55,20 @@ impl Plugin for FieldPlugin { #[derive(Component)] pub struct Field { pub capacity: u32, - pub pbuffers: HashMap, + pub buffers: HashMap, pub draw_entity: Option, pub emit_head: u32, } impl Field { - pub fn pbuffer(&self, attribute: Entity) -> Option { - self.pbuffers.get(&attribute).copied() + pub fn buffer(&self, attribute: Entity) -> Option { + self.buffers.get(&attribute).copied() } } /// Marker on a transient render entity indicating it rasterizes a [`Field`]. /// -/// The pack pass uses this to look up which Field's PBuffers to read when writing +/// The pack pass uses this to look up which Field's buffers to read when writing /// per-instance transforms into the upstream `mesh_input_buffer`. #[derive(Component, Clone, Copy)] pub struct FieldDraw { @@ -82,25 +82,25 @@ pub fn create( mut shader_buffers: ResMut>, render_device: Res, ) -> Result { - let mut pbuffers = HashMap::with_capacity(attribute_entities.len()); + let mut buffers = HashMap::with_capacity(attribute_entities.len()); for attr_entity in attribute_entities { let attr = attributes .get(attr_entity) .map_err(|_| ProcessingError::InvalidEntity)?; let byte_size = capacity as u64 * attr.format.byte_size() as u64; - let buffer_entity = make_pbuffer( + let buffer_entity = make_buffer( &mut commands, &mut shader_buffers, &render_device, &vec![0u8; byte_size as usize], ); - pbuffers.insert(attr_entity, buffer_entity); + buffers.insert(attr_entity, buffer_entity); } let field_entity = commands .spawn(Field { capacity, - pbuffers, + buffers, draw_entity: None, emit_head: 0, }) @@ -109,7 +109,7 @@ pub fn create( } /// Create a Field whose capacity matches the source [`Geometry`]'s vertex count -/// and whose PBuffers are pre-seeded from the geometry's mesh attributes where +/// and whose buffers are pre-seeded from the geometry's mesh attributes where /// names line up. Any registered attribute the mesh doesn't supply (or whose /// format doesn't match) gets zero-initialized — the user fills it in via /// `buffer_write` or `field_emit`. @@ -130,7 +130,7 @@ pub fn create_from_geometry( .ok_or(ProcessingError::GeometryNotFound)?; let capacity = mesh.count_vertices() as u32; - let mut pbuffers = HashMap::with_capacity(attribute_entities.len()); + let mut buffers = HashMap::with_capacity(attribute_entities.len()); for attr_entity in attribute_entities { let attr = attributes .get(attr_entity) @@ -144,14 +144,14 @@ pub fn create_from_geometry( .unwrap_or_else(|| vec![0u8; byte_size as usize]); let buffer_entity = - make_pbuffer(&mut commands, &mut shader_buffers, &render_device, &initial); - pbuffers.insert(attr_entity, buffer_entity); + make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); + buffers.insert(attr_entity, buffer_entity); } let field_entity = commands .spawn(Field { capacity, - pbuffers, + buffers, draw_entity: None, emit_head: 0, }) @@ -159,7 +159,7 @@ pub fn create_from_geometry( Ok(field_entity) } -fn make_pbuffer( +fn make_buffer( commands: &mut Commands, shader_buffers: &mut Assets, render_device: &RenderDevice, @@ -168,7 +168,7 @@ fn make_pbuffer( let byte_size = initial.len() as u64; let handle = shader_buffers.add(ShaderBuffer::new(initial, RenderAssetUsages::all())); let readback = render_device.create_buffer(&BufferDescriptor { - label: Some("Field PBuffer Readback"), + label: Some("Field Buffer Readback"), size: byte_size, usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, mapped_at_creation: false, @@ -219,7 +219,7 @@ pub fn destroy( let field = fields .get(entity) .map_err(|_| ProcessingError::FieldNotFound)?; - for &buffer_entity in field.pbuffers.values() { + for &buffer_entity in field.buffers.values() { commands.entity(buffer_entity).despawn(); } if let Some(draw_entity) = field.draw_entity { diff --git a/crates/processing_render/src/field/pack.rs b/crates/processing_render/src/field/pack.rs index 15ba29e..3e8acc7 100644 --- a/crates/processing_render/src/field/pack.rs +++ b/crates/processing_render/src/field/pack.rs @@ -1,4 +1,4 @@ -//! Pack pass — bridges a [`Field`]'s `position` / `rotation` / `scale` PBuffers into the +//! Pack pass — bridges a [`Field`]'s `position` / `rotation` / `scale` buffers into the //! upstream `mesh_input_buffer[base..base+capacity].world_from_local` slots reserved by the //! entity's [`GpuBatchedMesh3d`]. //! @@ -231,22 +231,22 @@ fn extract_field_draws( let Ok(field) = fields.get(field_draw.field) else { continue; }; - let Some(pos_pbuf) = field.pbuffer(builtins.position) else { + let Some(pos_entity) = field.buffer(builtins.position) else { continue; }; - let Ok(pos_buf) = buffers.get(pos_pbuf) else { + let Ok(pos_buf) = buffers.get(pos_entity) else { continue; }; let rotation = field - .pbuffer(builtins.rotation) + .buffer(builtins.rotation) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); let scale = field - .pbuffer(builtins.scale) + .buffer(builtins.scale) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); let dead = field - .pbuffer(builtins.dead) + .buffer(builtins.dead) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); diff --git a/crates/processing_render/src/field/pack.wgsl b/crates/processing_render/src/field/pack.wgsl index 8c07811..a84eb89 100644 --- a/crates/processing_render/src/field/pack.wgsl +++ b/crates/processing_render/src/field/pack.wgsl @@ -1,13 +1,13 @@ -// Pack pass — bridges libprocessing Field PBuffers into the upstream +// Pack pass — bridges libprocessing Field buffers into the upstream // per-instance MeshInputUniform / MeshCullingData slots reserved by // `GpuBatchedMesh3d`. // // Specialized via shader_defs: -// HAS_ROTATION — bind a `rotation` PBuffer (Float4 quaternion `xyzw`) -// HAS_SCALE — bind a `scale` PBuffer (Float3) -// HAS_DEAD — bind a `dead` PBuffer (Float, 0 = alive, non-zero = dead) +// HAS_ROTATION — bind a `rotation` buffer (Float4 quaternion `xyzw`) +// HAS_SCALE — bind a `scale` buffer (Float3) +// HAS_DEAD — bind a `dead` buffer (Float, 0 = alive, non-zero = dead) // -// PBuffer formats (CPU-tightly-packed): +// Buffer formats (CPU-tightly-packed): // position : 12 bytes per particle (Float3) // rotation : 16 bytes per particle (Float4 quat) // scale : 12 bytes per particle (Float3) diff --git a/crates/processing_render/src/geometry/mod.rs b/crates/processing_render/src/geometry/mod.rs index 4393bcd..87aaee1 100644 --- a/crates/processing_render/src/geometry/mod.rs +++ b/crates/processing_render/src/geometry/mod.rs @@ -16,7 +16,7 @@ use bevy::{ render::render_resource::PrimitiveTopology, }; -use crate::render::primitive::{box_mesh, sphere_mesh}; +use crate::render::primitive::{box_mesh, grid_mesh, sphere_mesh}; use processing_core::error::{ProcessingError, Result}; pub struct GeometryPlugin; @@ -193,6 +193,25 @@ pub fn create_sphere( commands.spawn(Geometry::new(handle, layout_entity)).id() } +pub fn create_grid( + In((nx, ny, nz, spacing)): In<(u32, u32, u32, f32)>, + mut commands: Commands, + mut meshes: ResMut>, + builtins: Res, +) -> Entity { + let handle = meshes.add(grid_mesh(nx, ny, nz, spacing)); + + let layout_entity = commands + .spawn(VertexLayout::with_attributes(vec![ + builtins.position, + builtins.color, + builtins.uv, + ])) + .id(); + + commands.spawn(Geometry::new(handle, layout_entity)).id() +} + pub fn normal(world: &mut World, entity: Entity, normal: Vec3) -> Result<()> { let mut geometry = world .get_mut::(entity) diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 88f09f3..de0eae7 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1396,6 +1396,19 @@ pub fn geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> error::Result< }) } +/// 3D lattice of `nx * ny * nz` points centered at the origin, with `spacing` +/// units between adjacent points. Topology is `PointList` — typically used as a +/// position source for [`field_create_from_geometry`] rather than rasterized +/// directly. +pub fn geometry_grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::create_grid, (nx, ny, nz, spacing)) + .unwrap()) + }) +} + pub fn poll_for_sketch_updates() -> error::Result> { app_mut(|app| { Ok(app @@ -1450,7 +1463,7 @@ pub fn material_create_pbr() -> error::Result { } /// Create an unlit material that reads each particle's color from the given -/// PBuffer. The buffer is expected to hold tightly-packed `Float4` colors +/// buffer. The buffer is expected to hold tightly-packed `Float4` colors /// (RGBA, 16 bytes per particle). pub fn material_create_field_color(color_buffer_entity: Entity) -> error::Result { use crate::field::material::FieldColorMaterial; @@ -1952,7 +1965,7 @@ pub fn field_create(capacity: u32, attribute_entities: Vec) -> error::Re } /// Create a Field whose capacity matches `geometry`'s vertex count and whose -/// PBuffers are pre-seeded from the geometry's mesh attributes when names line +/// buffers are pre-seeded from the geometry's mesh attributes when names line /// up (`position`, `normal`, `color`, `uv`). Custom attributes the mesh doesn't /// supply are zero-initialized — the user fills them via `buffer_write` or /// `field_emit`. @@ -1988,19 +2001,19 @@ pub fn field_capacity(entity: Entity) -> error::Result { }) } -pub fn field_pbuffer(entity: Entity, attribute_entity: Entity) -> error::Result> { +pub fn field_buffer(entity: Entity, attribute_entity: Entity) -> error::Result> { app_mut(|app| { Ok(app .world() .get::(entity) .ok_or(error::ProcessingError::FieldNotFound)? - .pbuffer(attribute_entity)) + .buffer(attribute_entity)) }) } /// GPU-side emission. Dispatches `compute_entity` over `count` invocations to /// initialize the next `count` ring-buffer slots. The framework auto-binds the -/// field's PBuffers (same convention as `field_apply`) and sets a `vec4` +/// field's buffers (same convention as `field_apply`) and sets a `vec4` /// uniform named `emit_range` to `(base_slot, count, capacity, 0.0)` — the /// kernel reads it to compute its target slot: /// @@ -2025,7 +2038,7 @@ pub fn field_emit_gpu( } const WORKGROUP_SIZE: u32 = 64; - let (capacity, head, pbuffers) = app_mut(|app| { + let (capacity, head, buffers) = app_mut(|app| { let world = app.world(); let field = world .get::(field_entity) @@ -2036,21 +2049,21 @@ pub fn field_emit_gpu( count, field.capacity ))); } - let mut pbuffers: Vec<(String, Entity)> = Vec::with_capacity(field.pbuffers.len()); - for (&attr_entity, &pbuf_entity) in &field.pbuffers { + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { let attr = world .get::(attr_entity) .ok_or(error::ProcessingError::InvalidEntity)?; - pbuffers.push((attr.name.to_string(), pbuf_entity)); + buffers.push((attr.name.to_string(), buf_entity)); } - Ok((field.capacity, field.emit_head, pbuffers)) + Ok((field.capacity, field.emit_head, buffers)) })?; - for (name, pbuf_entity) in pbuffers { + for (name, buf_entity) in buffers { match compute_set( compute_entity, name, - shader_value::ShaderValue::Buffer(pbuf_entity), + shader_value::ShaderValue::Buffer(buf_entity), ) { Ok(()) => {} Err(error::ProcessingError::UnknownShaderProperty(_)) => {} @@ -2110,18 +2123,18 @@ pub fn field_emit( let attr = world .get::(*attr_entity) .ok_or(error::ProcessingError::InvalidEntity)?; - let pbuf = field.pbuffer(*attr_entity).ok_or_else(|| { + let buf = field.buffer(*attr_entity).ok_or_else(|| { error::ProcessingError::InvalidArgument(format!( - "field has no PBuffer for attribute {:?}", + "field has no buffer for attribute {:?}", attr_entity )) })?; - specs.push((*attr_entity, attr.format.byte_size() as u32, pbuf)); + specs.push((*attr_entity, attr.format.byte_size() as u32, buf)); } Ok((field.capacity, field.emit_head, specs)) })?; - for ((_, bytes), &(_, byte_size, pbuf)) in attribute_data.iter().zip(attr_specs.iter()) { + for ((_, bytes), &(_, byte_size, buf)) in attribute_data.iter().zip(attr_specs.iter()) { let expected = (n as usize) * (byte_size as usize); if bytes.len() != expected { return Err(error::ProcessingError::InvalidArgument(format!( @@ -2135,9 +2148,9 @@ pub fn field_emit( let first_chunk_n = (capacity - head).min(n); let split = (first_chunk_n as usize) * (byte_size as usize); let first_offset = (head as u64) * (byte_size as u64); - buffer_write_element(pbuf, first_offset, bytes[..split].to_vec())?; + buffer_write_element(buf, first_offset, bytes[..split].to_vec())?; if first_chunk_n < n { - buffer_write_element(pbuf, 0, bytes[split..].to_vec())?; + buffer_write_element(buf, 0, bytes[split..].to_vec())?; } } @@ -2159,35 +2172,54 @@ pub fn field_kernel_noise() -> error::Result { compute_create(shader) } -/// Dispatch a compute pass against a Field's PBuffers. Each PBuffer is bound +/// Built-in transform kernel — applies an affine to each particle's `position` +/// in scale → axis-angle rotation → translate order. Configure via: +/// `compute_set("translate", Float4([tx, ty, tz, 0.0]))`, +/// `compute_set("rotation", Float4([ax, ay, az, angle_radians]))` (xyz = axis, +/// w = angle), `compute_set("scale", Float4([sx, sy, sz, 0.0]))`. Identity +/// defaults are seeded at creation time, so any unset parameter is a no-op +/// (rather than zeroing out positions). +pub fn field_kernel_transform() -> error::Result { + let shader = shader_load(field::kernels::TRANSFORM_PATH)?; + let entity = compute_create(shader)?; + // The uniform struct is zero-initialized by default. Without these, + // an unset `scale` would multiply every position by zero on the first + // dispatch and collapse the whole field to the origin. + compute_set(entity, "translate", shader_value::ShaderValue::Float4([0.0; 4]))?; + compute_set(entity, "rotation", shader_value::ShaderValue::Float4([0.0, 1.0, 0.0, 0.0]))?; + compute_set(entity, "scale", shader_value::ShaderValue::Float4([1.0, 1.0, 1.0, 0.0]))?; + Ok(entity) +} + +/// Dispatch a compute pass against a Field's buffers. Each buffer is bound /// by its attribute's name; bindings the shader doesn't declare are skipped. /// Workgroup size is fixed at 64 — the shader must declare `@workgroup_size(64)`. /// -/// Any non-PBuffer parameters (uniforms, etc.) on the compute should be set via +/// Any non-buffer parameters (uniforms, etc.) on the compute should be set via /// `compute_set` before calling this. pub fn field_apply(field_entity: Entity, compute_entity: Entity) -> error::Result<()> { const WORKGROUP_SIZE: u32 = 64; - let (capacity, pbuffers) = app_mut(|app| { + let (capacity, buffers) = app_mut(|app| { let world = app.world(); let field = world .get::(field_entity) .ok_or(error::ProcessingError::FieldNotFound)?; - let mut pbuffers: Vec<(String, Entity)> = Vec::with_capacity(field.pbuffers.len()); - for (&attr_entity, &pbuf_entity) in &field.pbuffers { + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { let attr = world .get::(attr_entity) .ok_or(error::ProcessingError::InvalidEntity)?; - pbuffers.push((attr.name.to_string(), pbuf_entity)); + buffers.push((attr.name.to_string(), buf_entity)); } - Ok((field.capacity, pbuffers)) + Ok((field.capacity, buffers)) })?; - for (name, pbuf_entity) in pbuffers { + for (name, buf_entity) in buffers { match compute_set( compute_entity, name, - shader_value::ShaderValue::Buffer(pbuf_entity), + shader_value::ShaderValue::Buffer(buf_entity), ) { Ok(()) => {} Err(error::ProcessingError::UnknownShaderProperty(_)) => {} diff --git a/crates/processing_render/src/render/primitive/mod.rs b/crates/processing_render/src/render/primitive/mod.rs index 8a3649f..0695a63 100644 --- a/crates/processing_render/src/render/primitive/mod.rs +++ b/crates/processing_render/src/render/primitive/mod.rs @@ -30,8 +30,8 @@ pub use shape::{ build_polygon_stroke, }; pub use shape3d::{ - box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, plane_mesh, - sphere_mesh, tetrahedron_mesh, torus_mesh, + box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, grid_mesh, + plane_mesh, sphere_mesh, tetrahedron_mesh, torus_mesh, }; pub use triangle::triangle; diff --git a/crates/processing_render/src/render/primitive/shape3d.rs b/crates/processing_render/src/render/primitive/shape3d.rs index ac059a7..1f93da2 100644 --- a/crates/processing_render/src/render/primitive/shape3d.rs +++ b/crates/processing_render/src/render/primitive/shape3d.rs @@ -1,3 +1,5 @@ +use bevy::asset::RenderAssetUsages; +use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use bevy::render::mesh::VertexAttributeValues; @@ -89,6 +91,41 @@ pub fn tetrahedron_mesh(radius: f32) -> Mesh { mesh } +/// 3D lattice of `nx * ny * nz` points centered at the origin, with `spacing` +/// units between adjacent points along each axis. Topology is `PointList`; +/// the mesh is meant primarily as a position source for `field_create_from_geometry`, +/// not for rasterization. UVs are normalized lattice coordinates `(x/(nx-1), y/(ny-1))`. +pub fn grid_mesh(nx: u32, ny: u32, nz: u32, spacing: f32) -> Mesh { + let count = (nx as usize) * (ny as usize) * (nz as usize); + let mut positions = Vec::with_capacity(count); + let mut uvs = Vec::with_capacity(count); + + let half_x = (nx as f32 - 1.0) * 0.5 * spacing; + let half_y = (ny as f32 - 1.0) * 0.5 * spacing; + let half_z = (nz as f32 - 1.0) * 0.5 * spacing; + let inv_x = if nx > 1 { 1.0 / (nx as f32 - 1.0) } else { 0.0 }; + let inv_y = if ny > 1 { 1.0 / (ny as f32 - 1.0) } else { 0.0 }; + + for ix in 0..nx { + for iy in 0..ny { + for iz in 0..nz { + positions.push([ + ix as f32 * spacing - half_x, + iy as f32 * spacing - half_y, + iz as f32 * spacing - half_z, + ]); + uvs.push([ix as f32 * inv_x, iy as f32 * inv_y]); + } + } + } + + let mut mesh = Mesh::new(PrimitiveTopology::PointList, RenderAssetUsages::all()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + ensure_vertex_colors(&mut mesh); + mesh +} + pub fn plane_mesh(width: f32, height: f32) -> Mesh { let plane = bevy::math::primitives::Plane3d::default(); let mut mesh = plane.mesh().size(width, height).build(); diff --git a/docs/field.md b/docs/field.md index e2f4ccc..5052e5e 100644 --- a/docs/field.md +++ b/docs/field.md @@ -9,7 +9,7 @@ The implementation rests on two existing libprocessing systems and one upstream - **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU storage buffers with CPU-side write, GPU readback, compute dispatch, and a Python wrapper that - tracks element type for validation. This is what backs every PBuffer. + tracks element type for validation. This is what backs every Field attribute buffer. - **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — named typed attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) shared between Geometries (per-vertex) and Fields (per-instance). `BuiltinAttributes` exposes @@ -24,11 +24,11 @@ The implementation rests on two existing libprocessing systems and one upstream ### Field -The top-level container. Holds a set of named PBuffers (one per registered attribute), an -optional persistent rasterization entity, a ring-buffer emit cursor, and per-Field render -state. Does not carry geometry — that's supplied at draw time. +The top-level container. Holds a set of named attribute buffers (one per registered +attribute), an optional persistent rasterization entity, a ring-buffer emit cursor, and +per-Field render state. Does not carry geometry — that's supplied at draw time. -### PBuffer +### Attribute buffer A single typed GPU storage buffer holding the values for one attribute across all elements. Backed by `compute::Buffer`. Indexed by particle slot. @@ -55,7 +55,7 @@ let velocity = geometry_attribute_create("velocity", AttributeFormat::Float3)?; let f = field_create(10_000, vec![position, velocity])?; ``` -Allocates one zero-initialized PBuffer per requested attribute, sized by `capacity * +Allocates one zero-initialized buffer per requested attribute, sized by `capacity * attr.format.byte_size()`. ### Mesh-seeded Field @@ -81,7 +81,7 @@ mesh attribute when names + formats line up: Field-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero-init (meshes don't carry them). -## Apply (PBuffer-only compute) +## Apply (attribute-buffer-only compute) ```rust let shader = shader_create(SPIN_WGSL)?; @@ -90,13 +90,13 @@ compute_set(spin, "dt", ShaderValue::Float(0.016))?; field_apply(field, spin)?; ``` -`field_apply` iterates the field's PBuffers and calls `compute_set(compute, attr.name, -ShaderValue::Buffer(pbuf_entity))` for each. Unknown shader properties are silently -skipped, so the kernel only declares the attributes it needs. Workgroup size is fixed at -64 — kernels must declare `@workgroup_size(64)`. +`field_apply` iterates the field's attribute buffers and calls `compute_set(compute, +attr.name, ShaderValue::Buffer(buf_entity))` for each. Unknown shader properties are +silently skipped, so the kernel only declares the attributes it needs. Workgroup size is +fixed at 64 — kernels must declare `@workgroup_size(64)`. -The kernel's bind group only ever contains the field's PBuffers + uniforms. The kernel -never touches upstream input/culling buffers — that's the pack pass's job. +The kernel's bind group only ever contains the field's attribute buffers + uniforms. The +kernel never touches upstream input/culling buffers — that's the pack pass's job. In `setup()` apply runs once; in `draw()` it runs every frame. The retained-vs-dynamic distinction is purely about placement. @@ -107,19 +107,19 @@ The pack pass is the only code that bridges to the upstream batch infrastructure as standard render-schedule systems: - **`extract_field_draws`** (`ExtractSchedule`) — reads `FieldDraw` markers from main - world, copies (Field, position/rotation/scale/dead PBuffer handles) into render world. + world, copies (Field, position/rotation/scale/dead buffer handles) into render world. - **`prepare_pack_bind_groups`** (`RenderSystems::PrepareBindGroups`) — looks up or creates the pack pipeline for the field's specialization key, builds a bind group with - the field's PBuffers + the upstream input/culling buffers + a uniform with `(base_index, + the field's buffers + the upstream input/culling buffers + a uniform with `(base_index, count)`. - **`dispatch_pack`** (`Core3d`, `before(early_gpu_preprocess)`) — dispatches the compute pass. The pack shader (`field/pack.wgsl`) is specialized via shader_defs: -- `HAS_ROTATION` — bind a `rotation` PBuffer (Float4 quat). Otherwise identity. -- `HAS_SCALE` — bind a `scale` PBuffer (Float3). Otherwise unit. -- `HAS_DEAD` — bind a `dead` PBuffer (Float). Otherwise alive. +- `HAS_ROTATION` — bind a `rotation` buffer (Float4 quat). Otherwise identity. +- `HAS_SCALE` — bind a `scale` buffer (Float3). Otherwise unit. +- `HAS_DEAD` — bind a `dead` buffer (Float). Otherwise alive. For each particle slot the pack writes: @@ -127,7 +127,7 @@ For each particle slot the pack writes: position translation. - `mesh_input_buffer[base+i].tag = i` — slot index, available to material shaders via `mesh_functions::get_tag(instance_index)`. -- `MeshCullingData[base+i].dead` — from the dead PBuffer if present, else 0. +- `MeshCullingData[base+i].dead` — from the dead buffer if present, else 0. Pipelines are cached per `PackPipelineKey { has_rotation, has_scale, has_dead }`. diff --git a/examples/field_animated.rs b/examples/field_animated.rs index 4181d71..bc7cc26 100644 --- a/examples/field_animated.rs +++ b/examples/field_animated.rs @@ -64,7 +64,7 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let field = field_create(capacity, vec![position_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write(position_buf, bytes)?; diff --git a/examples/field_basic.rs b/examples/field_basic.rs index 9cd365f..25e1c71 100644 --- a/examples/field_basic.rs +++ b/examples/field_basic.rs @@ -41,7 +41,7 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let field = field_create(capacity, vec![position_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write(position_buf, bytes)?; diff --git a/examples/field_colored.rs b/examples/field_colored.rs index 65d97ee..4b40011 100644 --- a/examples/field_colored.rs +++ b/examples/field_colored.rs @@ -43,9 +43,9 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write( position_buf, diff --git a/examples/field_colored_pbr.rs b/examples/field_colored_pbr.rs index 1100c46..1246527 100644 --- a/examples/field_colored_pbr.rs +++ b/examples/field_colored_pbr.rs @@ -46,9 +46,9 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write( position_buf, diff --git a/examples/field_emit.rs b/examples/field_emit.rs index dff6623..dd3e951 100644 --- a/examples/field_emit.rs +++ b/examples/field_emit.rs @@ -26,9 +26,9 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; // Push unemitted slots far off-screen so they don't all render at the diff --git a/examples/field_emit_gpu.rs b/examples/field_emit_gpu.rs index 5c8edc6..19d39c0 100644 --- a/examples/field_emit_gpu.rs +++ b/examples/field_emit_gpu.rs @@ -157,14 +157,14 @@ fn sketch() -> error::Result<()> { )?; // Mark all unemitted slots dead so they don't render at origin. - let dead_buf = field_pbuffer(field, dead_attr)? + let dead_buf = field_buffer(field, dead_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; let init_dead: Vec = (0..capacity) .flat_map(|_| 1.0_f32.to_le_bytes()) .collect(); buffer_write(dead_buf, init_dead)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; let mat = material_create_field_pbr(color_buf)?; diff --git a/examples/field_from_mesh.rs b/examples/field_from_mesh.rs index 28bff34..17c4bdc 100644 --- a/examples/field_from_mesh.rs +++ b/examples/field_from_mesh.rs @@ -35,11 +35,11 @@ fn sketch() -> error::Result<()> { // empty and we fill it from uv values. let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; let uv_buf = - field_pbuffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + field_buffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; let color_buf = - field_pbuffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + field_buffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; - // Read uvs back, build per-particle colors from them, write to color PBuffer. + // Read uvs back, build per-particle colors from them, write to color buffer. let uv_bytes = buffer_read(uv_buf)?; let mut colors: Vec = Vec::with_capacity(uv_bytes.len() * 2); for chunk in uv_bytes.chunks_exact(8) { diff --git a/examples/field_lifecycle.rs b/examples/field_lifecycle.rs index 13e8c09..cf24a5f 100644 --- a/examples/field_lifecycle.rs +++ b/examples/field_lifecycle.rs @@ -78,9 +78,9 @@ fn sketch() -> error::Result<()> { age_attr, ], )?; - let dead_buf = field_pbuffer(field, dead_attr)? + let dead_buf = field_buffer(field, dead_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; // Mark all slots dead initially so the unemitted ring slots don't render. diff --git a/examples/field_noise.rs b/examples/field_noise.rs index 2e5a7d8..7bc8cb1 100644 --- a/examples/field_noise.rs +++ b/examples/field_noise.rs @@ -33,9 +33,9 @@ fn sketch() -> error::Result<()> { let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; let uv_buf = - field_pbuffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + field_buffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; let color_buf = - field_pbuffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + field_buffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; // Color each particle by hue from its U coord. let uv_bytes = buffer_read(uv_buf)?; diff --git a/examples/field_oriented.rs b/examples/field_oriented.rs index 054d562..79f8d97 100644 --- a/examples/field_oriented.rs +++ b/examples/field_oriented.rs @@ -88,11 +88,11 @@ fn sketch() -> error::Result<()> { let rotation_attr = geometry_attribute_rotation(); let scale_attr = geometry_attribute_scale(); let field = field_create(capacity, vec![position_attr, rotation_attr, scale_attr])?; - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let rotation_buf = field_pbuffer(field, rotation_attr)? + let rotation_buf = field_buffer(field, rotation_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let scale_buf = field_pbuffer(field, scale_attr)? + let scale_buf = field_buffer(field, scale_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write( position_buf, diff --git a/examples/field_stress.rs b/examples/field_stress.rs index f085b8c..e048ca5 100644 --- a/examples/field_stress.rs +++ b/examples/field_stress.rs @@ -102,9 +102,9 @@ fn sketch() -> error::Result<()> { colors.push(rz); colors.push(1.0); } - let position_buf = field_pbuffer(field, position_attr)? + let position_buf = field_buffer(field, position_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_pbuffer(field, color_attr)? + let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; buffer_write( position_buf, From 4edd9cf739e1417de600bdf2016a38414f65b5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 1 May 2026 17:25:19 -0700 Subject: [PATCH 09/11] . --- .../processing_pyo3/examples/field_basic.py | 2 +- crates/processing_pyo3/examples/field_emit.py | 2 +- .../examples/field_emit_gpu.py | 2 +- .../examples/field_from_mesh.py | 2 +- .../examples/field_lifecycle.py | 2 +- .../processing_pyo3/examples/field_noise.py | 2 +- .../processing_pyo3/examples/field_stress.py | 2 +- crates/processing_pyo3/src/material.rs | 102 +++++++---- .../processing_render/src/field/material.rs | 89 +++------- crates/processing_render/src/field/mod.rs | 2 +- crates/processing_render/src/lib.rs | 161 ++++++++++++++---- crates/processing_render/src/material/mod.rs | 12 ++ examples/field_colored.rs | 2 +- examples/field_colored_pbr.rs | 2 +- examples/field_emit.rs | 2 +- examples/field_emit_gpu.rs | 2 +- examples/field_from_mesh.rs | 2 +- examples/field_lifecycle.rs | 2 +- examples/field_noise.rs | 2 +- examples/field_stress.rs | 2 +- 20 files changed, 253 insertions(+), 143 deletions(-) diff --git a/crates/processing_pyo3/examples/field_basic.py b/crates/processing_pyo3/examples/field_basic.py index 3a975d6..e8e4687 100644 --- a/crates/processing_pyo3/examples/field_basic.py +++ b/crates/processing_pyo3/examples/field_basic.py @@ -31,7 +31,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.field_pbr(color_buf) + mat = Material.pbr(albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/field_emit.py b/crates/processing_pyo3/examples/field_emit.py index 3b6d8fe..4f37e9c 100644 --- a/crates/processing_pyo3/examples/field_emit.py +++ b/crates/processing_pyo3/examples/field_emit.py @@ -27,7 +27,7 @@ def setup(): pos_buf.write([1.0e6] * (capacity * 3)) color_buf = field_obj.buffer(Attribute.color()) - mat = Material.field_color(color_buf) + mat = Material.unlit(albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/field_emit_gpu.py b/crates/processing_pyo3/examples/field_emit_gpu.py index 59ef1b0..7c0706c 100644 --- a/crates/processing_pyo3/examples/field_emit_gpu.py +++ b/crates/processing_pyo3/examples/field_emit_gpu.py @@ -153,7 +153,7 @@ def setup(): dead_buf.write([1.0] * CAPACITY) color_buf = field_obj.buffer(Attribute.color()) - mat = Material.field_pbr(color_buf) + mat = Material.pbr(albedo=color_buf) spawn = Compute(Shader(SPAWN_SHADER)) motion = Compute(Shader(MOTION_SHADER)) diff --git a/crates/processing_pyo3/examples/field_from_mesh.py b/crates/processing_pyo3/examples/field_from_mesh.py index 43642a2..4307a4d 100644 --- a/crates/processing_pyo3/examples/field_from_mesh.py +++ b/crates/processing_pyo3/examples/field_from_mesh.py @@ -31,7 +31,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.field_pbr(color_buf) + mat = Material.pbr(albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/field_lifecycle.py b/crates/processing_pyo3/examples/field_lifecycle.py index b36a7eb..7adf6d2 100644 --- a/crates/processing_pyo3/examples/field_lifecycle.py +++ b/crates/processing_pyo3/examples/field_lifecycle.py @@ -79,7 +79,7 @@ def setup(): dead_buf.write([1.0] * capacity) color_buf = field_obj.buffer(color_attr) - mat = Material.field_color(color_buf) + mat = Material.unlit(albedo=color_buf) aging = Compute(Shader(AGING_SHADER)) diff --git a/crates/processing_pyo3/examples/field_noise.py b/crates/processing_pyo3/examples/field_noise.py index 0f71411..274bc97 100644 --- a/crates/processing_pyo3/examples/field_noise.py +++ b/crates/processing_pyo3/examples/field_noise.py @@ -32,7 +32,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.field_pbr(color_buf) + mat = Material.pbr(albedo=color_buf) noise = kernel_noise() diff --git a/crates/processing_pyo3/examples/field_stress.py b/crates/processing_pyo3/examples/field_stress.py index c865e05..f6f8f1a 100644 --- a/crates/processing_pyo3/examples/field_stress.py +++ b/crates/processing_pyo3/examples/field_stress.py @@ -55,7 +55,7 @@ def setup(): colors.append([c.r, c.g, c.b, 1.0]) color_buf.write(colors) - mat = Material.field_pbr(color_buf) + mat = Material.pbr(albedo=color_buf) cube = Geometry.box(0.35, 0.35, 0.35) diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 2211514..5da23ff 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -3,6 +3,7 @@ use processing::prelude::*; use pyo3::types::PyDict; use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use crate::color::PyColor; use crate::compute::Buffer; use crate::math::{PyVec2, PyVec3, PyVec4}; use crate::shader::Shader; @@ -52,8 +53,51 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult) -> PyResult<()> { + if let Ok(buf) = value.extract::>() { + return material_set_albedo_buffer(entity, buf.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(c) = value.extract::>() { + let srgba: bevy::color::Srgba = c.0.into(); + return material_set_albedo_color(entity, [srgba.red, srgba.green, srgba.blue, srgba.alpha]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(rgba) = value.extract::<[f32; 4]>() { + return material_set_albedo_color(entity, rgba) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(rgb) = value.extract::<[f32; 3]>() { + return material_set_albedo_color(entity, [rgb[0], rgb[1], rgb[2], 1.0]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + Err(PyRuntimeError::new_err(format!( + "unsupported albedo type: {} (expected Color, Buffer, or [r,g,b,(a)])", + value.get_type().name()? + ))) +} + +fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + if name == "albedo" { + apply_albedo(entity, &value)?; + continue; + } + let v = py_to_shader_value(&value)?; + material_set(entity, &name, v).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) +} + #[pymethods] impl Material { + /// Construct a material. With no args, returns a default PBR. With a + /// `shader` arg, returns a custom material. Any kwargs (`albedo=...`, + /// `roughness=...`, etc.) are applied after construction. #[new] #[pyo3(signature = (shader=None, **kwargs))] pub fn new(shader: Option<&Shader>, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { @@ -64,48 +108,48 @@ impl Material { material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))? }; - let mat = Self { entity }; if let Some(kwargs) = kwargs { - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let value = py_to_shader_value(&value)?; - material_set(mat.entity, &name, value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - } + apply_kwargs(entity, kwargs)?; } - Ok(mat) + Ok(Self { entity }) } + /// PBR-lit material. `albedo` accepts a `Color` (solid) or a `Buffer` + /// (per-particle, indexed by per-instance tag — used with `Field`s). + #[staticmethod] #[pyo3(signature = (**kwargs))] - pub fn set(&self, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { - let Some(kwargs) = kwargs else { - return Ok(()); - }; - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let value = py_to_shader_value(&value)?; - material_set(self.entity, &name, value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + pub fn pbr(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; } - Ok(()) + Ok(Self { entity }) } - /// Unlit per-particle color material. Each particle samples its color from - /// the given buffer indexed by per-instance tag. + /// Unlit material — same shape as `pbr` but skips lighting calculations + /// (the per-particle / solid color is the final output). #[staticmethod] - pub fn field_color(buffer: &Buffer) -> PyResult { - let entity = material_create_field_color(buffer.entity) + #[pyo3(signature = (**kwargs))] + pub fn unlit(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + material_set(entity, "unlit", shader_value::ShaderValue::Float(1.0)) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; + } Ok(Self { entity }) } - /// PBR-lit per-particle color material. Same tag-indexed lookup as - /// `field_color`, but composed with `StandardMaterial` for proper lighting. - #[staticmethod] - pub fn field_pbr(buffer: &Buffer) -> PyResult { - let entity = material_create_field_pbr(buffer.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Self { entity }) + /// Patch one or more material properties. `albedo` is special-cased and + /// may swap the backing asset type between solid-color and buffer-color + /// variants — all other `StandardMaterial` state (roughness, metallic, + /// emissive, alpha_mode, unlit, etc.) is preserved across the swap. + #[pyo3(signature = (**kwargs))] + pub fn set(&self, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return Ok(()); + }; + apply_kwargs(self.entity, kwargs) } } diff --git a/crates/processing_render/src/field/material.rs b/crates/processing_render/src/field/material.rs index 3397a8b..a00646a 100644 --- a/crates/processing_render/src/field/material.rs +++ b/crates/processing_render/src/field/material.rs @@ -1,5 +1,10 @@ -//! `FieldColorMaterial` — unlit material that reads a per-particle color from a -//! storage buffer indexed by the per-instance tag (set to slot index by the pack pass). +//! `FieldMaterial` — `ExtendedMaterial` whose +//! per-particle color comes from a storage buffer indexed by the per-instance +//! tag (set to slot index by the pack pass). +//! +//! Lit vs unlit is just the `unlit` flag on the base `StandardMaterial`; +//! `apply_pbr_lighting` short-circuits to `base_color * particle_colors[tag]` +//! when `unlit = true`, so a single extension serves both cases. use std::ops::Deref; @@ -11,92 +16,50 @@ use bevy::shader::ShaderRef; use crate::render::material::UntypedMaterial; -pub struct FieldColorMaterialPlugin; +pub struct FieldMaterialPlugin; -impl Plugin for FieldColorMaterialPlugin { +impl Plugin for FieldMaterialPlugin { fn build(&self, app: &mut App) { - embedded_asset!(app, "field_color.wgsl"); - embedded_asset!(app, "field_pbr.wgsl"); - app.add_plugins(MaterialPlugin::::default()); - app.add_plugins(MaterialPlugin::::default()); + embedded_asset!(app, "field.wgsl"); + app.add_plugins(MaterialPlugin::::default()); } } -#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] -pub struct FieldColorMaterial { - #[storage(0, read_only)] - pub colors: Handle, -} - -impl Material for FieldColorMaterial { - fn vertex_shader() -> ShaderRef { - "embedded://processing_render/field/field_color.wgsl".into() - } - - fn fragment_shader() -> ShaderRef { - "embedded://processing_render/field/field_color.wgsl".into() - } -} - -#[derive(Component, Clone)] -pub struct FieldColorMaterial3d(pub Handle); - -impl bevy::asset::AsAssetId for FieldColorMaterial3d { - type Asset = FieldColorMaterial; - fn as_asset_id(&self) -> AssetId { - self.0.id() - } -} - -/// Sibling to `add_processing_materials` / `add_custom_materials`. Promotes -/// `UntypedMaterial(handle)` entities whose handle is a [`FieldColorMaterial`] -/// to having the typed `MeshMaterial3d` component required -/// by the render pipeline. -pub fn add_field_color_materials( - mut commands: Commands, - meshes: Query<(Entity, &UntypedMaterial)>, -) { - for (entity, handle) in meshes.iter() { - let handle = handle.deref().clone(); - if let Ok(handle) = handle.try_typed::() { - commands - .entity(entity) - .insert(MeshMaterial3d::(handle)); - } - } -} - -/// PBR-lit per-particle color material. Wraps `StandardMaterial` via -/// `ExtendedMaterial` so the user gets standard PBR lighting behavior on top -/// of per-particle albedo from a storage buffer. -pub type FieldPbrMaterial = ExtendedMaterial; +/// PBR material extended with a per-particle color buffer. Set the base +/// `StandardMaterial`'s `unlit` flag to switch between lit and unlit behavior; +/// the rest of the material works identically either way. +pub type FieldMaterial = ExtendedMaterial; #[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] -pub struct FieldPbrExtension { +pub struct FieldExtension { #[storage(100, read_only)] pub colors: Handle, } -impl MaterialExtension for FieldPbrExtension { +impl MaterialExtension for FieldExtension { fn fragment_shader() -> ShaderRef { - "embedded://processing_render/field/field_pbr.wgsl".into() + "embedded://processing_render/field/field.wgsl".into() } fn deferred_fragment_shader() -> ShaderRef { - "embedded://processing_render/field/field_pbr.wgsl".into() + "embedded://processing_render/field/field.wgsl".into() } } -pub fn add_field_pbr_materials( +/// Sibling of `add_processing_materials` / `add_custom_materials`. Promotes +/// `UntypedMaterial(handle)` entities whose handle is a [`FieldMaterial`] +/// to having the typed `MeshMaterial3d` component required +/// by the render pipeline. +pub fn add_field_materials( mut commands: Commands, meshes: Query<(Entity, &UntypedMaterial)>, ) { for (entity, handle) in meshes.iter() { let handle = handle.deref().clone(); - if let Ok(handle) = handle.try_typed::() { + if let Ok(handle) = handle.try_typed::() { commands .entity(entity) - .insert(MeshMaterial3d::(handle)); + .insert(MeshMaterial3d::(handle)); } } } diff --git a/crates/processing_render/src/field/mod.rs b/crates/processing_render/src/field/mod.rs index 9e4e307..5fdfba5 100644 --- a/crates/processing_render/src/field/mod.rs +++ b/crates/processing_render/src/field/mod.rs @@ -33,7 +33,7 @@ impl Plugin for FieldPlugin { fn build(&self, app: &mut App) { app.add_plugins(GpuInstanceBatchPlugin); app.add_plugins(pack::FieldPackPlugin); - app.add_plugins(material::FieldColorMaterialPlugin); + app.add_plugins(material::FieldMaterialPlugin); app.add_plugins(kernels::FieldKernelsPlugin); } } diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index de0eae7..12161ae 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -78,8 +78,7 @@ impl Plugin for ProcessingRenderPlugin { flush_draw_commands, add_processing_materials, add_custom_materials, - field::material::add_field_color_materials, - field::material::add_field_pbr_materials, + field::material::add_field_materials, ) .chain() .before(AssetEventSystems), @@ -1462,59 +1461,151 @@ pub fn material_create_pbr() -> error::Result { }) } -/// Create an unlit material that reads each particle's color from the given -/// buffer. The buffer is expected to hold tightly-packed `Float4` colors -/// (RGBA, 16 bytes per particle). -pub fn material_create_field_color(color_buffer_entity: Entity) -> error::Result { - use crate::field::material::FieldColorMaterial; +/// Create a default PBR material with `StandardMaterial::unlit = true` — +/// shorthand for `material_create_pbr` followed by setting `unlit`. +pub fn material_create_unlit() -> error::Result { + let entity = material_create_pbr()?; + material_set(entity, "unlit", shader_value::ShaderValue::Float(1.0))?; + Ok(entity) +} + +/// Set a material's albedo source to a constant color (RGBA, srgb space). +/// +/// If the material is currently backed by a buffer (i.e. an `ExtendedMaterial` +/// wrapping `FieldExtension`), this swaps the backing asset to the plain PBR +/// type while preserving every `StandardMaterial` field — `base_color` becomes +/// the new color, `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. +/// stay as previously set. +pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Result<()> { + use bevy::pbr::ExtendedMaterial; + use crate::field::material::FieldMaterial; + use crate::material::ProcessingMaterial; use crate::render::material::UntypedMaterial; + + type DefaultMat = ExtendedMaterial; + app_mut(|app| { - let handle = app + let untyped = app .world() - .get::(color_buffer_entity) - .ok_or(error::ProcessingError::BufferNotFound)? - .handle + .get::(entity) + .ok_or(error::ProcessingError::MaterialNotFound)? + .0 .clone(); + let new_color = Color::srgba(color[0], color[1], color[2], color[3]); + + // Already a default-PBR-backed material? Just patch base_color in place. + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); + let mat = mats + .get_mut(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + mat.into_inner().base.base_color = new_color; + return Ok(()); + } + + // Field-buffer-backed: read the StandardMaterial state, drop the old + // asset, create a fresh default-PBR asset carrying the same Std state + // plus the new base_color, then re-point the entity at it. + let Ok(handle) = untyped.try_typed::() else { + return Err(error::ProcessingError::MaterialNotFound); + }; let world = app.world_mut(); - let asset_handle = world - .resource_mut::>() - .add(FieldColorMaterial { colors: handle }); - Ok(world - .spawn(UntypedMaterial(asset_handle.untyped())) - .id()) + let preserved = { + let mut mats = world.resource_mut::>(); + let mat = mats + .get(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + let mut base = mat.base.clone(); + base.base_color = new_color; + mats.remove(&handle); + base + }; + let new_handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: preserved, + extension: ProcessingMaterial { blend_state: None }, + }); + world + .entity_mut(entity) + .insert(UntypedMaterial(new_handle.untyped())); + Ok(()) }) } -/// PBR-lit version of [`material_create_field_color`]. Particles get standard -/// directional/point/spot lighting; per-particle color from the buffer -/// modulates the StandardMaterial base color (default white). -pub fn material_create_field_pbr(color_buffer_entity: Entity) -> error::Result { +/// Set a material's albedo source to a per-particle color buffer (RGBA `Float4`, +/// 16 bytes per slot, indexed by the per-instance tag the field pack pass +/// writes). +/// +/// If the material is currently a plain PBR (no buffer), this swaps the +/// backing asset to a `FieldMaterial` while preserving every `StandardMaterial` +/// field — `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. all +/// carry over. The fragment shader modulates the existing `base_color` by the +/// per-particle color, so leaving `base_color = WHITE` (the default) gives +/// "use the buffer color verbatim"; setting it tints all particles. +pub fn material_set_albedo_buffer( + entity: Entity, + color_buffer_entity: Entity, +) -> error::Result<()> { use bevy::pbr::ExtendedMaterial; - use crate::field::material::{FieldPbrExtension, FieldPbrMaterial}; + use crate::field::material::{FieldExtension, FieldMaterial}; + use crate::material::ProcessingMaterial; use crate::render::material::UntypedMaterial; + + type DefaultMat = ExtendedMaterial; + app_mut(|app| { - let handle = app + let buffer_handle = app .world() .get::(color_buffer_entity) .ok_or(error::ProcessingError::BufferNotFound)? .handle .clone(); + let untyped = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::MaterialNotFound)? + .0 + .clone(); + + // Already field-buffer-backed: just swap the buffer handle in place. + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); + let mat = mats + .get_mut(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + mat.into_inner().extension.colors = buffer_handle; + return Ok(()); + } + + // Default-PBR-backed: preserve StandardMaterial state, drop old asset, + // create a FieldMaterial with the same base + the buffer. + let Ok(handle) = untyped.try_typed::() else { + return Err(error::ProcessingError::MaterialNotFound); + }; let world = app.world_mut(); - let asset_handle = world - .resource_mut::>() + let preserved = { + let mut mats = world.resource_mut::>(); + let base = mats + .get(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)? + .base + .clone(); + mats.remove(&handle); + base + }; + let new_handle = world + .resource_mut::>() .add(ExtendedMaterial { - base: StandardMaterial { - base_color: Color::WHITE, - perceptual_roughness: 0.4, - metallic: 0.0, - cull_mode: None, - ..default() + base: preserved, + extension: FieldExtension { + colors: buffer_handle, }, - extension: FieldPbrExtension { colors: handle }, }); - Ok(world - .spawn(UntypedMaterial(asset_handle.untyped())) - .id()) + world + .entity_mut(entity) + .insert(UntypedMaterial(new_handle.untyped())); + Ok(()) }) } diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index d7fd029..4f24315 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -60,6 +60,7 @@ pub fn set_property( In((entity, name, value)): In<(Entity, String, ShaderValue)>, material_handles: Query<&UntypedMaterial>, mut extended_materials: ResMut>>, + mut field_materials: ResMut>, mut custom_materials: ResMut>, mut p_buffers: Query<&mut compute::Buffer>, ) -> error::Result<()> { @@ -78,6 +79,17 @@ pub fn set_property( return pbr::set_property(&mut extended.base, &name, &value); } + if let Ok(handle) = untyped + .0 + .clone() + .try_typed::() + { + let mut extended = field_materials + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)?; + return pbr::set_property(&mut extended.base, &name, &value); + } + if let Ok(handle) = untyped.0.clone().try_typed::() { let mut mat = custom_materials .get_mut(&handle) diff --git a/examples/field_colored.rs b/examples/field_colored.rs index 4b40011..12c7da5 100644 --- a/examples/field_colored.rs +++ b/examples/field_colored.rs @@ -56,7 +56,7 @@ fn sketch() -> error::Result<()> { colors.iter().flat_map(|f| f.to_le_bytes()).collect(), )?; - let mat = material_create_field_color(color_buf)?; + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; while glfw_ctx.poll_events() { graphics_begin_draw(graphics)?; diff --git a/examples/field_colored_pbr.rs b/examples/field_colored_pbr.rs index 1246527..1f6c120 100644 --- a/examples/field_colored_pbr.rs +++ b/examples/field_colored_pbr.rs @@ -59,7 +59,7 @@ fn sketch() -> error::Result<()> { colors.iter().flat_map(|f| f.to_le_bytes()).collect(), )?; - let mat = material_create_field_pbr(color_buf)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; while glfw_ctx.poll_events() { graphics_begin_draw(graphics)?; diff --git a/examples/field_emit.rs b/examples/field_emit.rs index dd3e951..7df0761 100644 --- a/examples/field_emit.rs +++ b/examples/field_emit.rs @@ -39,7 +39,7 @@ fn sketch() -> error::Result<()> { init_positions.iter().flat_map(|f| f.to_le_bytes()).collect(), )?; - let mat = material_create_field_color(color_buf)?; + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; let mut frame: u32 = 0; while glfw_ctx.poll_events() { diff --git a/examples/field_emit_gpu.rs b/examples/field_emit_gpu.rs index 19d39c0..15a86b4 100644 --- a/examples/field_emit_gpu.rs +++ b/examples/field_emit_gpu.rs @@ -166,7 +166,7 @@ fn sketch() -> error::Result<()> { let color_buf = field_buffer(field, color_attr)? .ok_or(error::ProcessingError::FieldNotFound)?; - let mat = material_create_field_pbr(color_buf)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; let spawn_shader = shader_create(SPAWN_SHADER)?; let spawn = compute_create(spawn_shader)?; diff --git a/examples/field_from_mesh.rs b/examples/field_from_mesh.rs index 17c4bdc..5a290c7 100644 --- a/examples/field_from_mesh.rs +++ b/examples/field_from_mesh.rs @@ -54,7 +54,7 @@ fn sketch() -> error::Result<()> { buffer_write(color_buf, colors)?; let particle = geometry_sphere(0.18, 10, 8)?; - let mat = material_create_field_pbr(color_buf)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; while glfw_ctx.poll_events() { graphics_begin_draw(graphics)?; diff --git a/examples/field_lifecycle.rs b/examples/field_lifecycle.rs index cf24a5f..1246574 100644 --- a/examples/field_lifecycle.rs +++ b/examples/field_lifecycle.rs @@ -89,7 +89,7 @@ fn sketch() -> error::Result<()> { .collect(); buffer_write(dead_buf, init_dead)?; - let mat = material_create_field_color(color_buf)?; + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; let aging_shader = shader_create(AGING_SHADER)?; let aging = compute_create(aging_shader)?; diff --git a/examples/field_noise.rs b/examples/field_noise.rs index 7bc8cb1..eb33166 100644 --- a/examples/field_noise.rs +++ b/examples/field_noise.rs @@ -50,7 +50,7 @@ fn sketch() -> error::Result<()> { buffer_write(color_buf, colors)?; let particle = geometry_sphere(0.18, 10, 8)?; - let mat = material_create_field_pbr(color_buf)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; let noise = field_kernel_noise()?; let start = Instant::now(); diff --git a/examples/field_stress.rs b/examples/field_stress.rs index e048ca5..e6624ab 100644 --- a/examples/field_stress.rs +++ b/examples/field_stress.rs @@ -115,7 +115,7 @@ fn sketch() -> error::Result<()> { colors.iter().flat_map(|f| f.to_le_bytes()).collect(), )?; - let mat = material_create_field_pbr(color_buf)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; let spin_shader = shader_create(SPIN_SHADER)?; let spin = compute_create(spin_shader)?; From 2b9e26bbcd5870e6c1168a6d40b06a117c50a6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 1 May 2026 19:54:19 -0700 Subject: [PATCH 10/11] . --- Cargo.toml | 44 +++--- crates/processing_core/src/error.rs | 4 +- .../examples/camera_controllers.py | 2 +- .../processing_pyo3/examples/field_stress.py | 75 ---------- crates/processing_pyo3/examples/lights.py | 8 +- crates/processing_pyo3/examples/materials.py | 4 +- ...ield_animated.py => particles_animated.py} | 14 +- .../{field_basic.py => particles_basic.py} | 14 +- .../{field_emit.py => particles_emit.py} | 14 +- ...ield_emit_gpu.py => particles_emit_gpu.py} | 18 +-- ...ld_from_mesh.py => particles_from_mesh.py} | 14 +- ...ld_lifecycle.py => particles_lifecycle.py} | 16 +-- .../{field_noise.py => particles_noise.py} | 16 +-- .../examples/particles_stress.py | 51 +++++++ crates/processing_pyo3/src/graphics.rs | 18 ++- crates/processing_pyo3/src/lib.rs | 76 +++++++--- crates/processing_pyo3/src/math.rs | 18 +++ .../src/{field.rs => particles.rs} | 70 ++++++---- .../src/field/kernels/mod.rs | 18 --- crates/processing_render/src/lib.rs | 132 +++++++++--------- crates/processing_render/src/material/mod.rs | 6 +- .../src/particles/kernels/mod.rs | 19 +++ .../{field => particles}/kernels/noise.wgsl | 0 .../kernels/transform.wgsl | 26 ++-- .../src/{field => particles}/material.rs | 34 ++--- .../src/{field => particles}/mod.rs | 74 +++++----- .../src/{field => particles}/pack.rs | 82 +++++------ .../src/{field => particles}/pack.wgsl | 2 +- .../field.wgsl => particles/particles.wgsl} | 0 .../processing_render/src/render/command.rs | 8 +- crates/processing_render/src/render/mod.rs | 103 ++++++++++---- docs/{field.md => particles.md} | 119 ++++++++-------- ...ield_animated.rs => particles_animated.rs} | 13 +- .../{field_basic.rs => particles_basic.rs} | 11 +- ...{field_colored.rs => particles_colored.rs} | 15 +- ...olored_pbr.rs => particles_colored_pbr.rs} | 15 +- examples/{field_emit.rs => particles_emit.rs} | 19 ++- ...ield_emit_gpu.rs => particles_emit_gpu.rs} | 19 ++- ...ld_from_mesh.rs => particles_from_mesh.rs} | 11 +- ...ld_lifecycle.rs => particles_lifecycle.rs} | 21 ++- .../{field_noise.rs => particles_noise.rs} | 15 +- ...ield_oriented.rs => particles_oriented.rs} | 21 ++- .../{field_stress.rs => particles_stress.rs} | 17 +-- 43 files changed, 681 insertions(+), 595 deletions(-) delete mode 100644 crates/processing_pyo3/examples/field_stress.py rename crates/processing_pyo3/examples/{field_animated.py => particles_animated.py} (80%) rename crates/processing_pyo3/examples/{field_basic.py => particles_basic.py} (74%) rename crates/processing_pyo3/examples/{field_emit.py => particles_emit.py} (82%) rename crates/processing_pyo3/examples/{field_emit_gpu.py => particles_emit_gpu.py} (92%) rename crates/processing_pyo3/examples/{field_from_mesh.py => particles_from_mesh.py} (74%) rename crates/processing_pyo3/examples/{field_lifecycle.py => particles_lifecycle.py} (91%) rename crates/processing_pyo3/examples/{field_noise.py => particles_noise.py} (74%) create mode 100644 crates/processing_pyo3/examples/particles_stress.py rename crates/processing_pyo3/src/{field.rs => particles.rs} (80%) delete mode 100644 crates/processing_render/src/field/kernels/mod.rs create mode 100644 crates/processing_render/src/particles/kernels/mod.rs rename crates/processing_render/src/{field => particles}/kernels/noise.wgsl (100%) rename crates/processing_render/src/{field => particles}/kernels/transform.wgsl (60%) rename crates/processing_render/src/{field => particles}/material.rs (56%) rename crates/processing_render/src/{field => particles}/mod.rs (77%) rename crates/processing_render/src/{field => particles}/pack.rs (84%) rename crates/processing_render/src/{field => particles}/pack.wgsl (98%) rename crates/processing_render/src/{field/field.wgsl => particles/particles.wgsl} (100%) rename docs/{field.md => particles.md} (68%) rename examples/{field_animated.rs => particles_animated.rs} (89%) rename examples/{field_basic.rs => particles_basic.rs} (88%) rename examples/{field_colored.rs => particles_colored.rs} (85%) rename examples/{field_colored_pbr.rs => particles_colored_pbr.rs} (85%) rename examples/{field_emit.rs => particles_emit.rs} (88%) rename examples/{field_emit_gpu.rs => particles_emit_gpu.rs} (93%) rename examples/{field_from_mesh.rs => particles_from_mesh.rs} (89%) rename examples/{field_lifecycle.rs => particles_lifecycle.rs} (93%) rename examples/{field_noise.rs => particles_noise.rs} (87%) rename examples/{field_oriented.rs => particles_oriented.rs} (88%) rename examples/{field_stress.rs => particles_stress.rs} (91%) diff --git a/Cargo.toml b/Cargo.toml index b076897..e83186f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,48 +166,48 @@ name = "compute_readback" path = "examples/compute_readback.rs" [[example]] -name = "field_basic" -path = "examples/field_basic.rs" +name = "particles_basic" +path = "examples/particles_basic.rs" [[example]] -name = "field_animated" -path = "examples/field_animated.rs" +name = "particles_animated" +path = "examples/particles_animated.rs" [[example]] -name = "field_oriented" -path = "examples/field_oriented.rs" +name = "particles_oriented" +path = "examples/particles_oriented.rs" [[example]] -name = "field_colored" -path = "examples/field_colored.rs" +name = "particles_colored" +path = "examples/particles_colored.rs" [[example]] -name = "field_colored_pbr" -path = "examples/field_colored_pbr.rs" +name = "particles_colored_pbr" +path = "examples/particles_colored_pbr.rs" [[example]] -name = "field_emit" -path = "examples/field_emit.rs" +name = "particles_emit" +path = "examples/particles_emit.rs" [[example]] -name = "field_lifecycle" -path = "examples/field_lifecycle.rs" +name = "particles_lifecycle" +path = "examples/particles_lifecycle.rs" [[example]] -name = "field_from_mesh" -path = "examples/field_from_mesh.rs" +name = "particles_from_mesh" +path = "examples/particles_from_mesh.rs" [[example]] -name = "field_noise" -path = "examples/field_noise.rs" +name = "particles_noise" +path = "examples/particles_noise.rs" [[example]] -name = "field_emit_gpu" -path = "examples/field_emit_gpu.rs" +name = "particles_emit_gpu" +path = "examples/particles_emit_gpu.rs" [[example]] -name = "field_stress" -path = "examples/field_stress.rs" +name = "particles_stress" +path = "examples/particles_stress.rs" [profile.wasm-release] inherits = "release" diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 05bc0a6..254b1f5 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -56,6 +56,6 @@ pub enum ProcessingError { PipelineCompileError(String), #[error("Pipeline not ready after {0} frames")] PipelineNotReady(u32), - #[error("Field not found")] - FieldNotFound, + #[error("Particles not found")] + ParticlesNotFound, } diff --git a/crates/processing_pyo3/examples/camera_controllers.py b/crates/processing_pyo3/examples/camera_controllers.py index 7feae0f..562d5b6 100644 --- a/crates/processing_pyo3/examples/camera_controllers.py +++ b/crates/processing_pyo3/examples/camera_controllers.py @@ -8,7 +8,7 @@ def setup(): mode_3d() orbit_camera() - dir_light = create_directional_light((1.0, 0.98, 0.95), 1500.0) + dir_light = directional_light((1.0, 0.98, 0.95), 1500.0) dir_light.position(300.0, 400.0, 300.0) dir_light.look_at(0.0, 0.0, 0.0) diff --git a/crates/processing_pyo3/examples/field_stress.py b/crates/processing_pyo3/examples/field_stress.py deleted file mode 100644 index f6f8f1a..0000000 --- a/crates/processing_pyo3/examples/field_stress.py +++ /dev/null @@ -1,75 +0,0 @@ -from mewnala import * - -GRID = 100 # GRID^3 = 1,000,000 particles -SPACING = 1.0 -SPIN_PER_FRAME = 0.003 - -field_obj = None -cube = None -mat = None -spin = None - - -def setup(): - global field_obj, cube, mat, spin - - size(900, 700) - mode_3d() - - extent = GRID * SPACING * 0.5 - camera_position(0.0, extent * 0.6, extent * 2.5) - camera_look_at(0.0, 0.0, 0.0) - orbit_camera() - - # Three directional R/G/B lights from cardinal axes. - red = create_directional_light((1.0, 0.0, 0.0), 1000.0) - red.position(1.0, 0.0, 0.0) - red.look_at(0.0, 0.0, 0.0) - green = create_directional_light((0.0, 1.0, 0.0), 1000.0) - green.position(0.0, 1.0, 0.0) - green.look_at(0.0, 0.0, 0.0) - blue = create_directional_light((0.0, 0.0, 1.0), 1000.0) - blue.position(0.0, 0.0, 1.0) - blue.look_at(0.0, 0.0, 0.0) - - field_obj = Field( - geometry=Geometry.grid(GRID, GRID, GRID, SPACING), - attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], - ) - - # One-shot noise pass to break the regular lattice up. `scale` is the - # input multiplier applied to position before sampling — at < 1 / SPACING - # adjacent grid points sample nearly the same noise cell and get nearly - # identical displacement, leaving the lattice visible. Bumping it past - # 1 / SPACING breaks the grid. - jitter = kernel_noise() - jitter.set(scale=1.0 / SPACING, strength=SPACING * 0.6, time=0.0) - field_obj.apply(jitter) - - # Color each particle by its lattice u-coord. - uv_buf = field_obj.buffer(Attribute.uv()) - color_buf = field_obj.buffer(Attribute.color()) - colors = [] - for uv in uv_buf.read(): - c = hsva(uv[0] * 360.0, 0.85, 1.0) - colors.append([c.r, c.g, c.b, 1.0]) - color_buf.write(colors) - - mat = Material.pbr(albedo=color_buf) - - cube = Geometry.box(0.35, 0.35, 0.35) - - spin = kernel_transform() - spin.set(rotation=[0.0, 1.0, 0.0, SPIN_PER_FRAME]) - - -def draw(): - background(10, 10, 18) - - use_material(mat) - draw_field(field_obj, cube) - - field_obj.apply(spin) - - -run() diff --git a/crates/processing_pyo3/examples/lights.py b/crates/processing_pyo3/examples/lights.py index e825893..66552a9 100644 --- a/crates/processing_pyo3/examples/lights.py +++ b/crates/processing_pyo3/examples/lights.py @@ -7,19 +7,19 @@ def setup(): mode_3d() # Directional Light - dir_light = create_directional_light((0.5, 0.24, 1.0), 1500.0) + dir_light = directional_light((0.5, 0.24, 1.0), 1500.0) # Point Lights - point_light_a = create_point_light((1.0, 0.5, 0.25), 1000000.0, 200.0, 0.5) + point_light_a = point_light((1.0, 0.5, 0.25), 1000000.0, 200.0, 0.5) point_light_a.position(-25.0, 5.0, 51.0) point_light_a.look_at(0.0, 0.0, 0.0) - point_light_b = create_point_light((0.0, 0.5, 0.75), 2000000.0, 200.0, 0.25) + point_light_b = point_light((0.0, 0.5, 0.75), 2000000.0, 200.0, 0.25) point_light_b.position(0.0, 5.0, 50.5) point_light_b.look_at(0.0, 0.0, 0.0) # Spot Light - spot_light = create_spot_light((0.25, 0.8, 0.19), 15.0 * 1000000.0, 200.0, 0.84, 0.0, 0.7854) + spot_light = spot_light((0.25, 0.8, 0.19), 15.0 * 1000000.0, 200.0, 0.84, 0.0, 0.7854) spot_light.position(40.0, 0.0, 70.0) spot_light.look_at(0.0, 0.0, 0.0) diff --git a/crates/processing_pyo3/examples/materials.py b/crates/processing_pyo3/examples/materials.py index ffa66cc..2767e8a 100644 --- a/crates/processing_pyo3/examples/materials.py +++ b/crates/processing_pyo3/examples/materials.py @@ -7,8 +7,8 @@ def setup(): size(800, 600) mode_3d() - dir_light = create_directional_light((1.0, 0.98, 0.95), 1500.0) - point_light = create_point_light((1.0, 1.0, 1.0), 100000.0, 800.0, 0.0) + dir_light = directional_light((1.0, 0.98, 0.95), 1500.0) + point_light = point_light((1.0, 1.0, 1.0), 100000.0, 800.0, 0.0) point_light.position(200.0, 200.0, 400.0) mat = Material() diff --git a/crates/processing_pyo3/examples/field_animated.py b/crates/processing_pyo3/examples/particles_animated.py similarity index 80% rename from crates/processing_pyo3/examples/field_animated.py rename to crates/processing_pyo3/examples/particles_animated.py index 6a2e952..26b209e 100644 --- a/crates/processing_pyo3/examples/field_animated.py +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -1,6 +1,6 @@ from mewnala import * -field_obj = None +p = None sphere = None mat = None spin = None @@ -31,12 +31,12 @@ def setup(): - global field_obj, sphere, mat, spin + global p, sphere, mat, spin size(900, 700) mode_3d() - create_directional_light((0.9, 0.85, 0.8), 300.0) + directional_light((0.9, 0.85, 0.8), 300.0) sphere = Geometry.sphere(0.25, 12, 8) @@ -47,8 +47,8 @@ def setup(): for z in range(10): positions.extend([x - 4.5, y - 4.5, z - 4.5]) - field_obj = Field(capacity=capacity, attributes=[Attribute.position()]) - pos_buf = field_obj.buffer(Attribute.position()) + p = Particles(capacity=capacity, attributes=[Attribute.position()]) + pos_buf = p.buffer(Attribute.position()) pos_buf.write(positions) mat = Material(roughness=0.4) @@ -62,10 +62,10 @@ def draw(): fill(230, 128, 75) use_material(mat) - draw_field(field_obj, sphere) + particles(p, sphere) spin.set(dt=0.01) - field_obj.apply(spin) + p.apply(spin) run() diff --git a/crates/processing_pyo3/examples/field_basic.py b/crates/processing_pyo3/examples/particles_basic.py similarity index 74% rename from crates/processing_pyo3/examples/field_basic.py rename to crates/processing_pyo3/examples/particles_basic.py index e8e4687..d754130 100644 --- a/crates/processing_pyo3/examples/field_basic.py +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -1,28 +1,28 @@ from mewnala import * -field_obj = None +p = None particle = None mat = None def setup(): - global field_obj, particle, mat + global p, particle, mat size(900, 700) mode_3d() - create_directional_light((0.95, 0.9, 0.85), 600.0) + directional_light((0.95, 0.9, 0.85), 600.0) # Source mesh whose vertices become particle positions; uvs come along for # free and we use them to color each particle. source = Geometry.sphere(5.0, 32, 24) - field_obj = Field( + p = Particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = field_obj.buffer(Attribute.uv()) - color_buf = field_obj.buffer(Attribute.color()) + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) colors = [] for uv in uv_buf.read(): @@ -40,7 +40,7 @@ def draw(): background(15, 15, 20) use_material(mat) - draw_field(field_obj, particle) + particles(p, particle) run() diff --git a/crates/processing_pyo3/examples/field_emit.py b/crates/processing_pyo3/examples/particles_emit.py similarity index 82% rename from crates/processing_pyo3/examples/field_emit.py rename to crates/processing_pyo3/examples/particles_emit.py index 4f37e9c..cf3d1a7 100644 --- a/crates/processing_pyo3/examples/field_emit.py +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -1,14 +1,14 @@ from mewnala import * import math -field_obj = None +p = None sphere = None mat = None frame = 0 def setup(): - global field_obj, sphere, mat + global p, sphere, mat size(900, 700) mode_3d() @@ -16,17 +16,17 @@ def setup(): sphere = Geometry.sphere(0.08, 8, 6) capacity = 2000 - field_obj = Field( + p = Particles( capacity=capacity, attributes=[Attribute.position(), Attribute.color()], ) # Push unemitted slots far off-screen so they don't all render at the # origin while the ring buffer is still filling. - pos_buf = field_obj.buffer(Attribute.position()) + pos_buf = p.buffer(Attribute.position()) pos_buf.write([1.0e6] * (capacity * 3)) - color_buf = field_obj.buffer(Attribute.color()) + color_buf = p.buffer(Attribute.color()) mat = Material.unlit(albedo=color_buf) @@ -37,7 +37,7 @@ def draw(): background(15, 15, 20) use_material(mat) - draw_field(field_obj, sphere) + particles(p, sphere) # Emit 4 particles per frame in an outward-spiraling ring; once the ring # buffer fills (~500 frames at 4/frame for capacity 2000), oldest get @@ -54,7 +54,7 @@ def draw(): c = hsva((i * 4.32) % 360.0, 0.85, 1.0) colors.extend([c.r, c.g, c.b, 1.0]) - field_obj.emit(burst, position=positions, color=colors) + p.emit(burst, position=positions, color=colors) frame += 1 diff --git a/crates/processing_pyo3/examples/field_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py similarity index 92% rename from crates/processing_pyo3/examples/field_emit_gpu.py rename to crates/processing_pyo3/examples/particles_emit_gpu.py index 7c0706c..4472bc4 100644 --- a/crates/processing_pyo3/examples/field_emit_gpu.py +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -1,7 +1,7 @@ from mewnala import * import math -field_obj = None +p = None particle = None mat = None spawn = None @@ -124,19 +124,19 @@ def setup(): - global field_obj, particle, mat, spawn, motion + global p, particle, mat, spawn, motion size(900, 700) mode_3d() - create_directional_light((0.95, 0.9, 0.85), 800.0) + directional_light((0.95, 0.9, 0.85), 800.0) particle = Geometry.sphere(0.12, 8, 6) velocity_attr = Attribute("velocity", AttributeFormat.Float3) age_attr = Attribute("age", AttributeFormat.Float) - field_obj = Field( + p = Particles( capacity=CAPACITY, attributes=[ Attribute.position(), @@ -149,10 +149,10 @@ def setup(): ) # Mark all unemitted slots dead so they don't render at origin. - dead_buf = field_obj.buffer(Attribute.dead()) + dead_buf = p.buffer(Attribute.dead()) dead_buf.write([1.0] * CAPACITY) - color_buf = field_obj.buffer(Attribute.color()) + color_buf = p.buffer(Attribute.color()) mat = Material.pbr(albedo=color_buf) spawn = Compute(Shader(SPAWN_SHADER)) @@ -165,17 +165,17 @@ def draw(): background(10, 10, 18) use_material(mat) - draw_field(field_obj, particle) + particles(p, particle) # Animate spawn point in a small circle so the fountain meanders. t = elapsed_time sx = math.cos(t) * 0.4 sz = math.sin(t) * 0.4 spawn.set(pos=[sx, 7.0, sz, 0.0], speed=[SPEED, 0.0, 0.0, 0.0]) - field_obj.emit_gpu(BURST, spawn) + p.emit_gpu(BURST, spawn) motion.set(dt=DT, ttl=TTL, gravity=GRAVITY) - field_obj.apply(motion) + p.apply(motion) run() diff --git a/crates/processing_pyo3/examples/field_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py similarity index 74% rename from crates/processing_pyo3/examples/field_from_mesh.py rename to crates/processing_pyo3/examples/particles_from_mesh.py index 4307a4d..8105c9e 100644 --- a/crates/processing_pyo3/examples/field_from_mesh.py +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -1,28 +1,28 @@ from mewnala import * -field_obj = None +p = None particle = None mat = None def setup(): - global field_obj, particle, mat + global p, particle, mat size(900, 700) mode_3d() - create_directional_light((0.95, 0.9, 0.85), 200.0) + directional_light((0.95, 0.9, 0.85), 200.0) # Source mesh whose vertices become the particle positions. UVs come along # for free and we'll use them to paint each particle a unique color. source = Geometry.sphere(5.0, 32, 24) - field_obj = Field( + p = Particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = field_obj.buffer(Attribute.uv()) - color_buf = field_obj.buffer(Attribute.color()) + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) colors = [] for uv in uv_buf.read(): @@ -40,7 +40,7 @@ def draw(): background(15, 15, 20) use_material(mat) - draw_field(field_obj, particle) + particles(p, particle) run() diff --git a/crates/processing_pyo3/examples/field_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py similarity index 91% rename from crates/processing_pyo3/examples/field_lifecycle.py rename to crates/processing_pyo3/examples/particles_lifecycle.py index 7adf6d2..e42b520 100644 --- a/crates/processing_pyo3/examples/field_lifecycle.py +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -1,7 +1,7 @@ from mewnala import * import math -field_obj = None +p = None sphere = None mat = None aging = None @@ -54,7 +54,7 @@ def setup(): - global field_obj, sphere, mat, aging + global p, sphere, mat, aging global position_attr, color_attr, scale_attr, dead_attr, age_attr size(900, 700) @@ -69,16 +69,16 @@ def setup(): dead_attr = Attribute.dead() age_attr = Attribute("age", AttributeFormat.Float) - field_obj = Field( + p = Particles( capacity=capacity, attributes=[position_attr, color_attr, scale_attr, dead_attr, age_attr], ) # Mark all slots dead initially so unemitted ring slots don't render. - dead_buf = field_obj.buffer(dead_attr) + dead_buf = p.buffer(dead_attr) dead_buf.write([1.0] * capacity) - color_buf = field_obj.buffer(color_attr) + color_buf = p.buffer(color_attr) mat = Material.unlit(albedo=color_buf) aging = Compute(Shader(AGING_SHADER)) @@ -90,7 +90,7 @@ def draw(): background(10, 10, 18) use_material(mat) - draw_field(field_obj, sphere) + particles(p, sphere) # Spawn `BURST` new particles per frame in a small fountain. positions = [] @@ -108,7 +108,7 @@ def draw(): zeros = [0.0] * BURST ones_scale = [1.0] * (BURST * 3) - field_obj.emit( + p.emit( BURST, position=positions, color=colors, @@ -118,7 +118,7 @@ def draw(): ) aging.set(params=[DT, TTL, 0.0, 0.0]) - field_obj.apply(aging) + p.apply(aging) frame += 1 diff --git a/crates/processing_pyo3/examples/field_noise.py b/crates/processing_pyo3/examples/particles_noise.py similarity index 74% rename from crates/processing_pyo3/examples/field_noise.py rename to crates/processing_pyo3/examples/particles_noise.py index 274bc97..26c08c8 100644 --- a/crates/processing_pyo3/examples/field_noise.py +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -1,29 +1,29 @@ from mewnala import * -field_obj = None +p = None particle = None mat = None noise = None def setup(): - global field_obj, particle, mat, noise + global p, particle, mat, noise size(900, 700) mode_3d() - create_directional_light((0.95, 0.9, 0.85), 200.0) + directional_light((0.95, 0.9, 0.85), 200.0) # Seed positions from a sphere mesh; noise will jitter them around their # initial sphere shape over time. source = Geometry.sphere(5.0, 32, 24) - field_obj = Field( + p = Particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = field_obj.buffer(Attribute.uv()) - color_buf = field_obj.buffer(Attribute.color()) + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) colors = [] for uv in uv_buf.read(): @@ -42,10 +42,10 @@ def draw(): background(15, 15, 20) use_material(mat) - draw_field(field_obj, particle) + particles(p, particle) noise.set(scale=0.25, strength=0.02, time=elapsed_time * 0.5) - field_obj.apply(noise) + p.apply(noise) run() diff --git a/crates/processing_pyo3/examples/particles_stress.py b/crates/processing_pyo3/examples/particles_stress.py new file mode 100644 index 0000000..1f8c517 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_stress.py @@ -0,0 +1,51 @@ +from mewnala import * + +GRID = 150 +SPACING = 1.0 +SPIN_PER_FRAME = 0.003 + +p = None +cube = None +spin = None + + +def setup(): + global p, cube, spin + + size(900, 700) + mode_3d() + + extent = GRID * SPACING * 0.5 + camera_position(0.0, extent * 0.6, extent * 2.5) + camera_look_at(0.0, 0.0, 0.0) + orbit_camera() + + directional_light((1.0, 0.0, 0.0), 1000.0, position=Vec3.X, look_at=Vec3.ZERO) + directional_light((0.0, 1.0, 0.0), 1000.0, position=Vec3.Y, look_at=Vec3.ZERO) + directional_light((0.0, 0.0, 1.0), 1000.0, position=Vec3.Z, look_at=Vec3.ZERO) + + p = Particles( + geometry=Geometry.grid(GRID, GRID, GRID, SPACING), + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + p.apply(kernel_noise(), scale=1.0 / SPACING, strength=SPACING * 0.6) + + color_buf = p.buffer(Attribute.color()) + color_buf.write([ + [c.r, c.g, c.b, 1.0] + for uv in p.buffer(Attribute.uv()).read() + for c in [hsva(uv[0] * 360.0, 0.85, 1.0)] + ]) + + fill(color_buf) + cube = Geometry.box(0.35, 0.35, 0.35) + spin = kernel_transform() + + +def draw(): + background(10, 10, 18) + particles(p, cube) + p.apply(spin, rotation_axis=Vec3.Y, rotation_angle=SPIN_PER_FRAME) + +run() diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index f0443ca..a9de9d4 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -299,7 +299,7 @@ impl Geometry { /// 3D lattice of `nx * ny * nz` points centered at the origin, with /// `spacing` units between adjacent points. Topology is `PointList` — - /// typically used as a position source for `Field(geometry=...)` rather + /// typically used as a position source for `Particles(geometry=...)` rather /// than rasterized directly. #[staticmethod] #[pyo3(signature = (nx, ny, nz, spacing=1.0))] @@ -500,6 +500,14 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn fill(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + // `fill(buffer)` — per-particle albedo for `Particles` draws. Bypasses the + // color-mode parser and feeds the buffer entity straight through. + if args.len() == 1 + && let Ok(buf) = args.get_item(0)?.extract::>() + { + return graphics_record_command(self.entity, DrawCommand::FillBuffer(buf.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } let color = extract_color_with_mode( args, &graphics_get_color_mode(self.entity) @@ -999,15 +1007,15 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn draw_field( + pub fn particles( &self, - field: &crate::field::Field, + particles: &crate::particles::Particles, geometry: &Geometry, ) -> PyResult<()> { graphics_record_command( self.entity, - DrawCommand::Field { - field: field.entity, + DrawCommand::Particles { + particles: particles.entity, geometry: geometry.entity, }, ) diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 20258ef..6fba302 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -12,7 +12,7 @@ pub(crate) mod color; pub(crate) mod compute; #[cfg(feature = "cuda")] pub(crate) mod cuda; -pub(crate) mod field; +pub(crate) mod particles; mod glfw; mod gltf; mod graphics; @@ -335,11 +335,11 @@ mod mewnala { #[pymodule_export] use super::Compute; #[pymodule_export] - use super::field::Attribute; + use super::particles::Attribute; #[pymodule_export] - use super::field::AttributeFormat; + use super::particles::AttributeFormat; #[pymodule_export] - use super::field::Field; + use super::particles::Particles; #[pymodule_export] use super::Geometry; #[pymodule_export] @@ -353,6 +353,14 @@ mod mewnala { #[pymodule_export] use super::Material; #[pymodule_export] + use super::math::PyQuat; + #[pymodule_export] + use super::math::PyVec2; + #[pymodule_export] + use super::math::PyVec3; + #[pymodule_export] + use super::math::PyVec4; + #[pymodule_export] use super::PyBlendMode; #[pymodule_export] use super::Shader; @@ -1268,26 +1276,26 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module, signature = (field, geometry))] - fn draw_field( + #[pyo3(pass_module, signature = (particles, geometry))] + fn particles( module: &Bound<'_, PyModule>, - field: &Bound<'_, super::field::Field>, + particles: &Bound<'_, super::particles::Particles>, geometry: &Bound<'_, Geometry>, ) -> PyResult<()> { - graphics!(module).draw_field( - &*field.extract::>()?, + graphics!(module).particles( + &*particles.extract::>()?, &*geometry.extract::>()?, ) } #[pyfunction] fn kernel_noise() -> PyResult { - super::field::kernel_noise() + super::particles::kernel_noise() } #[pyfunction] fn kernel_transform() -> PyResult { - super::field::kernel_transform() + super::particles::kernel_transform() } #[pyfunction(name = "color")] @@ -1412,35 +1420,59 @@ mod mewnala { graphics.create_image(width, height) } + fn apply_light_transform( + light: &Light, + position: Option, + look_at: Option, + ) -> PyResult<()> { + if let Some(p) = position { + ::processing::prelude::transform_set_position(light.entity, p.into_vec3()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + if let Some(la) = look_at { + ::processing::prelude::transform_look_at(light.entity, la.into_vec3()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } + #[pyfunction] - #[pyo3(pass_module)] - fn create_directional_light( + #[pyo3(pass_module, signature = (color, illuminance, *, position=None, look_at=None))] + fn directional_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, illuminance: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_directional(color, illuminance) + let light = graphics.light_directional(color, illuminance)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction] - #[pyo3(pass_module)] - fn create_point_light( + #[pyo3(pass_module, signature = (color, intensity, range, radius, *, position=None, look_at=None))] + fn point_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, intensity: f32, range: f32, radius: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_point(color, intensity, range, radius) + let light = graphics.light_point(color, intensity, range, radius)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction] - #[pyo3(pass_module)] - fn create_spot_light( + #[pyo3(pass_module, signature = (color, intensity, range, radius, inner_angle, outer_angle, *, position=None, look_at=None))] + fn spot_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, intensity: f32, @@ -1448,10 +1480,14 @@ mod mewnala { radius: f32, inner_angle: f32, outer_angle: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_spot(color, intensity, range, radius, inner_angle, outer_angle) + let light = graphics.light_spot(color, intensity, range, radius, inner_angle, outer_angle)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction(name = "sphere")] diff --git a/crates/processing_pyo3/src/math.rs b/crates/processing_pyo3/src/math.rs index 2abde01..5e19d9b 100644 --- a/crates/processing_pyo3/src/math.rs +++ b/crates/processing_pyo3/src/math.rs @@ -504,6 +504,11 @@ macro_rules! impl_py_vec { } impl_py_vec!(PyVec2, "Vec2", 2, [(x, set_x, 0), (y, set_y, 1)], Vec2, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec2::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec2::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec2::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec2::Y) } + fn angle(&self) -> f32 { self.0.y.atan2(self.0.x) } @@ -543,6 +548,12 @@ impl_py_vec!(PyVec2, "Vec2", 2, [(x, set_x, 0), (y, set_y, 1)], Vec2, extra { }); impl_py_vec!(PyVec3, "Vec3", 3, [(x, set_x, 0), (y, set_y, 1), (z, set_z, 2)], Vec3, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec3::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec3::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec3::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec3::Y) } + #[classattr] #[allow(non_snake_case)] fn Z() -> Self { Self(Vec3::Z) } + fn cross(&self, other: &Self) -> Self { Self(self.0.cross(other.0)) } @@ -570,6 +581,13 @@ impl_py_vec!( [(x, set_x, 0), (y, set_y, 1), (z, set_z, 2), (w, set_w, 3)], Vec4, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec4::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec4::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec4::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec4::Y) } + #[classattr] #[allow(non_snake_case)] fn Z() -> Self { Self(Vec4::Z) } + #[classattr] #[allow(non_snake_case)] fn W() -> Self { Self(Vec4::W) } + fn truncate(&self) -> PyVec3 { PyVec3(self.0.truncate()) } diff --git a/crates/processing_pyo3/src/field.rs b/crates/processing_pyo3/src/particles.rs similarity index 80% rename from crates/processing_pyo3/src/field.rs rename to crates/processing_pyo3/src/particles.rs index 736ee6f..426935c 100644 --- a/crates/processing_pyo3/src/field.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -95,14 +95,14 @@ impl Attribute { } #[pyclass(unsendable)] -pub struct Field { +pub struct Particles { pub(crate) entity: Entity, /// Cached attribute metadata indexed by name, used to convert kwarg payloads - /// in `emit()` into the byte format the underlying `field_emit` expects. + /// in `emit()` into the byte format the underlying `particles_emit` expects. name_to_attr: HashMap, } -impl Field { +impl Particles { fn build_name_index(attrs: &[Attribute]) -> PyResult> { let mut map = HashMap::with_capacity(attrs.len()); for attr in attrs { @@ -115,8 +115,8 @@ impl Field { } #[pymethods] -impl Field { - /// Construct a Field. Provide either `capacity` (allocates empty buffers) +impl Particles { + /// Construct a Particles container. Provide either `capacity` (allocates empty buffers) /// or `geometry` (capacity = vertex count, buffers seeded from matching /// mesh attributes), but not both. #[new] @@ -134,40 +134,40 @@ impl Field { let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); let entity = match (capacity, geometry) { - (Some(cap), None) => field_create(cap, attr_entities) + (Some(cap), None) => particles_create(cap, attr_entities) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, - (None, Some(g)) => field_create_from_geometry(g.entity, attr_entities) + (None, Some(g)) => particles_create_from_geometry(g.entity, attr_entities) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, (None, None) => { return Err(PyRuntimeError::new_err( - "Field requires either capacity or geometry", + "Particles requires either capacity or geometry", )); } (Some(_), Some(_)) => { return Err(PyRuntimeError::new_err( - "Field accepts capacity or geometry, not both", + "Particles accepts capacity or geometry, not both", )); } }; Ok(Self { entity, - name_to_attr: Field::build_name_index(&attrs)?, + name_to_attr: Particles::build_name_index(&attrs)?, }) } - /// Number of slots reserved for this Field. + /// Number of slots reserved for this container. #[getter] pub fn capacity(&self) -> PyResult { - field_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } /// Get the underlying `Buffer` for a registered attribute, or `None` if the - /// attribute isn't part of this Field. The returned buffer's element type + /// attribute isn't part of this container. The returned buffer's element type /// matches the attribute's format so `read()` / `__getitem__` return typed /// values (e.g. lists of vec3 components for a Float3 attribute). pub fn buffer(&self, attribute: &Attribute) -> PyResult> { - let buf = field_buffer(self.entity, attribute.entity) + let buf = particles_buffer(self.entity, attribute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let (_, fmt) = geometry_attribute_info(attribute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; @@ -180,11 +180,23 @@ impl Field { Ok(buf.map(|e| Buffer::from_entity(e, Some(element_type)))) } - /// Run a compute kernel against this Field's buffers. Each buffer is - /// auto-bound by its attribute name; uniforms must be set on the compute - /// beforehand via `compute.set(...)`. - pub fn apply(&self, compute: &Compute) -> PyResult<()> { - field_apply(self.entity, compute.entity) + /// Run a compute kernel against these particles' buffers. Each buffer is + /// auto-bound by its attribute name. Any kwargs are forwarded to + /// `compute.set(...)` first, so callers can configure uniforms inline: + /// + /// ```python + /// field.apply(noise, scale=0.25, strength=0.02, time=t) + /// ``` + #[pyo3(signature = (compute, **kwargs))] + pub fn apply( + &self, + compute: &Compute, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + if let Some(kwargs) = kwargs { + compute.set(Some(kwargs))?; + } + particles_apply(self.entity, compute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -198,7 +210,7 @@ impl Field { #[pyo3(signature = (n, **kwargs))] pub fn emit(&self, n: u32, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { let Some(kwargs) = kwargs else { - return field_emit(self.entity, n, vec![]) + return particles_emit(self.entity, n, vec![]) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); }; let mut data: Vec<(Entity, Vec)> = Vec::new(); @@ -222,7 +234,7 @@ impl Field { let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); data.push((attr_entity, bytes)); } - field_emit(self.entity, n, data) + particles_emit(self.entity, n, data) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -232,29 +244,29 @@ impl Field { /// to `(base_slot, n, capacity, 0)`. User-set uniforms (spawn position, /// velocity hint, etc.) must be assigned to the compute beforehand. pub fn emit_gpu(&self, n: u32, compute: &Compute) -> PyResult<()> { - field_emit_gpu(self.entity, n, compute.entity) + particles_emit_gpu(self.entity, n, compute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } } -impl Drop for Field { +impl Drop for Particles { fn drop(&mut self) { - let _ = field_destroy(self.entity); + let _ = particles_destroy(self.entity); } } /// Built-in noise compute kernel. Configure via `compute.set(scale=..., strength=..., time=...)`. pub fn kernel_noise() -> PyResult { - let entity = field_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = particles_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Compute::from_entity(entity)) } /// Built-in transform compute kernel — applies an affine to each particle's /// position in scale → axis-angle rotation → translate order. Configure via -/// `compute.set(translate=[tx,ty,tz,0], rotation=[ax,ay,az,angle_rad], scale=[sx,sy,sz,0])` -/// (rotation xyz = axis, w = angle in radians). Defaults of zero/one behave as -/// identity, so unset parameters are no-ops. +/// `compute.set(translate=[tx,ty,tz], rotation_axis=[ax,ay,az], +/// rotation_angle=angle_rad, scale=[sx,sy,sz])`. Defaults of zero/one behave +/// as identity, so unset parameters are no-ops. pub fn kernel_transform() -> PyResult { - let entity = field_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = particles_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Compute::from_entity(entity)) } diff --git a/crates/processing_render/src/field/kernels/mod.rs b/crates/processing_render/src/field/kernels/mod.rs deleted file mode 100644 index b11065c..0000000 --- a/crates/processing_render/src/field/kernels/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Built-in compute kernels for [`Field`](super::Field). Each kernel is a small WGSL -//! shader packaged with libprocessing as an embedded asset. Use them via `field_apply` -//! after configuring parameters via `compute_set`. - -use bevy::asset::embedded_asset; -use bevy::prelude::*; - -pub struct FieldKernelsPlugin; - -impl Plugin for FieldKernelsPlugin { - fn build(&self, app: &mut App) { - embedded_asset!(app, "noise.wgsl"); - embedded_asset!(app, "transform.wgsl"); - } -} - -pub const NOISE_PATH: &str = "embedded://processing_render/field/kernels/noise.wgsl"; -pub const TRANSFORM_PATH: &str = "embedded://processing_render/field/kernels/transform.wgsl"; diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 12161ae..8808e40 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -3,7 +3,7 @@ pub mod camera; pub mod color; pub mod compute; -pub mod field; +pub mod particles; pub mod geometry; pub mod gltf; pub mod graphics; @@ -65,7 +65,7 @@ impl Plugin for ProcessingRenderPlugin { bevy::pbr::wireframe::WireframePlugin::default(), material::custom::CustomMaterialPlugin, compute::ComputePlugin, - field::FieldPlugin, + particles::ParticlesPlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, @@ -78,7 +78,7 @@ impl Plugin for ProcessingRenderPlugin { flush_draw_commands, add_processing_materials, add_custom_materials, - field::material::add_field_materials, + particles::material::add_particles_materials, ) .chain() .before(AssetEventSystems), @@ -1397,7 +1397,7 @@ pub fn geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> error::Result< /// 3D lattice of `nx * ny * nz` points centered at the origin, with `spacing` /// units between adjacent points. Topology is `PointList` — typically used as a -/// position source for [`field_create_from_geometry`] rather than rasterized +/// position source for [`particles_create_from_geometry`] rather than rasterized /// directly. pub fn geometry_grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> error::Result { app_mut(|app| { @@ -1472,13 +1472,13 @@ pub fn material_create_unlit() -> error::Result { /// Set a material's albedo source to a constant color (RGBA, srgb space). /// /// If the material is currently backed by a buffer (i.e. an `ExtendedMaterial` -/// wrapping `FieldExtension`), this swaps the backing asset to the plain PBR +/// wrapping `ParticlesExtension`), this swaps the backing asset to the plain PBR /// type while preserving every `StandardMaterial` field — `base_color` becomes /// the new color, `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. /// stay as previously set. pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Result<()> { use bevy::pbr::ExtendedMaterial; - use crate::field::material::FieldMaterial; + use crate::particles::material::ParticlesMaterial; use crate::material::ProcessingMaterial; use crate::render::material::UntypedMaterial; @@ -1503,15 +1503,15 @@ pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Resu return Ok(()); } - // Field-buffer-backed: read the StandardMaterial state, drop the old + // Particles-buffer-backed: read the StandardMaterial state, drop the old // asset, create a fresh default-PBR asset carrying the same Std state // plus the new base_color, then re-point the entity at it. - let Ok(handle) = untyped.try_typed::() else { + let Ok(handle) = untyped.try_typed::() else { return Err(error::ProcessingError::MaterialNotFound); }; let world = app.world_mut(); let preserved = { - let mut mats = world.resource_mut::>(); + let mut mats = world.resource_mut::>(); let mat = mats .get(&handle) .ok_or(error::ProcessingError::MaterialNotFound)?; @@ -1538,7 +1538,7 @@ pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Resu /// writes). /// /// If the material is currently a plain PBR (no buffer), this swaps the -/// backing asset to a `FieldMaterial` while preserving every `StandardMaterial` +/// backing asset to a `ParticlesMaterial` while preserving every `StandardMaterial` /// field — `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. all /// carry over. The fragment shader modulates the existing `base_color` by the /// per-particle color, so leaving `base_color = WHITE` (the default) gives @@ -1548,7 +1548,7 @@ pub fn material_set_albedo_buffer( color_buffer_entity: Entity, ) -> error::Result<()> { use bevy::pbr::ExtendedMaterial; - use crate::field::material::{FieldExtension, FieldMaterial}; + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; use crate::material::ProcessingMaterial; use crate::render::material::UntypedMaterial; @@ -1569,8 +1569,8 @@ pub fn material_set_albedo_buffer( .clone(); // Already field-buffer-backed: just swap the buffer handle in place. - if let Ok(handle) = untyped.clone().try_typed::() { - let mut mats = app.world_mut().resource_mut::>(); + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); let mat = mats .get_mut(&handle) .ok_or(error::ProcessingError::MaterialNotFound)?; @@ -1579,7 +1579,7 @@ pub fn material_set_albedo_buffer( } // Default-PBR-backed: preserve StandardMaterial state, drop old asset, - // create a FieldMaterial with the same base + the buffer. + // create a ParticlesMaterial with the same base + the buffer. let Ok(handle) = untyped.try_typed::() else { return Err(error::ProcessingError::MaterialNotFound); }; @@ -1595,10 +1595,10 @@ pub fn material_set_albedo_buffer( base }; let new_handle = world - .resource_mut::>() + .resource_mut::>() .add(ExtendedMaterial { base: preserved, - extension: FieldExtension { + extension: ParticlesExtension { colors: buffer_handle, }, }); @@ -2047,64 +2047,64 @@ pub fn compute_destroy(entity: Entity) -> error::Result<()> { }) } -pub fn field_create(capacity: u32, attribute_entities: Vec) -> error::Result { +pub fn particles_create(capacity: u32, attribute_entities: Vec) -> error::Result { app_mut(|app| { app.world_mut() - .run_system_cached_with(field::create, (capacity, attribute_entities)) + .run_system_cached_with(particles::create, (capacity, attribute_entities)) .unwrap() }) } -/// Create a Field whose capacity matches `geometry`'s vertex count and whose +/// Create a Particles whose capacity matches `geometry`'s vertex count and whose /// buffers are pre-seeded from the geometry's mesh attributes when names line /// up (`position`, `normal`, `color`, `uv`). Custom attributes the mesh doesn't /// supply are zero-initialized — the user fills them via `buffer_write` or -/// `field_emit`. -pub fn field_create_from_geometry( +/// `particles_emit`. +pub fn particles_create_from_geometry( geometry_entity: Entity, attribute_entities: Vec, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( - field::create_from_geometry, + particles::create_from_geometry, (geometry_entity, attribute_entities), ) .unwrap() }) } -pub fn field_destroy(entity: Entity) -> error::Result<()> { +pub fn particles_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() - .run_system_cached_with(field::destroy, entity) + .run_system_cached_with(particles::destroy, entity) .unwrap() }) } -pub fn field_capacity(entity: Entity) -> error::Result { +pub fn particles_capacity(entity: Entity) -> error::Result { app_mut(|app| { Ok(app .world() - .get::(entity) - .ok_or(error::ProcessingError::FieldNotFound)? + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? .capacity) }) } -pub fn field_buffer(entity: Entity, attribute_entity: Entity) -> error::Result> { +pub fn particles_buffer(entity: Entity, attribute_entity: Entity) -> error::Result> { app_mut(|app| { Ok(app .world() - .get::(entity) - .ok_or(error::ProcessingError::FieldNotFound)? + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? .buffer(attribute_entity)) }) } /// GPU-side emission. Dispatches `compute_entity` over `count` invocations to /// initialize the next `count` ring-buffer slots. The framework auto-binds the -/// field's buffers (same convention as `field_apply`) and sets a `vec4` +/// field's buffers (same convention as `particles_apply`) and sets a `vec4` /// uniform named `emit_range` to `(base_slot, count, capacity, 0.0)` — the /// kernel reads it to compute its target slot: /// @@ -2117,10 +2117,10 @@ pub fn field_buffer(entity: Entity, attribute_entity: Entity) -> error::Result error::Result<()> { @@ -2132,11 +2132,11 @@ pub fn field_emit_gpu( let (capacity, head, buffers) = app_mut(|app| { let world = app.world(); let field = world - .get::(field_entity) - .ok_or(error::ProcessingError::FieldNotFound)?; + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; if count > field.capacity { return Err(error::ProcessingError::InvalidArgument(format!( - "field_emit_gpu count={} exceeds field capacity {}", + "particles_emit_gpu count={} exceeds field capacity {}", count, field.capacity ))); } @@ -2178,19 +2178,19 @@ pub fn field_emit_gpu( app_mut(|app| { let mut field = app .world_mut() - .get_mut::(field_entity) - .ok_or(error::ProcessingError::FieldNotFound)?; + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; field.emit_head = (field.emit_head + count) % field.capacity; Ok(()) }) } -/// Emit `n` particles into a Field, writing per-attribute byte payloads into the -/// next `n` slots starting at the field's ring-buffer head. Each entry in +/// Emit `n` particles into [`Particles`], writing per-attribute byte payloads into the +/// next `n` slots starting at the particles' ring-buffer head. Each entry in /// `attribute_data` must match the registered attribute's `byte_size * n`. /// On wrap, oldest particles in the ring are overwritten. -pub fn field_emit( - field_entity: Entity, +pub fn particles_emit( + particles_entity: Entity, n: u32, attribute_data: Vec<(Entity, Vec)>, ) -> error::Result<()> { @@ -2201,11 +2201,11 @@ pub fn field_emit( let (capacity, head, attr_specs) = app_mut(|app| { let world = app.world(); let field = world - .get::(field_entity) - .ok_or(error::ProcessingError::FieldNotFound)?; + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; if n > field.capacity { return Err(error::ProcessingError::InvalidArgument(format!( - "field_emit n={} exceeds field capacity {}", + "particles_emit n={} exceeds field capacity {}", n, field.capacity ))); } @@ -2216,7 +2216,7 @@ pub fn field_emit( .ok_or(error::ProcessingError::InvalidEntity)?; let buf = field.buffer(*attr_entity).ok_or_else(|| { error::ProcessingError::InvalidArgument(format!( - "field has no buffer for attribute {:?}", + "particles have no buffer for attribute {:?}", attr_entity )) })?; @@ -2248,8 +2248,8 @@ pub fn field_emit( app_mut(|app| { let mut field = app .world_mut() - .get_mut::(field_entity) - .ok_or(error::ProcessingError::FieldNotFound)?; + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; field.emit_head = (field.emit_head + n) % field.capacity; Ok(()) }) @@ -2258,44 +2258,46 @@ pub fn field_emit( /// Built-in noise kernel — perturbs each particle's `position` by sampled 3D /// value noise. Configure via `compute_set("scale", Float(...))`, /// `compute_set("strength", Float(...))`, `compute_set("time", Float(...))`. -pub fn field_kernel_noise() -> error::Result { - let shader = shader_load(field::kernels::NOISE_PATH)?; +pub fn particles_kernel_noise() -> error::Result { + let shader = shader_load(particles::kernels::NOISE_PATH)?; compute_create(shader) } /// Built-in transform kernel — applies an affine to each particle's `position` /// in scale → axis-angle rotation → translate order. Configure via: -/// `compute_set("translate", Float4([tx, ty, tz, 0.0]))`, -/// `compute_set("rotation", Float4([ax, ay, az, angle_radians]))` (xyz = axis, -/// w = angle), `compute_set("scale", Float4([sx, sy, sz, 0.0]))`. Identity -/// defaults are seeded at creation time, so any unset parameter is a no-op -/// (rather than zeroing out positions). -pub fn field_kernel_transform() -> error::Result { - let shader = shader_load(field::kernels::TRANSFORM_PATH)?; +/// `compute_set("translate", Float3([tx, ty, tz]))`, +/// `compute_set("rotation_axis", Float3([ax, ay, az]))`, +/// `compute_set("rotation_angle", Float(angle_radians))`, +/// `compute_set("scale", Float3([sx, sy, sz]))`. Identity defaults are seeded +/// at creation time, so any unset parameter is a no-op (rather than zeroing +/// out positions). +pub fn particles_kernel_transform() -> error::Result { + let shader = shader_load(particles::kernels::TRANSFORM_PATH)?; let entity = compute_create(shader)?; // The uniform struct is zero-initialized by default. Without these, // an unset `scale` would multiply every position by zero on the first // dispatch and collapse the whole field to the origin. - compute_set(entity, "translate", shader_value::ShaderValue::Float4([0.0; 4]))?; - compute_set(entity, "rotation", shader_value::ShaderValue::Float4([0.0, 1.0, 0.0, 0.0]))?; - compute_set(entity, "scale", shader_value::ShaderValue::Float4([1.0, 1.0, 1.0, 0.0]))?; + compute_set(entity, "translate", shader_value::ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "rotation_axis", shader_value::ShaderValue::Float3([0.0, 1.0, 0.0]))?; + compute_set(entity, "rotation_angle", shader_value::ShaderValue::Float(0.0))?; + compute_set(entity, "scale", shader_value::ShaderValue::Float3([1.0, 1.0, 1.0]))?; Ok(entity) } -/// Dispatch a compute pass against a Field's buffers. Each buffer is bound +/// Dispatch a compute pass against a [`Particles`]'s buffers. Each buffer is bound /// by its attribute's name; bindings the shader doesn't declare are skipped. /// Workgroup size is fixed at 64 — the shader must declare `@workgroup_size(64)`. /// /// Any non-buffer parameters (uniforms, etc.) on the compute should be set via /// `compute_set` before calling this. -pub fn field_apply(field_entity: Entity, compute_entity: Entity) -> error::Result<()> { +pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { const WORKGROUP_SIZE: u32 = 64; let (capacity, buffers) = app_mut(|app| { let world = app.world(); let field = world - .get::(field_entity) - .ok_or(error::ProcessingError::FieldNotFound)?; + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); for (&attr_entity, &buf_entity) in &field.buffers { let attr = world diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index 4f24315..2127cbe 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -60,7 +60,7 @@ pub fn set_property( In((entity, name, value)): In<(Entity, String, ShaderValue)>, material_handles: Query<&UntypedMaterial>, mut extended_materials: ResMut>>, - mut field_materials: ResMut>, + mut particles_materials: ResMut>, mut custom_materials: ResMut>, mut p_buffers: Query<&mut compute::Buffer>, ) -> error::Result<()> { @@ -82,9 +82,9 @@ pub fn set_property( if let Ok(handle) = untyped .0 .clone() - .try_typed::() + .try_typed::() { - let mut extended = field_materials + let mut extended = particles_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; return pbr::set_property(&mut extended.base, &name, &value); diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs new file mode 100644 index 0000000..0360ed5 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -0,0 +1,19 @@ +//! Built-in compute kernels for [`Particles`](super::Particles). Each kernel is +//! a small WGSL shader packaged with libprocessing as an embedded asset. Use +//! them via `particles_apply` after configuring parameters via `compute_set`. + +use bevy::asset::embedded_asset; +use bevy::prelude::*; + +pub struct ParticlesKernelsPlugin; + +impl Plugin for ParticlesKernelsPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "noise.wgsl"); + embedded_asset!(app, "transform.wgsl"); + } +} + +pub const NOISE_PATH: &str = "embedded://processing_render/particles/kernels/noise.wgsl"; +pub const TRANSFORM_PATH: &str = + "embedded://processing_render/particles/kernels/transform.wgsl"; diff --git a/crates/processing_render/src/field/kernels/noise.wgsl b/crates/processing_render/src/particles/kernels/noise.wgsl similarity index 100% rename from crates/processing_render/src/field/kernels/noise.wgsl rename to crates/processing_render/src/particles/kernels/noise.wgsl diff --git a/crates/processing_render/src/field/kernels/transform.wgsl b/crates/processing_render/src/particles/kernels/transform.wgsl similarity index 60% rename from crates/processing_render/src/field/kernels/transform.wgsl rename to crates/processing_render/src/particles/kernels/transform.wgsl index 25eed57..93708a6 100644 --- a/crates/processing_render/src/field/kernels/transform.wgsl +++ b/crates/processing_render/src/particles/kernels/transform.wgsl @@ -1,16 +1,18 @@ // Built-in transform kernel — applies an affine to each particle's position. -// Order: scale, then rotate around `rotation.xyz` by `rotation.w` radians, +// Order: scale, then rotate around `rotation_axis` by `rotation_angle` radians, // then translate. Defaults of zero/one behave as identity. // // Configure via `compute_set`: -// translate : vec4 — xyz applied last, w ignored -// rotation : vec4 — xyz axis (need not be normalized), w = angle radians -// scale : vec4 — xyz scale factor, w ignored +// translate : vec3 — applied last +// rotation_axis : vec3 — need not be normalized +// rotation_angle : f32 — radians +// scale : vec3 — per-axis scale factor struct Params { - translate: vec4, - rotation: vec4, - scale: vec4, + translate: vec3, + rotation_angle: f32, + rotation_axis: vec3, + scale: vec3, } @group(0) @binding(0) var position: array; @@ -37,14 +39,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { position[i * 3u + 2u], ); - p = p * params.scale.xyz; + p = p * params.scale; - let axis_len = length(params.rotation.xyz); - if axis_len > 1.0e-6 && abs(params.rotation.w) > 1.0e-8 { - p = rotate(p, params.rotation.xyz / axis_len, params.rotation.w); + let axis_len = length(params.rotation_axis); + if axis_len > 1.0e-6 && abs(params.rotation_angle) > 1.0e-8 { + p = rotate(p, params.rotation_axis / axis_len, params.rotation_angle); } - p = p + params.translate.xyz; + p = p + params.translate; position[i * 3u + 0u] = p.x; position[i * 3u + 1u] = p.y; diff --git a/crates/processing_render/src/field/material.rs b/crates/processing_render/src/particles/material.rs similarity index 56% rename from crates/processing_render/src/field/material.rs rename to crates/processing_render/src/particles/material.rs index a00646a..2dd77b0 100644 --- a/crates/processing_render/src/field/material.rs +++ b/crates/processing_render/src/particles/material.rs @@ -1,6 +1,6 @@ -//! `FieldMaterial` — `ExtendedMaterial` whose -//! per-particle color comes from a storage buffer indexed by the per-instance -//! tag (set to slot index by the pack pass). +//! `ParticlesMaterial` — `ExtendedMaterial` +//! whose per-particle color comes from a storage buffer indexed by the +//! per-instance tag (set to slot index by the pack pass). //! //! Lit vs unlit is just the `unlit` flag on the base `StandardMaterial`; //! `apply_pbr_lighting` short-circuits to `base_color * particle_colors[tag]` @@ -16,50 +16,50 @@ use bevy::shader::ShaderRef; use crate::render::material::UntypedMaterial; -pub struct FieldMaterialPlugin; +pub struct ParticlesMaterialPlugin; -impl Plugin for FieldMaterialPlugin { +impl Plugin for ParticlesMaterialPlugin { fn build(&self, app: &mut App) { - embedded_asset!(app, "field.wgsl"); - app.add_plugins(MaterialPlugin::::default()); + embedded_asset!(app, "particles.wgsl"); + app.add_plugins(MaterialPlugin::::default()); } } /// PBR material extended with a per-particle color buffer. Set the base /// `StandardMaterial`'s `unlit` flag to switch between lit and unlit behavior; /// the rest of the material works identically either way. -pub type FieldMaterial = ExtendedMaterial; +pub type ParticlesMaterial = ExtendedMaterial; #[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] -pub struct FieldExtension { +pub struct ParticlesExtension { #[storage(100, read_only)] pub colors: Handle, } -impl MaterialExtension for FieldExtension { +impl MaterialExtension for ParticlesExtension { fn fragment_shader() -> ShaderRef { - "embedded://processing_render/field/field.wgsl".into() + "embedded://processing_render/particles/particles.wgsl".into() } fn deferred_fragment_shader() -> ShaderRef { - "embedded://processing_render/field/field.wgsl".into() + "embedded://processing_render/particles/particles.wgsl".into() } } /// Sibling of `add_processing_materials` / `add_custom_materials`. Promotes -/// `UntypedMaterial(handle)` entities whose handle is a [`FieldMaterial`] -/// to having the typed `MeshMaterial3d` component required +/// `UntypedMaterial(handle)` entities whose handle is a [`ParticlesMaterial`] +/// to having the typed `MeshMaterial3d` component required /// by the render pipeline. -pub fn add_field_materials( +pub fn add_particles_materials( mut commands: Commands, meshes: Query<(Entity, &UntypedMaterial)>, ) { for (entity, handle) in meshes.iter() { let handle = handle.deref().clone(); - if let Ok(handle) = handle.try_typed::() { + if let Ok(handle) = handle.try_typed::() { commands .entity(entity) - .insert(MeshMaterial3d::(handle)); + .insert(MeshMaterial3d::(handle)); } } } diff --git a/crates/processing_render/src/field/mod.rs b/crates/processing_render/src/particles/mod.rs similarity index 77% rename from crates/processing_render/src/field/mod.rs rename to crates/processing_render/src/particles/mod.rs index 5fdfba5..e3513a6 100644 --- a/crates/processing_render/src/field/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -1,13 +1,13 @@ //! GPU-resident particle / instancing container. //! -//! A [`Field`] holds a set of named [`compute::Buffer`]s — one per registered -//! attribute. It is pure storage: it carries no instance shape and no material. The shape is -//! supplied at draw time via the `field` verb, and the material is read from ambient state at -//! that point. Rasterization is layered on later by spawning a transient -//! `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` entity that consumes the Field's buffers -//! through the pack pass. +//! [`Particles`] holds a set of named [`compute::Buffer`]s — one per registered +//! attribute. It is pure storage: it carries no instance shape and no material. +//! The shape is supplied at draw time via the `particles` verb, and the material +//! is read from ambient state at that point. Rasterization is layered on later +//! by spawning a transient `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` +//! entity that consumes the buffers through the pack pass. //! -//! See `docs/field.md` for the full design. +//! See `docs/particles.md` for the full design. pub mod kernels; pub mod material; @@ -27,14 +27,14 @@ use processing_core::error::{ProcessingError, Result}; use crate::compute; use crate::geometry::{Attribute, AttributeFormat, Geometry}; -pub struct FieldPlugin; +pub struct ParticlesPlugin; -impl Plugin for FieldPlugin { +impl Plugin for ParticlesPlugin { fn build(&self, app: &mut App) { app.add_plugins(GpuInstanceBatchPlugin); - app.add_plugins(pack::FieldPackPlugin); - app.add_plugins(material::FieldMaterialPlugin); - app.add_plugins(kernels::FieldKernelsPlugin); + app.add_plugins(pack::ParticlesPackPlugin); + app.add_plugins(material::ParticlesMaterialPlugin); + app.add_plugins(kernels::ParticlesKernelsPlugin); } } @@ -44,35 +44,35 @@ impl Plugin for FieldPlugin { /// [`compute::Buffer`] entity. The set of registered attributes is fixed at creation. /// /// `draw_entity` is the persistent rasterization entity carrying `GpuBatchedMesh3d` and -/// the active material — created lazily on the first `field` draw call and reused on +/// the active material — created lazily on the first `particles` draw call and reused on /// subsequent ones. It must persist across frames because the upstream batching queue /// processes mesh instance batches one frame after the reservation is created; despawning /// per-frame would lose the entity before it ever gets queued. /// -/// `emit_head` is the ring-buffer write cursor used by `field_emit`. New particles are +/// `emit_head` is the ring-buffer write cursor used by `particles_emit`. New particles are /// written to slots `[emit_head, emit_head + n) mod capacity` and the head advances by `n`. /// When the ring wraps, oldest particles are overwritten — capacity is a visible contract. #[derive(Component)] -pub struct Field { +pub struct Particles { pub capacity: u32, pub buffers: HashMap, pub draw_entity: Option, pub emit_head: u32, } -impl Field { +impl Particles { pub fn buffer(&self, attribute: Entity) -> Option { self.buffers.get(&attribute).copied() } } -/// Marker on a transient render entity indicating it rasterizes a [`Field`]. +/// Marker on a transient render entity indicating it rasterizes a [`Particles`]. /// -/// The pack pass uses this to look up which Field's buffers to read when writing +/// The pack pass uses this to look up which Particles' buffers to read when writing /// per-instance transforms into the upstream `mesh_input_buffer`. #[derive(Component, Clone, Copy)] -pub struct FieldDraw { - pub field: Entity, +pub struct ParticlesDraw { + pub particles: Entity, } pub fn create( @@ -97,22 +97,22 @@ pub fn create( buffers.insert(attr_entity, buffer_entity); } - let field_entity = commands - .spawn(Field { + let entity = commands + .spawn(Particles { capacity, buffers, draw_entity: None, emit_head: 0, }) .id(); - Ok(field_entity) + Ok(entity) } -/// Create a Field whose capacity matches the source [`Geometry`]'s vertex count -/// and whose buffers are pre-seeded from the geometry's mesh attributes where -/// names line up. Any registered attribute the mesh doesn't supply (or whose -/// format doesn't match) gets zero-initialized — the user fills it in via -/// `buffer_write` or `field_emit`. +/// Create [`Particles`] whose capacity matches the source [`Geometry`]'s vertex +/// count and whose buffers are pre-seeded from the geometry's mesh attributes +/// where names line up. Any registered attribute the mesh doesn't supply (or +/// whose format doesn't match) gets zero-initialized — the user fills it in via +/// `buffer_write` or `particles_emit`. pub fn create_from_geometry( In((geom_entity, attribute_entities)): In<(Entity, Vec)>, mut commands: Commands, @@ -148,15 +148,15 @@ pub fn create_from_geometry( buffers.insert(attr_entity, buffer_entity); } - let field_entity = commands - .spawn(Field { + let entity = commands + .spawn(Particles { capacity, buffers, draw_entity: None, emit_head: 0, }) .id(); - Ok(field_entity) + Ok(entity) } fn make_buffer( @@ -168,7 +168,7 @@ fn make_buffer( let byte_size = initial.len() as u64; let handle = shader_buffers.add(ShaderBuffer::new(initial, RenderAssetUsages::all())); let readback = render_device.create_buffer(&BufferDescriptor { - label: Some("Field Buffer Readback"), + label: Some("Particles Buffer Readback"), size: byte_size, usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, mapped_at_creation: false, @@ -214,15 +214,15 @@ fn attribute_values_to_bytes( pub fn destroy( In(entity): In, mut commands: Commands, - fields: Query<&Field>, + particles: Query<&Particles>, ) -> Result<()> { - let field = fields + let p = particles .get(entity) - .map_err(|_| ProcessingError::FieldNotFound)?; - for &buffer_entity in field.buffers.values() { + .map_err(|_| ProcessingError::ParticlesNotFound)?; + for &buffer_entity in p.buffers.values() { commands.entity(buffer_entity).despawn(); } - if let Some(draw_entity) = field.draw_entity { + if let Some(draw_entity) = p.draw_entity { commands.entity(draw_entity).despawn(); } commands.entity(entity).despawn(); diff --git a/crates/processing_render/src/field/pack.rs b/crates/processing_render/src/particles/pack.rs similarity index 84% rename from crates/processing_render/src/field/pack.rs rename to crates/processing_render/src/particles/pack.rs index 3e8acc7..b2d4c5d 100644 --- a/crates/processing_render/src/field/pack.rs +++ b/crates/processing_render/src/particles/pack.rs @@ -1,9 +1,9 @@ -//! Pack pass — bridges a [`Field`]'s `position` / `rotation` / `scale` buffers into the +//! Pack pass — bridges a [`Particles`]'s `position` / `rotation` / `scale` buffers into the //! upstream `mesh_input_buffer[base..base+capacity].world_from_local` slots reserved by the //! entity's [`GpuBatchedMesh3d`]. //! //! The pack shader is specialized via shader_defs (`HAS_ROTATION`, `HAS_SCALE`) based on -//! which builtin attributes the field carries. Pipelines and bind-group layouts are cached +//! which builtin attributes the particles carry. Pipelines and bind-group layouts are cached //! per shader_def combination. use std::num::NonZeroU64; @@ -33,32 +33,32 @@ use bevy::shader::{Shader, ShaderDefVal}; use crate::compute; use crate::geometry::BuiltinAttributes; -use super::{Field, FieldDraw}; +use super::{Particles, ParticlesDraw}; const WORKGROUP_SIZE: u32 = 64; -pub struct FieldPackPlugin; +pub struct ParticlesPackPlugin; -impl Plugin for FieldPackPlugin { +impl Plugin for ParticlesPackPlugin { fn build(&self, app: &mut App) { let shader = { let mut shaders = app.world_mut().resource_mut::>(); shaders.add(Shader::from_wgsl( include_str!("pack.wgsl"), - "processing_render/field/pack.wgsl", + "processing_render/particles/pack.wgsl", )) }; - app.insert_resource(FieldPackShader(shader.clone())); + app.insert_resource(ParticlesPackShader(shader.clone())); let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return; }; render_app - .insert_resource(FieldPackShader(shader)) - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems(ExtractSchedule, extract_field_draws) + .insert_resource(ParticlesPackShader(shader)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(ExtractSchedule, extract_particles_draws) .add_systems( Render, prepare_pack_bind_groups.in_set(RenderSystems::PrepareBindGroups), @@ -68,7 +68,7 @@ impl Plugin for FieldPackPlugin { } #[derive(Resource, Clone)] -pub struct FieldPackShader(pub Handle); +pub struct ParticlesPackShader(pub Handle); /// Specialization key — controls which `#ifdef`s are set when compiling the pack shader, /// and which bindings are present in the bind-group layout. @@ -85,19 +85,19 @@ pub struct CachedPackPipeline { } #[derive(Resource, Default)] -pub struct FieldPackPipelines { +pub struct ParticlesPackPipelines { pub by_key: HashMap, } #[derive(Copy, Clone, Default, ShaderType)] -struct FieldPackParams { +struct ParticlesPackParams { base_input_index: u32, count: u32, _pad0: u32, _pad1: u32, } -pub struct ExtractedFieldData { +pub struct ExtractedParticlesData { pub key: PackPipelineKey, pub position: Handle, pub rotation: Option>, @@ -106,12 +106,12 @@ pub struct ExtractedFieldData { } #[derive(Resource, Default)] -pub struct ExtractedFieldDraws { - pub by_main: MainEntityHashMap, +pub struct ExtractedParticlesDraws { + pub by_main: MainEntityHashMap, } #[derive(Resource, Default)] -pub struct FieldPackBindGroups { +pub struct ParticlesPackBindGroups { per_batch: MainEntityHashMap, } @@ -180,7 +180,7 @@ fn shader_defs_for(key: PackPipelineKey) -> Vec { } fn get_or_create_pipeline( - pipelines: &mut FieldPackPipelines, + pipelines: &mut ParticlesPackPipelines, pipeline_cache: &PipelineCache, shader: &Handle, key: PackPipelineKey, @@ -190,7 +190,7 @@ fn get_or_create_pipeline( } let bind_group_layout = BindGroupLayoutDescriptor::new( format!( - "FieldPackBindGroupLayout(rot={},scale={},dead={})", + "ParticlesPackBindGroupLayout(rot={},scale={},dead={})", key.has_rotation, key.has_scale, key.has_dead ), &pack_layout_entries(key), @@ -198,7 +198,7 @@ fn get_or_create_pipeline( let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { label: Some( format!( - "field_pack_pipeline(rot={},scale={},dead={})", + "particles_pack_pipeline(rot={},scale={},dead={})", key.has_rotation, key.has_scale, key.has_dead ) .into(), @@ -219,33 +219,33 @@ fn get_or_create_pipeline( pipelines.by_key.get(&key).unwrap().pipeline } -fn extract_field_draws( - field_draws: Extract>, - fields: Extract>, +fn extract_particles_draws( + particles_draws: Extract>, + particles_q: Extract>, buffers: Extract>, builtins: Extract>, - mut extracted: ResMut, + mut extracted: ResMut, ) { extracted.by_main.clear(); - for (entity, field_draw) in field_draws.iter() { - let Ok(field) = fields.get(field_draw.field) else { + for (entity, particles_draw) in particles_draws.iter() { + let Ok(p) = particles_q.get(particles_draw.particles) else { continue; }; - let Some(pos_entity) = field.buffer(builtins.position) else { + let Some(pos_entity) = p.buffer(builtins.position) else { continue; }; let Ok(pos_buf) = buffers.get(pos_entity) else { continue; }; - let rotation = field + let rotation = p .buffer(builtins.rotation) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); - let scale = field + let scale = p .buffer(builtins.scale) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); - let dead = field + let dead = p .buffer(builtins.dead) .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); @@ -257,7 +257,7 @@ fn extract_field_draws( }; extracted.by_main.insert( MainEntity::from(entity), - ExtractedFieldData { + ExtractedParticlesData { key, position: pos_buf.handle.clone(), rotation, @@ -269,17 +269,17 @@ fn extract_field_draws( } fn prepare_pack_bind_groups( - shader: Res, - mut pipelines: ResMut, + shader: Res, + mut pipelines: ResMut, pipeline_cache: Res, - extracted: Res, + extracted: Res, reservations: Res, batched_instance_buffers: Res>, culling_data_buffer: Res, gpu_buffers: Res>, render_device: Res, render_queue: Res, - mut bind_groups: ResMut, + mut bind_groups: ResMut, ) { bind_groups.per_batch.clear(); @@ -327,7 +327,7 @@ fn prepare_pack_bind_groups( } let cached = pipelines.by_key.get(&data.key).unwrap(); - let params = FieldPackParams { + let params = ParticlesPackParams { base_input_index: reservation.input_buffer_base, count: reservation.max_capacity, ..default() @@ -373,7 +373,7 @@ fn prepare_pack_bind_groups( }); let bind_group = render_device.create_bind_group( - Some("field_pack_bind_group"), + Some("particles_pack_bind_group"), &pipeline_cache.get_bind_group_layout(&cached.bind_group_layout), &entries, ); @@ -392,7 +392,7 @@ fn prepare_pack_bind_groups( fn dispatch_pack( mut render_context: RenderContext, - bind_groups: Res, + bind_groups: Res, pipeline_cache: Res, ) { if bind_groups.per_batch.is_empty() { @@ -402,7 +402,7 @@ fn dispatch_pack( let mut pass = render_context .command_encoder() .begin_compute_pass(&ComputePassDescriptor { - label: Some("field_pack"), + label: Some("particles_pack"), timestamp_writes: None, }); diff --git a/crates/processing_render/src/field/pack.wgsl b/crates/processing_render/src/particles/pack.wgsl similarity index 98% rename from crates/processing_render/src/field/pack.wgsl rename to crates/processing_render/src/particles/pack.wgsl index a84eb89..19730cb 100644 --- a/crates/processing_render/src/field/pack.wgsl +++ b/crates/processing_render/src/particles/pack.wgsl @@ -1,4 +1,4 @@ -// Pack pass — bridges libprocessing Field buffers into the upstream +// Pack pass — bridges libprocessing Particles buffers into the upstream // per-instance MeshInputUniform / MeshCullingData slots reserved by // `GpuBatchedMesh3d`. // diff --git a/crates/processing_render/src/field/field.wgsl b/crates/processing_render/src/particles/particles.wgsl similarity index 100% rename from crates/processing_render/src/field/field.wgsl rename to crates/processing_render/src/particles/particles.wgsl diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index c468a1f..3f57e06 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -297,6 +297,10 @@ pub enum DrawCommand { BackgroundColor(Color), BackgroundImage(Entity), Fill(Color), + /// Per-instance albedo source for `Field` draws — sets the ambient fill to + /// a `compute::Buffer` of `Float4` colors indexed by per-instance tag. + /// Mutually exclusive with `Fill(Color)`; setting either clears the other. + FillBuffer(Entity), NoFill, StrokeColor(Color), NoStroke, @@ -424,8 +428,8 @@ pub enum DrawCommand { angle: f32, }, Geometry(Entity), - Field { - field: Entity, + Particles { + particles: Entity, geometry: Entity, }, BlendMode(Option), diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 89cdcdf..185c792 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -24,7 +24,7 @@ use transform::TransformStack; use crate::{ Flush, - field::{Field, FieldDraw}, + particles::{Particles, ParticlesDraw}, geometry::Geometry, gltf::GltfNodeTransform, image::Image, @@ -49,6 +49,8 @@ pub struct RenderResources<'w, 's> { meshes: ResMut<'w, Assets>, materials: ResMut<'w, Assets>, custom_materials: ResMut<'w, Assets>, + particles_materials: ResMut<'w, Assets>, + particle_buffers: Query<'w, 's, &'static crate::compute::Buffer>, } struct BatchState { @@ -76,6 +78,10 @@ impl BatchState { #[derive(Debug, Component)] pub struct RenderState { pub fill_color: Option, + /// Per-instance color buffer for [`Particles`] draws. Mutually exclusive with + /// `fill_color` — set by [`DrawCommand::FillBuffer`], cleared by `Fill` / + /// `NoFill`. + pub fill_buffer: Option, pub stroke_color: Option, pub stroke_weight: f32, pub stroke_config: StrokeConfig, @@ -91,6 +97,7 @@ impl RenderState { pub fn new() -> Self { Self { fill_color: Some(Color::WHITE), + fill_buffer: None, stroke_color: Some(Color::BLACK), stroke_weight: 1.0, stroke_config: StrokeConfig::default(), @@ -109,6 +116,7 @@ impl RenderState { pub fn reset(&mut self) { self.fill_color = Some(Color::WHITE); + self.fill_buffer = None; self.stroke_color = Some(Color::BLACK); self.stroke_weight = 1.0; self.stroke_config = StrokeConfig::default(); @@ -160,7 +168,7 @@ pub fn flush_draw_commands( p_images: Query<&Image>, p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, - mut p_fields: Query<&mut Field>, + mut p_particles: Query<&mut Particles>, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() @@ -175,9 +183,15 @@ pub fn flush_draw_commands( match cmd { DrawCommand::Fill(color) => { state.fill_color = Some(color); + state.fill_buffer = None; + } + DrawCommand::FillBuffer(buf_entity) => { + state.fill_buffer = Some(buf_entity); + state.fill_color = None; } DrawCommand::NoFill => { state.fill_color = None; + state.fill_buffer = None; } DrawCommand::StrokeColor(color) => { state.stroke_color = Some(color); @@ -899,41 +913,55 @@ pub fn flush_draw_commands( batch.draw_index += 1; } - DrawCommand::Field { field, geometry } => { + DrawCommand::Particles { particles, geometry } => { let Some((geometry_data, _)) = p_geometries.get(geometry).ok() else { warn!("Could not find Geometry for entity {:?}", geometry); continue; }; - let Ok(mut field_data) = p_fields.get_mut(field) else { - warn!("Could not find Field for entity {:?}", field); + let Ok(mut particles_data) = p_particles.get_mut(particles) else { + warn!("Could not find Particles for entity {:?}", particles); continue; }; - let material_key = material_key_with_fill(&state); - let material_handle = match &material_key { - MaterialKey::Custom { - entity: mat_entity, - blend_state, - } => { - let Some(untyped) = p_material_handles.get(*mat_entity).ok() else { - warn!("Could not find material for entity {:?}", mat_entity); + // `fill(buffer)` short-circuits the regular material key + // path: build a `ParticlesMaterial` whose albedo comes + // from the buffer, indexed by per-instance tag. + let material_handle = if let Some(buf_entity) = state.fill_buffer { + match particles_fill_material(&mut res, buf_entity) { + Some(h) => h, + None => { + warn!("fill(buffer) entity {:?} not found", buf_entity); continue; - }; - clone_custom_material_with_blend( - &mut res.custom_materials, - &untyped.0, - *blend_state, - ) + } + } + } else { + let material_key = material_key_with_fill(&state); + match &material_key { + MaterialKey::Custom { + entity: mat_entity, + blend_state, + } => { + let Some(untyped) = p_material_handles.get(*mat_entity).ok() + else { + warn!("Could not find material for entity {:?}", mat_entity); + continue; + }; + clone_custom_material_with_blend( + &mut res.custom_materials, + &untyped.0, + *blend_state, + ) + } + _ => material_key.to_material(&mut res.materials), } - _ => material_key.to_material(&mut res.materials), }; flush_batch(&mut res, &mut batch, &p_material_handles); let mesh_handle = geometry_data.handle.clone(); - let capacity = field_data.capacity; + let capacity = particles_data.capacity; let render_layers = batch.render_layers.clone(); - match field_data.draw_entity { + match particles_data.draw_entity { Some(e) => { res.commands.entity(e).insert(( GpuBatchedMesh3d { @@ -957,11 +985,11 @@ pub fn flush_draw_commands( center: Vec3A::ZERO, half_extents: Vec3A::splat(1000.0), }, - FieldDraw { field }, + ParticlesDraw { particles }, render_layers, )) .id(); - field_data.draw_entity = Some(e); + particles_data.draw_entity = Some(e); } } @@ -1240,6 +1268,33 @@ fn material_key_with_color( } } +/// Build a `ParticlesMaterial` whose albedo comes from `buf_entity`, returning the +/// untyped handle ready to attach to the field's draw entity. The asset is +/// freshly allocated on every call — bind-group construction and uniform +/// upload are cheap and the bookkeeping isn't worth caching. +fn particles_fill_material( + res: &mut RenderResources, + buf_entity: Entity, +) -> Option { + use bevy::pbr::ExtendedMaterial; + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; + + let buf = res.particle_buffers.get(buf_entity).ok()?; + let handle = res.particles_materials.add(ParticlesMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 0.4, + metallic: 0.0, + cull_mode: None, + ..Default::default() + }, + extension: ParticlesExtension { + colors: buf.handle.clone(), + }, + }); + Some(handle.untyped()) +} + fn material_key_with_fill(state: &RenderState) -> MaterialKey { let color = state.fill_color.unwrap_or(Color::WHITE); material_key_with_color(&state.material_key, color, state.blend_state) diff --git a/docs/field.md b/docs/particles.md similarity index 68% rename from docs/field.md rename to docs/particles.md index 5052e5e..751eb94 100644 --- a/docs/field.md +++ b/docs/particles.md @@ -1,6 +1,6 @@ -# Field — GPU-resident particle and instancing +# Particles — GPU-resident particle and instancing -A `Field` is a GPU-resident container of named attribute buffers, drawn by instancing a +A `Particles` is a GPU-resident container of named attribute buffers, drawn by instancing a geometry once per element. It is the libprocessing analogue of a Houdini point cloud: a collection of points carrying arbitrary named attributes, where storage is contextual and attributes are first-class. @@ -9,12 +9,12 @@ The implementation rests on two existing libprocessing systems and one upstream - **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU storage buffers with CPU-side write, GPU readback, compute dispatch, and a Python wrapper that - tracks element type for validation. This is what backs every Field attribute buffer. + tracks element type for validation. This is what backs every Particles attribute buffer. - **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — named typed attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) shared between - Geometries (per-vertex) and Fields (per-instance). `BuiltinAttributes` exposes + Geometries (per-vertex) and Particles (per-instance). `BuiltinAttributes` exposes `position`, `normal`, `color`, `uv`, `rotation` (Float4 quat), `scale` (Float3), `dead` - (Float, 0=alive). The last three are field-only. + (Float, 0=alive). The last three are particles-only. - **Upstream `processing/bevy`** commit `ee443e51` adds `GpuBatchedMesh3d` and the `GpuInstanceBatchReservations` machinery — a fixed-capacity batch where a compute pass can write per-instance transforms into the upstream input buffer before @@ -22,11 +22,11 @@ The implementation rests on two existing libprocessing systems and one upstream ## Concepts -### Field +### Particles The top-level container. Holds a set of named attribute buffers (one per registered attribute), an optional persistent rasterization entity, a ring-buffer emit cursor, and -per-Field render state. Does not carry geometry — that's supplied at draw time. +per-Particles render state. Does not carry geometry — that's supplied at draw time. ### Attribute buffer @@ -35,34 +35,34 @@ elements. Backed by `compute::Buffer`. Indexed by particle slot. ### Attribute -The naming + type identity. A Field maps `Attribute` entities to `compute::Buffer` +The naming + type identity. A Particles maps `Attribute` entities to `compute::Buffer` entities. Lookups are typed entity comparisons, never strings. The Format declared at attribute creation is the source of truth for element byte size and shader-side semantics (Float4 rotation = quat, Float3 = position/scale, etc.). -### Draw verb: `field` +### Draw verb: `particles` -`field(f, shape)` (`DrawCommand::Field { field, geometry }`) is the rasterization verb. +`particles(f, shape)` (`DrawCommand::Particles { particles, geometry }`) is the rasterization verb. Reads ambient material at call time and instances `shape` once per slot in `f`. ## Construction -### Empty Field +### Empty Particles ```rust let position = geometry_attribute_position(); let velocity = geometry_attribute_create("velocity", AttributeFormat::Float3)?; -let f = field_create(10_000, vec![position, velocity])?; +let f = particles_create(10_000, vec![position, velocity])?; ``` Allocates one zero-initialized buffer per requested attribute, sized by `capacity * attr.format.byte_size()`. -### Mesh-seeded Field +### Mesh-seeded Particles ```rust let source = geometry_sphere(5.0, 32, 24)?; -let f = field_create_from_geometry( +let f = particles_create_from_geometry( source, vec![position_attr, uv_attr, color_attr], )?; @@ -71,14 +71,14 @@ let f = field_create_from_geometry( Capacity = mesh vertex count. Each registered attribute is pre-seeded from the matching mesh attribute when names + formats line up: -| Field attribute | Mesh attribute (Bevy) | +| Particles attribute | Mesh attribute (Bevy) | |----|----| | `position` (Float3) | `Mesh::ATTRIBUTE_POSITION` | | `normal` (Float3) | `Mesh::ATTRIBUTE_NORMAL` | | `color` (Float4) | `Mesh::ATTRIBUTE_COLOR` | | `uv` (Float2) | `Mesh::ATTRIBUTE_UV_0` | -Field-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero-init +Particles-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero-init (meshes don't carry them). ## Apply (attribute-buffer-only compute) @@ -87,10 +87,10 @@ Field-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero let shader = shader_create(SPIN_WGSL)?; let spin = compute_create(shader)?; compute_set(spin, "dt", ShaderValue::Float(0.016))?; -field_apply(field, spin)?; +particles_apply(field, spin)?; ``` -`field_apply` iterates the field's attribute buffers and calls `compute_set(compute, +`particles_apply` iterates the field's attribute buffers and calls `compute_set(compute, attr.name, ShaderValue::Buffer(buf_entity))` for each. Unknown shader properties are silently skipped, so the kernel only declares the attributes it needs. Workgroup size is fixed at 64 — kernels must declare `@workgroup_size(64)`. @@ -106,8 +106,8 @@ distinction is purely about placement. The pack pass is the only code that bridges to the upstream batch infrastructure. It runs as standard render-schedule systems: -- **`extract_field_draws`** (`ExtractSchedule`) — reads `FieldDraw` markers from main - world, copies (Field, position/rotation/scale/dead buffer handles) into render world. +- **`extract_particles_draws`** (`ExtractSchedule`) — reads `ParticlesDraw` markers from main + world, copies (Particles, position/rotation/scale/dead buffer handles) into render world. - **`prepare_pack_bind_groups`** (`RenderSystems::PrepareBindGroups`) — looks up or creates the pack pipeline for the field's specialization key, builds a bind group with the field's buffers + the upstream input/culling buffers + a uniform with `(base_index, @@ -115,7 +115,7 @@ as standard render-schedule systems: - **`dispatch_pack`** (`Core3d`, `before(early_gpu_preprocess)`) — dispatches the compute pass. -The pack shader (`field/pack.wgsl`) is specialized via shader_defs: +The pack shader (`particles/pack.wgsl`) is specialized via shader_defs: - `HAS_ROTATION` — bind a `rotation` buffer (Float4 quat). Otherwise identity. - `HAS_SCALE` — bind a `scale` buffer (Float3). Otherwise unit. @@ -133,31 +133,36 @@ Pipelines are cached per `PackPipelineKey { has_rotation, has_scale, has_dead }` ## Materials -Two material types support per-particle color via tag-indexed lookup. Both bind a -`colors: Handle` storage buffer and read `particle_colors[mesh.tag]`. +A single material type — `ParticlesMaterial` (`ExtendedMaterial`) — handles both lit and unlit per-particle color. The +extension binds `colors: Handle` and the shader reads +`particle_colors[mesh.tag]`. Lit vs unlit is the `unlit` flag on the base +`StandardMaterial`; `apply_pbr_lighting` short-circuits to base × particle color +when set. -### `FieldColorMaterial` (unlit) +### `fill(buffer)` — immediate-mode ```rust -let mat = material_create_field_color(color_buffer_entity)?; -graphics_record_command(g, DrawCommand::Material(mat))?; -graphics_record_command(g, DrawCommand::Field { field, geometry: shape })?; +graphics_record_command(g, DrawCommand::FillBuffer(color_buffer_entity))?; +graphics_record_command(g, DrawCommand::Particles { particles, geometry: shape })?; ``` -Outputs the per-particle color directly. Use for emissive / no-lighting particle effects. +Sets the ambient fill source to the buffer; the next `DrawCommand::Particles` +allocates a `ParticlesMaterial` carrying that buffer. No explicit material +construction needed. -### `FieldPbrMaterial` (PBR-lit) +### Explicit material with `albedo` source ```rust -let mat = material_create_field_pbr(color_buffer_entity)?; +let mat = material_create_pbr()?; +material_set_albedo_buffer(mat, color_buffer_entity)?; +material_set(mat, "roughness", ShaderValue::Float(0.4))?; ``` -`ExtendedMaterial`. Composes via -`pbr_input_from_standard_material` + `apply_pbr_lighting` — modulates the StandardMaterial -base color (default white) by the per-particle color. Standard PBR lighting (directional / -point / spot lights) applies normally. Default roughness 0.4, metallic 0.0; not yet -user-configurable via `material_set` (would need a dispatch arm for the new material -type). +`albedo` accepts either a constant color (`material_set_albedo_color`) or a +buffer (`material_set_albedo_buffer`); switching between them swaps the backing +asset type while preserving the `StandardMaterial` state (roughness / metallic / +emissive / unlit / etc.). ### Anything richer @@ -167,7 +172,7 @@ WGSL that reads `mesh.tag` and indexes into their own storage buffer. ## Emit (ring buffer) ```rust -field_emit( +particles_emit( field, n, vec![ @@ -196,7 +201,7 @@ registered, the pack pass reads it and writes `MeshCullingData::dead` — non-ze the slot is skipped in preprocessing and never rendered. Aging is user-managed: write an apply() shader that increments an age attribute and sets -`dead = 1.0` when age exceeds a threshold. The canonical pattern (`field_lifecycle.rs`): +`dead = 1.0` when age exceeds a threshold. The canonical pattern (`particles_lifecycle.rs`): ```wgsl @compute @workgroup_size(64) @@ -229,19 +234,19 @@ handle ordering. ## Immediate-mode compatibility The "automatic instancing of repeated draw calls with the same material" path remains the -non-Field instancing escape hatch. A user looping `translate; sphere()` gets -auto-instancing via `Mesh3d` for free, no Field needed. Field is for cases where compute +non-Particles instancing escape hatch. A user looping `translate; sphere()` gets +auto-instancing via `Mesh3d` for free, no Particles needed. Particles is for cases where compute matters or populations are large + dynamic. -`GpuBatchedMesh3d` (used by Field's transient draw entity) and `Mesh3d` are mutually +`GpuBatchedMesh3d` (used by Particles's transient draw entity) and `Mesh3d` are mutually exclusive on one entity by upstream design. ## v1 non-goals - **Chainable `apply()`** — currently flat function call. Quality of life. -- **Stateful builder methods on Field** (`field.color() / field.vertex()`) — the +- **Stateful builder methods on Particles** (`particles.color() / field.vertex()`) — the mesh-seeding path covers most cases. -- **Closure-based `createField(|| { sphere(); ... })` recording mode** — would need +- **Closure-based `create_particles(|| { sphere(); ... })` recording mode** — would need shape-API recording infrastructure (sphere/box dispatching into a Geometry instead of drawing). - **GPU-driven emission**, sparse alive set / compaction, multi-emitter pools, cross-field @@ -249,36 +254,36 @@ exclusive on one entity by upstream design. - **Per-instance attributes via `@location`** — upstream supports only the transform; the tag side-channel into a storage buffer is the only path for non-transform per-instance data. -- **Auto-default attribute reset on `field_emit`**. -- **User-configurable PBR properties** on `FieldPbrMaterial` (roughness, metallic) via +- **Auto-default attribute reset on `particles_emit`**. +- **User-configurable PBR properties** on `ParticlesMaterial` (roughness, metallic) via `material_set`. - **Built-in compute kernels** (NOISE, CURL, etc.) — packaged WGSL. - **Ping-pong apply**. ## Architectural notes -- **Pack pass schedule.** The original design intent was to tie pack to the `field(f, +- **Pack pass schedule.** The original design intent was to tie pack to the `particles(f, shape)` draw verb call (lazy, one-shot). The implementation runs pack as standard - render-schedule systems triggered by the `FieldDraw` marker on transient draw entities. + render-schedule systems triggered by the `ParticlesDraw` marker on transient draw entities. Same effect (pack only fires when there's something to draw), simpler integration. - **Per-particle color material.** The original design intent was to extend `ProcessingMaterial`. The implementation is two standalone material types - (`FieldColorMaterial`, `FieldPbrMaterial`). Standalone was cleaner; ambient `fill()` + (`ParticlesMaterial`, `ParticlesMaterial`). Standalone was cleaner; ambient `fill()` doesn't auto-tint particles, but the user explicitly opts in via the dedicated factory. -- **Persistent draw entity.** The Field's `draw_entity` must persist across frames — the +- **Persistent draw entity.** The Particles's `draw_entity` must persist across frames — the upstream batching queue processes mesh instance batches one frame after the reservation is created, so despawning per-frame would lose the entity before queueing. ## Examples -- `field_basic` — 1000 spheres on a 10×10×10 grid, static positions, default material. -- `field_animated` — same grid, rotating around Y via per-frame compute apply. -- `field_oriented` — 125 cubes with per-particle quaternion rotation + per-particle scale. -- `field_colored` — RGB-gradient cube via `FieldColorMaterial` (unlit). -- `field_colored_pbr` — same, lit with `FieldPbrMaterial`. -- `field_emit` — continuous ring-buffer emission in a spiral. -- `field_lifecycle` — fountain that emits particles with aging + shrink-on-death. -- `field_from_mesh` — particles positioned at the vertices of a source sphere mesh. +- `particles_basic` — 1000 spheres on a 10×10×10 grid, static positions, default material. +- `particles_animated` — same grid, rotating around Y via per-frame compute apply. +- `particles_oriented` — 125 cubes with per-particle quaternion rotation + per-particle scale. +- `particles_colored` — RGB-gradient cube via `ParticlesMaterial` (unlit). +- `particles_colored_pbr` — same, lit with `ParticlesMaterial`. +- `particles_emit` — continuous ring-buffer emission in a spiral. +- `particles_lifecycle` — fountain that emits particles with aging + shrink-on-death. +- `particles_from_mesh` — particles positioned at the vertices of a source sphere mesh. ## Fixed bugs (during development) diff --git a/examples/field_animated.rs b/examples/particles_animated.rs similarity index 89% rename from examples/field_animated.rs rename to examples/particles_animated.rs index bc7cc26..efcc3f7 100644 --- a/examples/field_animated.rs +++ b/examples/particles_animated.rs @@ -63,9 +63,9 @@ fn sketch() -> error::Result<()> { let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); let position_attr = geometry_attribute_position(); - let field = field_create(capacity, vec![position_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write(position_buf, bytes)?; let pbr = material_create_pbr()?; @@ -87,15 +87,12 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(pbr))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; compute_set(spin, "dt", shader_value::ShaderValue::Float(0.01))?; - field_apply(field, spin)?; + particles_apply(p, spin)?; } Ok(()) diff --git a/examples/field_basic.rs b/examples/particles_basic.rs similarity index 88% rename from examples/field_basic.rs rename to examples/particles_basic.rs index 25e1c71..104c85e 100644 --- a/examples/field_basic.rs +++ b/examples/particles_basic.rs @@ -40,9 +40,9 @@ fn sketch() -> error::Result<()> { let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); let position_attr = geometry_attribute_position(); - let field = field_create(capacity, vec![position_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write(position_buf, bytes)?; let pbr = material_create_pbr()?; @@ -61,10 +61,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(pbr))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; } diff --git a/examples/field_colored.rs b/examples/particles_colored.rs similarity index 85% rename from examples/field_colored.rs rename to examples/particles_colored.rs index 12c7da5..fc691ed 100644 --- a/examples/field_colored.rs +++ b/examples/particles_colored.rs @@ -42,11 +42,11 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); - let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write( position_buf, positions.iter().flat_map(|f| f.to_le_bytes()).collect(), @@ -67,10 +67,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; } diff --git a/examples/field_colored_pbr.rs b/examples/particles_colored_pbr.rs similarity index 85% rename from examples/field_colored_pbr.rs rename to examples/particles_colored_pbr.rs index 1f6c120..e5cc5fb 100644 --- a/examples/field_colored_pbr.rs +++ b/examples/particles_colored_pbr.rs @@ -45,11 +45,11 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); - let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write( position_buf, positions.iter().flat_map(|f| f.to_le_bytes()).collect(), @@ -70,10 +70,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; } diff --git a/examples/field_emit.rs b/examples/particles_emit.rs similarity index 88% rename from examples/field_emit.rs rename to examples/particles_emit.rs index 7df0761..ca27d5e 100644 --- a/examples/field_emit.rs +++ b/examples/particles_emit.rs @@ -25,11 +25,11 @@ fn sketch() -> error::Result<()> { let capacity: u32 = 2000; let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); - let field = field_create(capacity, vec![position_attr, color_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; // Push unemitted slots far off-screen so they don't all render at the // origin while the ring buffer is still filling. @@ -51,10 +51,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; @@ -83,8 +80,8 @@ fn sketch() -> error::Result<()> { let position_bytes: Vec = positions.iter().flat_map(|f| f.to_le_bytes()).collect(); let color_bytes: Vec = colors.iter().flat_map(|f| f.to_le_bytes()).collect(); - field_emit( - field, + particles_emit( + p, burst, vec![(position_attr, position_bytes), (color_attr, color_bytes)], )?; diff --git a/examples/field_emit_gpu.rs b/examples/particles_emit_gpu.rs similarity index 93% rename from examples/field_emit_gpu.rs rename to examples/particles_emit_gpu.rs index 15a86b4..873d578 100644 --- a/examples/field_emit_gpu.rs +++ b/examples/particles_emit_gpu.rs @@ -144,7 +144,7 @@ fn sketch() -> error::Result<()> { let velocity_attr = geometry_attribute_create("velocity", AttributeFormat::Float3)?; let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; - let field = field_create( + let p = particles_create( capacity, vec![ position_attr, @@ -157,15 +157,15 @@ fn sketch() -> error::Result<()> { )?; // Mark all unemitted slots dead so they don't render at origin. - let dead_buf = field_buffer(field, dead_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let dead_buf = particles_buffer(p, dead_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; let init_dead: Vec = (0..capacity) .flat_map(|_| 1.0_f32.to_le_bytes()) .collect(); buffer_write(dead_buf, init_dead)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; let spawn_shader = shader_create(SPAWN_SHADER)?; @@ -190,10 +190,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: particle, - }, + DrawCommand::Particles { particles: p, geometry: particle }, )?; graphics_end_draw(graphics)?; @@ -211,12 +208,12 @@ fn sketch() -> error::Result<()> { "speed", shader_value::ShaderValue::Float4([speed, 0.0, 0.0, 0.0]), )?; - field_emit_gpu(field, burst, spawn)?; + particles_emit_gpu(p, burst, spawn)?; compute_set(motion, "dt", shader_value::ShaderValue::Float(dt))?; compute_set(motion, "ttl", shader_value::ShaderValue::Float(ttl))?; compute_set(motion, "gravity", shader_value::ShaderValue::Float(gravity))?; - field_apply(field, motion)?; + particles_apply(p, motion)?; } Ok(()) diff --git a/examples/field_from_mesh.rs b/examples/particles_from_mesh.rs similarity index 89% rename from examples/field_from_mesh.rs rename to examples/particles_from_mesh.rs index 5a290c7..eefe29f 100644 --- a/examples/field_from_mesh.rs +++ b/examples/particles_from_mesh.rs @@ -33,11 +33,11 @@ fn sketch() -> error::Result<()> { // Position + uv come straight from the source sphere; color is allocated // empty and we fill it from uv values. - let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + let p = particles_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; let uv_buf = - field_buffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + particles_buffer(p, uv_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; let color_buf = - field_buffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + particles_buffer(p, color_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; // Read uvs back, build per-particle colors from them, write to color buffer. let uv_bytes = buffer_read(uv_buf)?; @@ -65,10 +65,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: particle, - }, + DrawCommand::Particles { particles: p, geometry: particle }, )?; graphics_end_draw(graphics)?; } diff --git a/examples/field_lifecycle.rs b/examples/particles_lifecycle.rs similarity index 93% rename from examples/field_lifecycle.rs rename to examples/particles_lifecycle.rs index 1246574..2969338 100644 --- a/examples/field_lifecycle.rs +++ b/examples/particles_lifecycle.rs @@ -68,7 +68,7 @@ fn sketch() -> error::Result<()> { let dead_attr = geometry_attribute_dead(); let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; - let field = field_create( + let p = particles_create( capacity, vec![ position_attr, @@ -78,10 +78,10 @@ fn sketch() -> error::Result<()> { age_attr, ], )?; - let dead_buf = field_buffer(field, dead_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let dead_buf = particles_buffer(p, dead_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; // Mark all slots dead initially so the unemitted ring slots don't render. let init_dead: Vec = (0..capacity) @@ -108,10 +108,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: sphere, - }, + DrawCommand::Particles { particles: p, geometry: sphere }, )?; graphics_end_draw(graphics)?; @@ -148,8 +145,8 @@ fn sketch() -> error::Result<()> { .collect::>() }) .collect(); - field_emit( - field, + particles_emit( + p, burst, vec![ (position_attr, position_bytes), @@ -166,7 +163,7 @@ fn sketch() -> error::Result<()> { "params", shader_value::ShaderValue::Float4([dt, ttl, 0.0, 0.0]), )?; - field_apply(field, aging)?; + particles_apply(p, aging)?; frame += 1; } diff --git a/examples/field_noise.rs b/examples/particles_noise.rs similarity index 87% rename from examples/field_noise.rs rename to examples/particles_noise.rs index eb33166..218a86a 100644 --- a/examples/field_noise.rs +++ b/examples/particles_noise.rs @@ -30,12 +30,12 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let uv_attr = geometry_attribute_uv(); let color_attr = geometry_attribute_color(); - let field = field_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + let p = particles_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; let uv_buf = - field_buffer(field, uv_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + particles_buffer(p, uv_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; let color_buf = - field_buffer(field, color_attr)?.ok_or(error::ProcessingError::FieldNotFound)?; + particles_buffer(p, color_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; // Color each particle by hue from its U coord. let uv_bytes = buffer_read(uv_buf)?; @@ -51,7 +51,7 @@ fn sketch() -> error::Result<()> { let particle = geometry_sphere(0.18, 10, 8)?; let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; - let noise = field_kernel_noise()?; + let noise = particles_kernel_noise()?; let start = Instant::now(); while glfw_ctx.poll_events() { @@ -63,10 +63,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: particle, - }, + DrawCommand::Particles { particles: p, geometry: particle }, )?; graphics_end_draw(graphics)?; @@ -74,7 +71,7 @@ fn sketch() -> error::Result<()> { compute_set(noise, "scale", shader_value::ShaderValue::Float(0.25))?; compute_set(noise, "strength", shader_value::ShaderValue::Float(0.02))?; compute_set(noise, "time", shader_value::ShaderValue::Float(t * 0.5))?; - field_apply(field, noise)?; + particles_apply(p, noise)?; } Ok(()) diff --git a/examples/field_oriented.rs b/examples/particles_oriented.rs similarity index 88% rename from examples/field_oriented.rs rename to examples/particles_oriented.rs index 79f8d97..c283d33 100644 --- a/examples/field_oriented.rs +++ b/examples/particles_oriented.rs @@ -87,13 +87,13 @@ fn sketch() -> error::Result<()> { let position_attr = geometry_attribute_position(); let rotation_attr = geometry_attribute_rotation(); let scale_attr = geometry_attribute_scale(); - let field = field_create(capacity, vec![position_attr, rotation_attr, scale_attr])?; - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let rotation_buf = field_buffer(field, rotation_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let scale_buf = field_buffer(field, scale_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let p = particles_create(capacity, vec![position_attr, rotation_attr, scale_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let rotation_buf = particles_buffer(p, rotation_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let scale_buf = particles_buffer(p, scale_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write( position_buf, positions.iter().flat_map(|f| f.to_le_bytes()).collect(), @@ -126,10 +126,7 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(pbr))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: cube, - }, + DrawCommand::Particles { particles: p, geometry: cube }, )?; graphics_end_draw(graphics)?; @@ -138,7 +135,7 @@ fn sketch() -> error::Result<()> { "params", shader_value::ShaderValue::Float4([0.015, 0.0, 0.0, 0.0]), )?; - field_apply(field, spin)?; + particles_apply(p, spin)?; } Ok(()) diff --git a/examples/field_stress.rs b/examples/particles_stress.rs similarity index 91% rename from examples/field_stress.rs rename to examples/particles_stress.rs index e6624ab..4331dcf 100644 --- a/examples/field_stress.rs +++ b/examples/particles_stress.rs @@ -83,7 +83,7 @@ fn sketch() -> error::Result<()> { let capacity = GRID * GRID * GRID; let position_attr = geometry_attribute_position(); let color_attr = geometry_attribute_color(); - let field = field_create(capacity, vec![position_attr, color_attr])?; + let p = particles_create(capacity, vec![position_attr, color_attr])?; let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); @@ -102,10 +102,10 @@ fn sketch() -> error::Result<()> { colors.push(rz); colors.push(1.0); } - let position_buf = field_buffer(field, position_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; - let color_buf = field_buffer(field, color_attr)? - .ok_or(error::ProcessingError::FieldNotFound)?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; buffer_write( position_buf, positions.iter().flat_map(|f| f.to_le_bytes()).collect(), @@ -130,15 +130,12 @@ fn sketch() -> error::Result<()> { graphics_record_command(graphics, DrawCommand::Material(mat))?; graphics_record_command( graphics, - DrawCommand::Field { - field, - geometry: cube, - }, + DrawCommand::Particles { particles: p, geometry: cube }, )?; graphics_end_draw(graphics)?; compute_set(spin, "dt", shader_value::ShaderValue::Float(0.003))?; - field_apply(field, spin)?; + particles_apply(p, spin)?; } Ok(()) From f74f8eb42321e475ee80410cbb276678930be6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Sat, 2 May 2026 13:21:31 -0700 Subject: [PATCH 11/11] More cleanup. --- .../examples/particles_basic.py | 2 - .../examples/particles_emit.py | 7 +- .../examples/particles_emit_gpu.py | 3 +- .../examples/particles_from_mesh.py | 2 - .../examples/particles_lifecycle.py | 4 +- .../examples/particles_noise.py | 2 - crates/processing_pyo3/src/compute.rs | 18 +- crates/processing_pyo3/src/graphics.rs | 2 - crates/processing_pyo3/src/lib.rs | 6 +- crates/processing_pyo3/src/material.rs | 25 +- crates/processing_pyo3/src/particles.rs | 59 ++-- crates/processing_render/src/lib.rs | 108 ++---- .../src/particles/kernels/mod.rs | 5 +- .../src/particles/kernels/noise.wgsl | 6 +- .../src/particles/kernels/transform.wgsl | 11 +- .../src/particles/material.rs | 19 +- crates/processing_render/src/particles/mod.rs | 43 +-- .../processing_render/src/particles/pack.rs | 10 +- .../processing_render/src/particles/pack.wgsl | 17 +- .../src/particles/particles.wgsl | 9 +- .../processing_render/src/render/command.rs | 5 +- crates/processing_render/src/render/mod.rs | 15 +- docs/particles.md | 316 ++++++------------ 23 files changed, 208 insertions(+), 486 deletions(-) diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py index d754130..d992b42 100644 --- a/crates/processing_pyo3/examples/particles_basic.py +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -13,8 +13,6 @@ def setup(): directional_light((0.95, 0.9, 0.85), 600.0) - # Source mesh whose vertices become particle positions; uvs come along for - # free and we use them to color each particle. source = Geometry.sphere(5.0, 32, 24) p = Particles( geometry=source, diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py index cf3d1a7..210be17 100644 --- a/crates/processing_pyo3/examples/particles_emit.py +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -21,8 +21,7 @@ def setup(): attributes=[Attribute.position(), Attribute.color()], ) - # Push unemitted slots far off-screen so they don't all render at the - # origin while the ring buffer is still filling. + # Park unemitted slots far off-screen until the ring buffer fills. pos_buf = p.buffer(Attribute.position()) pos_buf.write([1.0e6] * (capacity * 3)) @@ -39,9 +38,7 @@ def draw(): use_material(mat) particles(p, sphere) - # Emit 4 particles per frame in an outward-spiraling ring; once the ring - # buffer fills (~500 frames at 4/frame for capacity 2000), oldest get - # overwritten and the swirl continues without bound. + # Emit 4 particles per frame in an outward-spiraling ring. burst = 4 positions = [] colors = [] diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py index 4472bc4..2b57d31 100644 --- a/crates/processing_pyo3/examples/particles_emit_gpu.py +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -148,7 +148,7 @@ def setup(): ], ) - # Mark all unemitted slots dead so they don't render at origin. + # Park unemitted slots until the spawn kernel fills them. dead_buf = p.buffer(Attribute.dead()) dead_buf.write([1.0] * CAPACITY) @@ -167,7 +167,6 @@ def draw(): use_material(mat) particles(p, particle) - # Animate spawn point in a small circle so the fountain meanders. t = elapsed_time sx = math.cos(t) * 0.4 sz = math.sin(t) * 0.4 diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py index 8105c9e..4c9f762 100644 --- a/crates/processing_pyo3/examples/particles_from_mesh.py +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -13,8 +13,6 @@ def setup(): directional_light((0.95, 0.9, 0.85), 200.0) - # Source mesh whose vertices become the particle positions. UVs come along - # for free and we'll use them to paint each particle a unique color. source = Geometry.sphere(5.0, 32, 24) p = Particles( geometry=source, diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py index e42b520..3439a16 100644 --- a/crates/processing_pyo3/examples/particles_lifecycle.py +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -74,7 +74,7 @@ def setup(): attributes=[position_attr, color_attr, scale_attr, dead_attr, age_attr], ) - # Mark all slots dead initially so unemitted ring slots don't render. + # Park unemitted slots until the spawn loop fills them. dead_buf = p.buffer(dead_attr) dead_buf.write([1.0] * capacity) @@ -92,12 +92,10 @@ def draw(): use_material(mat) particles(p, sphere) - # Spawn `BURST` new particles per frame in a small fountain. positions = [] colors = [] for k in range(BURST): i = frame * BURST + k - # Cheap pseudo-random offset. u = (((i * 2654435761) >> 8) & 0xFFFF) / 65535.0 v = (((i * 40503) >> 8) & 0xFFFF) / 65535.0 theta = u * math.tau diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py index 26c08c8..c32bbb1 100644 --- a/crates/processing_pyo3/examples/particles_noise.py +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -14,8 +14,6 @@ def setup(): directional_light((0.95, 0.9, 0.85), 200.0) - # Seed positions from a sphere mesh; noise will jitter them around their - # initial sphere shape over time. source = Geometry.sphere(5.0, 32, 24) p = Particles( geometry=source, diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index 11064ca..98d7570 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -16,18 +16,14 @@ pub struct Buffer { pub(crate) entity: Entity, element_type: Option, size: u64, - /// `false` for buffers we created and own — `Drop` destroys the entity. - /// `true` for buffers we wrap (e.g. a Field's attribute buffer) where the - /// underlying entity belongs to someone else; destroying it would yank the - /// buffer out from under the owner. + /// `true` for borrowed wrappers (e.g. `Particles.buffer()`) where the + /// underlying entity belongs elsewhere; `Drop` skips destroy in that case. borrowed: bool, } impl Buffer { - /// Wrap an existing buffer entity (e.g., one owned by a Field). - /// `size` is queried from the buffer; `element_type` is supplied so typed - /// reads / `__getitem__` work correctly. The wrapper does NOT destroy the - /// entity on drop — ownership stays with whoever produced it. + /// Wrap an existing buffer entity without taking ownership. `Drop` will + /// not destroy it. pub(crate) fn from_entity(entity: Entity, element_type: Option) -> Self { let size = buffer_size(entity).unwrap_or(0); Self { @@ -144,10 +140,8 @@ impl Buffer { } pub fn write(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { - // Fast path: raw bytes go through unchanged. This is essential for - // large buffers where iterating Python objects would be unworkably - // slow (e.g. 1M-element fields). Element type is preserved if already - // known; otherwise the buffer stays untyped (read() returns bytes). + // Bytes path skips per-element conversion — the only viable route for + // multi-million-element uploads. if let Ok(b) = values.cast::() { return buffer_write(self.entity, b.as_bytes().to_vec()) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index a9de9d4..f6e4ef3 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -500,8 +500,6 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn fill(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - // `fill(buffer)` — per-particle albedo for `Particles` draws. Bypasses the - // color-mode parser and feeds the buffer entity straight through. if args.len() == 1 && let Ok(buf) = args.get_item(0)?.extract::>() { diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 6fba302..c850ef1 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -1146,10 +1146,8 @@ mod mewnala { Ok(()) }); - // Tear the App down gracefully while the thread-local is still alive, - // matching what `processing::exit()` does in Rust sketches. Without - // this the App falls to its eager thread-local destructor at process - // exit and a Bevy resource panics inside its own Drop, aborting. + // Tear down the App while the thread-local is still alive — letting + // it run via the eager TLS destructor aborts inside a Bevy resource Drop. let _ = ::processing::exit(0); result diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 5da23ff..5a057f3 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -21,7 +21,6 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult>() { return Ok(shader_value::ShaderValue::Float4(v.0.to_array())); } @@ -36,7 +35,6 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult() { return Ok(shader_value::ShaderValue::Float4(v)); } @@ -53,9 +51,8 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult) -> PyResult<()> { if let Ok(buf) = value.extract::>() { return material_set_albedo_buffer(entity, buf.entity) @@ -95,9 +92,8 @@ fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { #[pymethods] impl Material { - /// Construct a material. With no args, returns a default PBR. With a - /// `shader` arg, returns a custom material. Any kwargs (`albedo=...`, - /// `roughness=...`, etc.) are applied after construction. + /// No args: default PBR. With `shader`: custom material. Kwargs are + /// applied via `set` after construction. #[new] #[pyo3(signature = (shader=None, **kwargs))] pub fn new(shader: Option<&Shader>, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { @@ -114,8 +110,8 @@ impl Material { Ok(Self { entity }) } - /// PBR-lit material. `albedo` accepts a `Color` (solid) or a `Buffer` - /// (per-particle, indexed by per-instance tag — used with `Field`s). + /// PBR-lit material. `albedo` accepts a `Color` or a `Buffer` (the latter + /// being per-particle, used with `Particles`). #[staticmethod] #[pyo3(signature = (**kwargs))] pub fn pbr(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { @@ -126,8 +122,7 @@ impl Material { Ok(Self { entity }) } - /// Unlit material — same shape as `pbr` but skips lighting calculations - /// (the per-particle / solid color is the final output). + /// Like `pbr` but skips lighting; albedo is the final output color. #[staticmethod] #[pyo3(signature = (**kwargs))] pub fn unlit(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { @@ -140,10 +135,8 @@ impl Material { Ok(Self { entity }) } - /// Patch one or more material properties. `albedo` is special-cased and - /// may swap the backing asset type between solid-color and buffer-color - /// variants — all other `StandardMaterial` state (roughness, metallic, - /// emissive, alpha_mode, unlit, etc.) is preserved across the swap. + /// Patch material properties. `albedo` may swap the backing asset between + /// color and buffer variants; other StandardMaterial fields are preserved. #[pyo3(signature = (**kwargs))] pub fn set(&self, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { let Some(kwargs) = kwargs else { diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index 426935c..77e7334 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use crate::compute::{Buffer, Compute}; use crate::graphics::Geometry; -/// Per-element format of a field attribute / mesh vertex attribute. +/// Per-element format for an attribute. #[pyclass(eq, eq_int)] #[derive(Clone, Copy, PartialEq, Eq)] pub enum AttributeFormat { @@ -47,8 +47,8 @@ impl AttributeFormat { } } -/// Named typed attribute identity. Returned from the builtin `position()` / -/// `color()` / etc. classmethods, or constructed directly for custom attributes. +/// Named typed attribute. Use the `position()`/`color()`/etc. classmethods for +/// builtins or `Attribute(name, format)` for custom ones. #[pyclass(unsendable, frozen, hash, eq)] #[derive(Clone, PartialEq, Eq, Hash)] pub struct Attribute { @@ -97,8 +97,8 @@ impl Attribute { #[pyclass(unsendable)] pub struct Particles { pub(crate) entity: Entity, - /// Cached attribute metadata indexed by name, used to convert kwarg payloads - /// in `emit()` into the byte format the underlying `particles_emit` expects. + /// Name → (entity, format) so `emit(**kwargs)` can route kwargs to the + /// right attribute and pack them into bytes. name_to_attr: HashMap, } @@ -116,9 +116,8 @@ impl Particles { #[pymethods] impl Particles { - /// Construct a Particles container. Provide either `capacity` (allocates empty buffers) - /// or `geometry` (capacity = vertex count, buffers seeded from matching - /// mesh attributes), but not both. + /// Pass `capacity` for empty buffers, or `geometry` to seed positions + /// (and matching attributes) from a source mesh. Exactly one is required. #[new] #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] pub fn new( @@ -156,16 +155,14 @@ impl Particles { }) } - /// Number of slots reserved for this container. #[getter] pub fn capacity(&self) -> PyResult { particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Get the underlying `Buffer` for a registered attribute, or `None` if the - /// attribute isn't part of this container. The returned buffer's element type - /// matches the attribute's format so `read()` / `__getitem__` return typed - /// values (e.g. lists of vec3 components for a Float3 attribute). + /// Backing `Buffer` for a registered attribute, or `None` if not registered. + /// The element type matches the attribute's format so `read()` returns + /// typed values. pub fn buffer(&self, attribute: &Attribute) -> PyResult> { let buf = particles_buffer(self.entity, attribute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; @@ -180,12 +177,12 @@ impl Particles { Ok(buf.map(|e| Buffer::from_entity(e, Some(element_type)))) } - /// Run a compute kernel against these particles' buffers. Each buffer is - /// auto-bound by its attribute name. Any kwargs are forwarded to - /// `compute.set(...)` first, so callers can configure uniforms inline: + /// Dispatch a compute kernel against these particles' buffers. Buffers + /// are auto-bound by attribute name; kwargs are forwarded to + /// `compute.set(...)`. For example: /// /// ```python - /// field.apply(noise, scale=0.25, strength=0.02, time=t) + /// p.apply(noise, scale=0.25, strength=0.02, time=t) /// ``` #[pyo3(signature = (compute, **kwargs))] pub fn apply( @@ -200,12 +197,12 @@ impl Particles { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// CPU-driven emission. Per-attribute data is provided as kwargs keyed by - /// the attribute's name. Each value is a flat list of f32 values (the - /// length must equal `n * format.float_count()`). + /// Emit `n` particles into the next ring-buffer slots. Per-attribute data + /// is passed as kwargs keyed by attribute name; each value is a flat list + /// of `n * format.float_count()` floats. /// /// ```python - /// f.emit(50, position=[x0,y0,z0, x1,y1,z1, ...], color=[r0,g0,b0,a0, ...]) + /// p.emit(50, position=[x0,y0,z0, x1,y1,z1, ...], color=[r0,g0,b0,a0, ...]) /// ``` #[pyo3(signature = (n, **kwargs))] pub fn emit(&self, n: u32, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { @@ -218,7 +215,7 @@ impl Particles { let name: String = key.extract()?; let (attr_entity, fmt) = self.name_to_attr.get(&name).copied().ok_or_else(|| { PyRuntimeError::new_err(format!( - "field has no attribute named '{name}' (registered: {:?})", + "no attribute named '{name}' (registered: {:?})", self.name_to_attr.keys().collect::>() )) })?; @@ -238,11 +235,9 @@ impl Particles { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// GPU-driven emission. Dispatches `compute` over `n` invocations to - /// initialize the next `n` ring-buffer slots. The compute's buffer - /// bindings are auto-set; the `emit_range: vec4` uniform is auto-set - /// to `(base_slot, n, capacity, 0)`. User-set uniforms (spawn position, - /// velocity hint, etc.) must be assigned to the compute beforehand. + /// Emit `n` particles via a GPU kernel. Buffer bindings and a + /// `emit_range: vec4 = (base_slot, n, capacity, 0)` uniform are + /// auto-bound; set any other uniforms via `compute.set(...)` first. pub fn emit_gpu(&self, n: u32, compute: &Compute) -> PyResult<()> { particles_emit_gpu(self.entity, n, compute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -255,17 +250,15 @@ impl Drop for Particles { } } -/// Built-in noise compute kernel. Configure via `compute.set(scale=..., strength=..., time=...)`. +/// Built-in noise kernel. Uniforms: `scale`, `strength`, `time`. pub fn kernel_noise() -> PyResult { let entity = particles_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Compute::from_entity(entity)) } -/// Built-in transform compute kernel — applies an affine to each particle's -/// position in scale → axis-angle rotation → translate order. Configure via -/// `compute.set(translate=[tx,ty,tz], rotation_axis=[ax,ay,az], -/// rotation_angle=angle_rad, scale=[sx,sy,sz])`. Defaults of zero/one behave -/// as identity, so unset parameters are no-ops. +/// Built-in transform kernel: scale → axis-angle rotate → translate. Uniforms: +/// `translate: vec3`, `rotation_axis: vec3`, `rotation_angle: f32`, +/// `scale: vec3`. Identity defaults are seeded so unset uniforms are no-ops. pub fn kernel_transform() -> PyResult { let entity = particles_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Compute::from_entity(entity)) diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 8808e40..0cc5a88 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1395,10 +1395,9 @@ pub fn geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> error::Result< }) } -/// 3D lattice of `nx * ny * nz` points centered at the origin, with `spacing` -/// units between adjacent points. Topology is `PointList` — typically used as a -/// position source for [`particles_create_from_geometry`] rather than rasterized -/// directly. +/// 3D lattice of `nx * ny * nz` `PointList` vertices centered at the origin, +/// `spacing` units apart. Intended as a position source for +/// [`particles_create_from_geometry`]. pub fn geometry_grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> error::Result { app_mut(|app| { Ok(app @@ -1461,21 +1460,16 @@ pub fn material_create_pbr() -> error::Result { }) } -/// Create a default PBR material with `StandardMaterial::unlit = true` — -/// shorthand for `material_create_pbr` followed by setting `unlit`. +/// `material_create_pbr` with `unlit = true` set on the base StandardMaterial. pub fn material_create_unlit() -> error::Result { let entity = material_create_pbr()?; material_set(entity, "unlit", shader_value::ShaderValue::Float(1.0))?; Ok(entity) } -/// Set a material's albedo source to a constant color (RGBA, srgb space). -/// -/// If the material is currently backed by a buffer (i.e. an `ExtendedMaterial` -/// wrapping `ParticlesExtension`), this swaps the backing asset to the plain PBR -/// type while preserving every `StandardMaterial` field — `base_color` becomes -/// the new color, `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. -/// stay as previously set. +/// Set the albedo source to a constant srgba color. If the material is +/// currently buffer-backed, swaps the asset back to plain PBR while +/// preserving every other `StandardMaterial` field. pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Result<()> { use bevy::pbr::ExtendedMaterial; use crate::particles::material::ParticlesMaterial; @@ -1493,7 +1487,6 @@ pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Resu .clone(); let new_color = Color::srgba(color[0], color[1], color[2], color[3]); - // Already a default-PBR-backed material? Just patch base_color in place. if let Ok(handle) = untyped.clone().try_typed::() { let mut mats = app.world_mut().resource_mut::>(); let mat = mats @@ -1503,9 +1496,6 @@ pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Resu return Ok(()); } - // Particles-buffer-backed: read the StandardMaterial state, drop the old - // asset, create a fresh default-PBR asset carrying the same Std state - // plus the new base_color, then re-point the entity at it. let Ok(handle) = untyped.try_typed::() else { return Err(error::ProcessingError::MaterialNotFound); }; @@ -1533,16 +1523,11 @@ pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Resu }) } -/// Set a material's albedo source to a per-particle color buffer (RGBA `Float4`, -/// 16 bytes per slot, indexed by the per-instance tag the field pack pass -/// writes). -/// -/// If the material is currently a plain PBR (no buffer), this swaps the -/// backing asset to a `ParticlesMaterial` while preserving every `StandardMaterial` -/// field — `roughness`/`metallic`/`emissive`/`alpha_mode`/`unlit`/etc. all -/// carry over. The fragment shader modulates the existing `base_color` by the -/// per-particle color, so leaving `base_color = WHITE` (the default) gives -/// "use the buffer color verbatim"; setting it tints all particles. +/// Set the albedo source to a per-particle color buffer (`Float4` per slot, +/// indexed by `mesh.tag`). If the material is currently plain PBR, swaps the +/// asset to a `ParticlesMaterial` while preserving every other +/// `StandardMaterial` field. `base_color` modulates the buffer color, so +/// leaving it WHITE renders the buffer color verbatim. pub fn material_set_albedo_buffer( entity: Entity, color_buffer_entity: Entity, @@ -1578,8 +1563,6 @@ pub fn material_set_albedo_buffer( return Ok(()); } - // Default-PBR-backed: preserve StandardMaterial state, drop old asset, - // create a ParticlesMaterial with the same base + the buffer. let Ok(handle) = untyped.try_typed::() else { return Err(error::ProcessingError::MaterialNotFound); }; @@ -2055,11 +2038,9 @@ pub fn particles_create(capacity: u32, attribute_entities: Vec) -> error }) } -/// Create a Particles whose capacity matches `geometry`'s vertex count and whose -/// buffers are pre-seeded from the geometry's mesh attributes when names line -/// up (`position`, `normal`, `color`, `uv`). Custom attributes the mesh doesn't -/// supply are zero-initialized — the user fills them via `buffer_write` or -/// `particles_emit`. +/// Capacity = `geometry`'s vertex count. Builtin attributes (`position`, +/// `normal`, `color`, `uv`) are seeded from the matching mesh attribute when +/// formats line up; everything else is zero-initialized. pub fn particles_create_from_geometry( geometry_entity: Entity, attribute_entities: Vec, @@ -2102,23 +2083,11 @@ pub fn particles_buffer(entity: Entity, attribute_entity: Entity) -> error::Resu }) } -/// GPU-side emission. Dispatches `compute_entity` over `count` invocations to -/// initialize the next `count` ring-buffer slots. The framework auto-binds the -/// field's buffers (same convention as `particles_apply`) and sets a `vec4` -/// uniform named `emit_range` to `(base_slot, count, capacity, 0.0)` — the -/// kernel reads it to compute its target slot: -/// -/// ```wgsl -/// @group(0) @binding(N) var emit_range: vec4; -/// // ... -/// let local_i = gid.x; -/// if local_i >= u32(emit_range.y) { return; } -/// let slot = (u32(emit_range.x) + local_i) % u32(emit_range.z); -/// ``` -/// -/// Use this when per-particle initial state should be computed on the GPU -/// (random velocities, hashed colors, etc.). Use [`particles_emit`] when the CPU -/// already has the per-particle data. +/// GPU-driven emission. Dispatches `compute_entity` over `count` invocations +/// to initialize the next `count` ring-buffer slots. Auto-binds attribute +/// buffers (same convention as [`particles_apply`]) and a `vec4` uniform +/// `emit_range = (base_slot, count, capacity, 0)` from which the kernel +/// derives its target slot. CPU-side counterpart: [`particles_emit`]. pub fn particles_emit_gpu( particles_entity: Entity, count: u32, @@ -2185,10 +2154,9 @@ pub fn particles_emit_gpu( }) } -/// Emit `n` particles into [`Particles`], writing per-attribute byte payloads into the -/// next `n` slots starting at the particles' ring-buffer head. Each entry in -/// `attribute_data` must match the registered attribute's `byte_size * n`. -/// On wrap, oldest particles in the ring are overwritten. +/// CPU-driven emission. Writes per-attribute byte payloads into the next `n` +/// ring-buffer slots. Each entry in `attribute_data` must be exactly +/// `attr.byte_size * n` bytes. On wrap, oldest slots are overwritten. pub fn particles_emit( particles_entity: Entity, n: u32, @@ -2255,28 +2223,21 @@ pub fn particles_emit( }) } -/// Built-in noise kernel — perturbs each particle's `position` by sampled 3D -/// value noise. Configure via `compute_set("scale", Float(...))`, -/// `compute_set("strength", Float(...))`, `compute_set("time", Float(...))`. +/// Built-in noise kernel: displaces `position` by 3D value noise. Uniforms: +/// `scale: f32`, `strength: f32`, `time: f32`. pub fn particles_kernel_noise() -> error::Result { let shader = shader_load(particles::kernels::NOISE_PATH)?; compute_create(shader) } -/// Built-in transform kernel — applies an affine to each particle's `position` -/// in scale → axis-angle rotation → translate order. Configure via: -/// `compute_set("translate", Float3([tx, ty, tz]))`, -/// `compute_set("rotation_axis", Float3([ax, ay, az]))`, -/// `compute_set("rotation_angle", Float(angle_radians))`, -/// `compute_set("scale", Float3([sx, sy, sz]))`. Identity defaults are seeded -/// at creation time, so any unset parameter is a no-op (rather than zeroing -/// out positions). +/// Built-in transform kernel: scale → axis-angle rotate → translate on +/// `position`. Uniforms: `translate: vec3`, `rotation_axis: vec3`, +/// `rotation_angle: f32`, `scale: vec3`. Identity defaults are seeded so +/// any unset parameter behaves as a no-op (without them, default-zero +/// `scale` would collapse the field to the origin on the first dispatch). pub fn particles_kernel_transform() -> error::Result { let shader = shader_load(particles::kernels::TRANSFORM_PATH)?; let entity = compute_create(shader)?; - // The uniform struct is zero-initialized by default. Without these, - // an unset `scale` would multiply every position by zero on the first - // dispatch and collapse the whole field to the origin. compute_set(entity, "translate", shader_value::ShaderValue::Float3([0.0; 3]))?; compute_set(entity, "rotation_axis", shader_value::ShaderValue::Float3([0.0, 1.0, 0.0]))?; compute_set(entity, "rotation_angle", shader_value::ShaderValue::Float(0.0))?; @@ -2284,12 +2245,9 @@ pub fn particles_kernel_transform() -> error::Result { Ok(entity) } -/// Dispatch a compute pass against a [`Particles`]'s buffers. Each buffer is bound -/// by its attribute's name; bindings the shader doesn't declare are skipped. -/// Workgroup size is fixed at 64 — the shader must declare `@workgroup_size(64)`. -/// -/// Any non-buffer parameters (uniforms, etc.) on the compute should be set via -/// `compute_set` before calling this. +/// Dispatch `compute_entity` against the [`Particles`]'s buffers. Each buffer +/// is auto-bound by attribute name; undeclared bindings are skipped. Kernels +/// must declare `@workgroup_size(64)`. Set uniforms via `compute_set` first. pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { const WORKGROUP_SIZE: u32 = 64; diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs index 0360ed5..ca46709 100644 --- a/crates/processing_render/src/particles/kernels/mod.rs +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -1,6 +1,5 @@ -//! Built-in compute kernels for [`Particles`](super::Particles). Each kernel is -//! a small WGSL shader packaged with libprocessing as an embedded asset. Use -//! them via `particles_apply` after configuring parameters via `compute_set`. +//! Built-in compute kernels for [`Particles`](super::Particles), embedded as +//! assets and dispatched via `particles_apply`. use bevy::asset::embedded_asset; use bevy::prelude::*; diff --git a/crates/processing_render/src/particles/kernels/noise.wgsl b/crates/processing_render/src/particles/kernels/noise.wgsl index 6331b9c..72e5178 100644 --- a/crates/processing_render/src/particles/kernels/noise.wgsl +++ b/crates/processing_render/src/particles/kernels/noise.wgsl @@ -1,8 +1,4 @@ -// Built-in noise kernel — perturbs particle positions by sampled 3D value -// noise. Configure via `compute_set`: -// scale : f32 — input position scale (low = broad pattern) -// strength : f32 — output displacement magnitude per dispatch -// time : f32 — animation phase (offset the noise field) +// Per-particle position displacement by sampled 3D value noise. struct Params { scale: f32, diff --git a/crates/processing_render/src/particles/kernels/transform.wgsl b/crates/processing_render/src/particles/kernels/transform.wgsl index 93708a6..2c9c766 100644 --- a/crates/processing_render/src/particles/kernels/transform.wgsl +++ b/crates/processing_render/src/particles/kernels/transform.wgsl @@ -1,12 +1,4 @@ -// Built-in transform kernel — applies an affine to each particle's position. -// Order: scale, then rotate around `rotation_axis` by `rotation_angle` radians, -// then translate. Defaults of zero/one behave as identity. -// -// Configure via `compute_set`: -// translate : vec3 — applied last -// rotation_axis : vec3 — need not be normalized -// rotation_angle : f32 — radians -// scale : vec3 — per-axis scale factor +// Affine on each particle position: scale → axis-angle rotate → translate. struct Params { translate: vec3, @@ -18,7 +10,6 @@ struct Params { @group(0) @binding(0) var position: array; @group(0) @binding(1) var params: Params; -// Rodrigues' rotation. `axis` must be normalized; `angle` is in radians. fn rotate(p: vec3, axis: vec3, angle: f32) -> vec3 { let c = cos(angle); let s = sin(angle); diff --git a/crates/processing_render/src/particles/material.rs b/crates/processing_render/src/particles/material.rs index 2dd77b0..cb349c3 100644 --- a/crates/processing_render/src/particles/material.rs +++ b/crates/processing_render/src/particles/material.rs @@ -1,10 +1,6 @@ -//! `ParticlesMaterial` — `ExtendedMaterial` -//! whose per-particle color comes from a storage buffer indexed by the -//! per-instance tag (set to slot index by the pack pass). -//! -//! Lit vs unlit is just the `unlit` flag on the base `StandardMaterial`; -//! `apply_pbr_lighting` short-circuits to `base_color * particle_colors[tag]` -//! when `unlit = true`, so a single extension serves both cases. +//! Per-particle albedo on top of `StandardMaterial`. The `unlit` flag on the +//! base material toggles between lit and unlit; `apply_pbr_lighting` +//! short-circuits when set. use std::ops::Deref; @@ -25,9 +21,6 @@ impl Plugin for ParticlesMaterialPlugin { } } -/// PBR material extended with a per-particle color buffer. Set the base -/// `StandardMaterial`'s `unlit` flag to switch between lit and unlit behavior; -/// the rest of the material works identically either way. pub type ParticlesMaterial = ExtendedMaterial; #[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] @@ -46,10 +39,8 @@ impl MaterialExtension for ParticlesExtension { } } -/// Sibling of `add_processing_materials` / `add_custom_materials`. Promotes -/// `UntypedMaterial(handle)` entities whose handle is a [`ParticlesMaterial`] -/// to having the typed `MeshMaterial3d` component required -/// by the render pipeline. +/// Promote `UntypedMaterial(handle)` to `MeshMaterial3d` +/// where the handle's type matches. Sibling of `add_processing_materials`. pub fn add_particles_materials( mut commands: Commands, meshes: Query<(Entity, &UntypedMaterial)>, diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs index e3513a6..079de01 100644 --- a/crates/processing_render/src/particles/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -1,13 +1,4 @@ -//! GPU-resident particle / instancing container. -//! -//! [`Particles`] holds a set of named [`compute::Buffer`]s — one per registered -//! attribute. It is pure storage: it carries no instance shape and no material. -//! The shape is supplied at draw time via the `particles` verb, and the material -//! is read from ambient state at that point. Rasterization is layered on later -//! by spawning a transient `bevy::pbr::gpu_instance_batch::GpuBatchedMesh3d` -//! entity that consumes the buffers through the pack pass. -//! -//! See `docs/particles.md` for the full design. +//! GPU-resident particle / instancing container. See `docs/particles.md`. pub mod kernels; pub mod material; @@ -38,25 +29,16 @@ impl Plugin for ParticlesPlugin { } } -/// A GPU-resident container of named per-instance attribute buffers. -/// -/// `buffers` maps an [`Attribute`](crate::geometry::Attribute) entity to its backing -/// [`compute::Buffer`] entity. The set of registered attributes is fixed at creation. -/// -/// `draw_entity` is the persistent rasterization entity carrying `GpuBatchedMesh3d` and -/// the active material — created lazily on the first `particles` draw call and reused on -/// subsequent ones. It must persist across frames because the upstream batching queue -/// processes mesh instance batches one frame after the reservation is created; despawning -/// per-frame would lose the entity before it ever gets queued. -/// -/// `emit_head` is the ring-buffer write cursor used by `particles_emit`. New particles are -/// written to slots `[emit_head, emit_head + n) mod capacity` and the head advances by `n`. -/// When the ring wraps, oldest particles are overwritten — capacity is a visible contract. #[derive(Component)] pub struct Particles { pub capacity: u32, + /// `Attribute` entity → backing `compute::Buffer` entity. pub buffers: HashMap, + /// Lazy persistent rasterization entity. Must outlive the per-frame draw + /// because `GpuInstanceBatchReservations` queue mesh batches one frame + /// behind, so respawning per-frame loses the reservation. pub draw_entity: Option, + /// Ring-buffer write cursor for `particles_emit`. Wraps at `capacity`. pub emit_head: u32, } @@ -66,10 +48,7 @@ impl Particles { } } -/// Marker on a transient render entity indicating it rasterizes a [`Particles`]. -/// -/// The pack pass uses this to look up which Particles' buffers to read when writing -/// per-instance transforms into the upstream `mesh_input_buffer`. +/// Render-side marker pointing at the [`Particles`] entity to pack from. #[derive(Component, Clone, Copy)] pub struct ParticlesDraw { pub particles: Entity, @@ -108,11 +87,9 @@ pub fn create( Ok(entity) } -/// Create [`Particles`] whose capacity matches the source [`Geometry`]'s vertex -/// count and whose buffers are pre-seeded from the geometry's mesh attributes -/// where names line up. Any registered attribute the mesh doesn't supply (or -/// whose format doesn't match) gets zero-initialized — the user fills it in via -/// `buffer_write` or `particles_emit`. +/// Capacity = source mesh's vertex count. Registered attributes are seeded +/// from the matching mesh attribute (by name + format); unmatched ones are +/// zero-initialized. pub fn create_from_geometry( In((geom_entity, attribute_entities)): In<(Entity, Vec)>, mut commands: Commands, diff --git a/crates/processing_render/src/particles/pack.rs b/crates/processing_render/src/particles/pack.rs index b2d4c5d..c9a1f36 100644 --- a/crates/processing_render/src/particles/pack.rs +++ b/crates/processing_render/src/particles/pack.rs @@ -1,10 +1,6 @@ -//! Pack pass — bridges a [`Particles`]'s `position` / `rotation` / `scale` buffers into the -//! upstream `mesh_input_buffer[base..base+capacity].world_from_local` slots reserved by the -//! entity's [`GpuBatchedMesh3d`]. -//! -//! The pack shader is specialized via shader_defs (`HAS_ROTATION`, `HAS_SCALE`) based on -//! which builtin attributes the particles carry. Pipelines and bind-group layouts are cached -//! per shader_def combination. +//! Compute pass that writes [`Particles`] position/rotation/scale/dead into +//! the per-instance slots reserved by [`GpuBatchedMesh3d`]. Pipelines are +//! cached per `(HAS_ROTATION, HAS_SCALE, HAS_DEAD)` shader_def combination. use std::num::NonZeroU64; diff --git a/crates/processing_render/src/particles/pack.wgsl b/crates/processing_render/src/particles/pack.wgsl index 19730cb..e0c2b0c 100644 --- a/crates/processing_render/src/particles/pack.wgsl +++ b/crates/processing_render/src/particles/pack.wgsl @@ -1,17 +1,6 @@ -// Pack pass — bridges libprocessing Particles buffers into the upstream -// per-instance MeshInputUniform / MeshCullingData slots reserved by -// `GpuBatchedMesh3d`. -// -// Specialized via shader_defs: -// HAS_ROTATION — bind a `rotation` buffer (Float4 quaternion `xyzw`) -// HAS_SCALE — bind a `scale` buffer (Float3) -// HAS_DEAD — bind a `dead` buffer (Float, 0 = alive, non-zero = dead) -// -// Buffer formats (CPU-tightly-packed): -// position : 12 bytes per particle (Float3) -// rotation : 16 bytes per particle (Float4 quat) -// scale : 12 bytes per particle (Float3) -// dead : 4 bytes per particle (Float) +// Packs Particles position/rotation/scale/dead buffers into the per-instance +// MeshInputUniform / MeshCullingData slots reserved by `GpuBatchedMesh3d`. +// HAS_ROTATION / HAS_SCALE / HAS_DEAD shader_defs gate the optional bindings. struct MeshInput { world_from_local: mat3x4, diff --git a/crates/processing_render/src/particles/particles.wgsl b/crates/processing_render/src/particles/particles.wgsl index c36cdb5..0c17040 100644 --- a/crates/processing_render/src/particles/particles.wgsl +++ b/crates/processing_render/src/particles/particles.wgsl @@ -1,10 +1,5 @@ -// PBR per-particle color material for [`Field`] rasterization. -// -// Composes with `StandardMaterial` via `ExtendedMaterial`. The base material -// supplies all PBR properties (roughness, metallic, etc.); we modulate the -// resulting `base_color` by the per-particle color looked up from a storage -// buffer indexed by `mesh.tag` (the per-instance slot index written by the -// pack pass). +// Modulates StandardMaterial base_color by particle_colors[tag] then runs +// the standard PBR fragment. tag = per-instance slot index from pack.wgsl. #import bevy_pbr::{ pbr_fragment::pbr_input_from_standard_material, diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 3f57e06..8f65b66 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -297,9 +297,8 @@ pub enum DrawCommand { BackgroundColor(Color), BackgroundImage(Entity), Fill(Color), - /// Per-instance albedo source for `Field` draws — sets the ambient fill to - /// a `compute::Buffer` of `Float4` colors indexed by per-instance tag. - /// Mutually exclusive with `Fill(Color)`; setting either clears the other. + /// Per-instance albedo for `Particles`: a `compute::Buffer` of `Float4` + /// colors indexed by tag. Mutually exclusive with `Fill(Color)`. FillBuffer(Entity), NoFill, StrokeColor(Color), diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 185c792..77f1254 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -78,9 +78,8 @@ impl BatchState { #[derive(Debug, Component)] pub struct RenderState { pub fill_color: Option, - /// Per-instance color buffer for [`Particles`] draws. Mutually exclusive with - /// `fill_color` — set by [`DrawCommand::FillBuffer`], cleared by `Fill` / - /// `NoFill`. + /// Per-instance albedo buffer for [`Particles`] draws. Mutually exclusive + /// with `fill_color`. pub fill_buffer: Option, pub stroke_color: Option, pub stroke_weight: f32, @@ -923,9 +922,6 @@ pub fn flush_draw_commands( continue; }; - // `fill(buffer)` short-circuits the regular material key - // path: build a `ParticlesMaterial` whose albedo comes - // from the buffer, indexed by per-instance tag. let material_handle = if let Some(buf_entity) = state.fill_buffer { match particles_fill_material(&mut res, buf_entity) { Some(h) => h, @@ -1268,15 +1264,12 @@ fn material_key_with_color( } } -/// Build a `ParticlesMaterial` whose albedo comes from `buf_entity`, returning the -/// untyped handle ready to attach to the field's draw entity. The asset is -/// freshly allocated on every call — bind-group construction and uniform -/// upload are cheap and the bookkeeping isn't worth caching. +/// Allocate a fresh `ParticlesMaterial` reading albedo from `buf_entity`. +/// Not cached: bind-group + uniform upload is cheap enough at one per frame. fn particles_fill_material( res: &mut RenderResources, buf_entity: Entity, ) -> Option { - use bevy::pbr::ExtendedMaterial; use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; let buf = res.particle_buffers.get(buf_entity).ok()?; diff --git a/docs/particles.md b/docs/particles.md index 751eb94..322f213 100644 --- a/docs/particles.md +++ b/docs/particles.md @@ -1,157 +1,105 @@ -# Particles — GPU-resident particle and instancing - -A `Particles` is a GPU-resident container of named attribute buffers, drawn by instancing a -geometry once per element. It is the libprocessing analogue of a Houdini point cloud: a -collection of points carrying arbitrary named attributes, where storage is contextual and -attributes are first-class. - -The implementation rests on two existing libprocessing systems and one upstream contribution: - -- **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU storage - buffers with CPU-side write, GPU readback, compute dispatch, and a Python wrapper that - tracks element type for validation. This is what backs every Particles attribute buffer. -- **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — named typed - attribute identities (`AttributeFormat::{Float, Float2, Float3, Float4}`) shared between - Geometries (per-vertex) and Particles (per-instance). `BuiltinAttributes` exposes - `position`, `normal`, `color`, `uv`, `rotation` (Float4 quat), `scale` (Float3), `dead` - (Float, 0=alive). The last three are particles-only. -- **Upstream `processing/bevy`** commit `ee443e51` adds `GpuBatchedMesh3d` and the - `GpuInstanceBatchReservations` machinery — a fixed-capacity batch where a compute pass - can write per-instance transforms into the upstream input buffer before +# Particles + +A `Particles` is a GPU-resident container of named attribute buffers, drawn by +instancing a geometry once per element. The libprocessing analogue of a Houdini +point cloud. + +## Pieces + +- **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU + storage with CPU-side write, GPU readback, and a Python wrapper that tracks + element type. Backs every Particles attribute buffer. +- **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — + named typed attribute identity (`AttributeFormat::{Float, Float2, Float3, + Float4}`), shared between Geometries and Particles. Builtins: `position`, + `normal`, `color`, `uv`, plus the particles-only `rotation` (Float4 quat), + `scale` (Float3), `dead` (Float, 0=alive). +- **Upstream `processing/bevy`** commit `ee443e51` adds `GpuBatchedMesh3d` and + `GpuInstanceBatchReservations` — a fixed-capacity batch where a compute pass + writes per-instance transforms into the upstream input buffer before `early_gpu_preprocess` consumes them. -## Concepts - -### Particles - -The top-level container. Holds a set of named attribute buffers (one per registered -attribute), an optional persistent rasterization entity, a ring-buffer emit cursor, and -per-Particles render state. Does not carry geometry — that's supplied at draw time. - -### Attribute buffer - -A single typed GPU storage buffer holding the values for one attribute across all -elements. Backed by `compute::Buffer`. Indexed by particle slot. - -### Attribute - -The naming + type identity. A Particles maps `Attribute` entities to `compute::Buffer` -entities. Lookups are typed entity comparisons, never strings. The Format declared at -attribute creation is the source of truth for element byte size and shader-side semantics -(Float4 rotation = quat, Float3 = position/scale, etc.). - -### Draw verb: `particles` - -`particles(f, shape)` (`DrawCommand::Particles { particles, geometry }`) is the rasterization verb. -Reads ambient material at call time and instances `shape` once per slot in `f`. - ## Construction -### Empty Particles +Empty: ```rust -let position = geometry_attribute_position(); let velocity = geometry_attribute_create("velocity", AttributeFormat::Float3)?; -let f = particles_create(10_000, vec![position, velocity])?; +let p = particles_create(10_000, vec![geometry_attribute_position(), velocity])?; ``` -Allocates one zero-initialized buffer per requested attribute, sized by `capacity * -attr.format.byte_size()`. +One zero-initialized buffer per requested attribute, sized +`capacity * attr.format.byte_size()`. -### Mesh-seeded Particles +Mesh-seeded: ```rust let source = geometry_sphere(5.0, 32, 24)?; -let f = particles_create_from_geometry( +let p = particles_create_from_geometry( source, vec![position_attr, uv_attr, color_attr], )?; ``` -Capacity = mesh vertex count. Each registered attribute is pre-seeded from the matching -mesh attribute when names + formats line up: - -| Particles attribute | Mesh attribute (Bevy) | -|----|----| -| `position` (Float3) | `Mesh::ATTRIBUTE_POSITION` | -| `normal` (Float3) | `Mesh::ATTRIBUTE_NORMAL` | -| `color` (Float4) | `Mesh::ATTRIBUTE_COLOR` | -| `uv` (Float2) | `Mesh::ATTRIBUTE_UV_0` | - -Particles-only builtins (`rotation`, `scale`, `dead`) and custom attributes are zero-init -(meshes don't carry them). +Capacity = mesh vertex count. Builtins seed from the matching mesh attribute +(`position` ← `ATTRIBUTE_POSITION`, `normal` ← `ATTRIBUTE_NORMAL`, `color` ← +`ATTRIBUTE_COLOR`, `uv` ← `ATTRIBUTE_UV_0`); particles-only builtins and custom +attributes start at zero. -## Apply (attribute-buffer-only compute) +## Apply ```rust -let shader = shader_create(SPIN_WGSL)?; -let spin = compute_create(shader)?; +let spin = compute_create(shader_create(SPIN_WGSL)?)?; compute_set(spin, "dt", ShaderValue::Float(0.016))?; -particles_apply(field, spin)?; +particles_apply(p, spin)?; ``` -`particles_apply` iterates the field's attribute buffers and calls `compute_set(compute, -attr.name, ShaderValue::Buffer(buf_entity))` for each. Unknown shader properties are -silently skipped, so the kernel only declares the attributes it needs. Workgroup size is -fixed at 64 — kernels must declare `@workgroup_size(64)`. +`particles_apply` binds each attribute buffer by name; bindings the shader +doesn't declare are skipped. Workgroup size is fixed at 64. -The kernel's bind group only ever contains the field's attribute buffers + uniforms. The -kernel never touches upstream input/culling buffers — that's the pack pass's job. - -In `setup()` apply runs once; in `draw()` it runs every frame. The retained-vs-dynamic -distinction is purely about placement. +Built-in kernels: `particles_kernel_noise()` (uniforms `scale`, `strength`, +`time`), `particles_kernel_transform()` (`translate`, `rotation_axis`, +`rotation_angle`, `scale`, with identity defaults seeded so unset uniforms are +no-ops). ## Pack pass -The pack pass is the only code that bridges to the upstream batch infrastructure. It runs -as standard render-schedule systems: - -- **`extract_particles_draws`** (`ExtractSchedule`) — reads `ParticlesDraw` markers from main - world, copies (Particles, position/rotation/scale/dead buffer handles) into render world. -- **`prepare_pack_bind_groups`** (`RenderSystems::PrepareBindGroups`) — looks up or - creates the pack pipeline for the field's specialization key, builds a bind group with - the field's buffers + the upstream input/culling buffers + a uniform with `(base_index, - count)`. -- **`dispatch_pack`** (`Core3d`, `before(early_gpu_preprocess)`) — dispatches the compute - pass. - -The pack shader (`particles/pack.wgsl`) is specialized via shader_defs: +Bridges Particles attribute buffers into the per-instance slots reserved by +`GpuBatchedMesh3d`. Runs as render-schedule systems: -- `HAS_ROTATION` — bind a `rotation` buffer (Float4 quat). Otherwise identity. -- `HAS_SCALE` — bind a `scale` buffer (Float3). Otherwise unit. -- `HAS_DEAD` — bind a `dead` buffer (Float). Otherwise alive. +- `extract_particles_draws` (ExtractSchedule) — copies Particles + buffer + handles into the render world keyed by `ParticlesDraw` markers. +- `prepare_pack_bind_groups` (RenderSystems::PrepareBindGroups) — looks up or + builds the pack pipeline for the specialization key + bind group. +- `dispatch_pack` (Core3d, before `early_gpu_preprocess`) — dispatches. -For each particle slot the pack writes: +The pack shader (`particles/pack.wgsl`) is specialized per +`(HAS_ROTATION, HAS_SCALE, HAS_DEAD)`. For each slot it writes: -- `mesh_input_buffer[base+i].world_from_local` — `mat3x4` from rotation × scale + - position translation. -- `mesh_input_buffer[base+i].tag = i` — slot index, available to material shaders via +- `mesh_input_buffer[base+i].world_from_local` — `mat3x4` from rotation × scale + + position translation. +- `mesh_input_buffer[base+i].tag = i` — slot index, available via `mesh_functions::get_tag(instance_index)`. -- `MeshCullingData[base+i].dead` — from the dead buffer if present, else 0. - -Pipelines are cached per `PackPipelineKey { has_rotation, has_scale, has_dead }`. +- `MeshCullingData[base+i].dead` — from the `dead` buffer if present, else 0. ## Materials -A single material type — `ParticlesMaterial` (`ExtendedMaterial`) — handles both lit and unlit per-particle color. The -extension binds `colors: Handle` and the shader reads -`particle_colors[mesh.tag]`. Lit vs unlit is the `unlit` flag on the base -`StandardMaterial`; `apply_pbr_lighting` short-circuits to base × particle color -when set. +`ParticlesMaterial = ExtendedMaterial` +binds a `colors: Handle` and reads `particle_colors[mesh.tag]`. +Lit vs unlit is the `unlit` flag on the base `StandardMaterial`; +`apply_pbr_lighting` short-circuits when set. -### `fill(buffer)` — immediate-mode +Immediate-mode: ```rust graphics_record_command(g, DrawCommand::FillBuffer(color_buffer_entity))?; graphics_record_command(g, DrawCommand::Particles { particles, geometry: shape })?; ``` -Sets the ambient fill source to the buffer; the next `DrawCommand::Particles` -allocates a `ParticlesMaterial` carrying that buffer. No explicit material -construction needed. +`fill(buffer)` sets the ambient albedo source; the next +`DrawCommand::Particles` allocates a `ParticlesMaterial` carrying that buffer. -### Explicit material with `albedo` source +Explicit: ```rust let mat = material_create_pbr()?; @@ -159,21 +107,20 @@ material_set_albedo_buffer(mat, color_buffer_entity)?; material_set(mat, "roughness", ShaderValue::Float(0.4))?; ``` -`albedo` accepts either a constant color (`material_set_albedo_color`) or a -buffer (`material_set_albedo_buffer`); switching between them swaps the backing -asset type while preserving the `StandardMaterial` state (roughness / metallic / -emissive / unlit / etc.). +`material_set_albedo_buffer` / `material_set_albedo_color` swap the backing +asset between plain PBR and `ParticlesMaterial` while preserving every other +`StandardMaterial` field. -### Anything richer +Custom shaders (per-particle UV, per-particle scalars, anything beyond color) +require a `CustomMaterial` that reads `mesh.tag` and indexes its own buffer. -Per-particle UV, custom scalars, etc. require a `CustomMaterial` where the user writes -WGSL that reads `mesh.tag` and indexes into their own storage buffer. +## Emit -## Emit (ring buffer) +CPU-driven: ```rust particles_emit( - field, + p, n, vec![ (position_attr, position_bytes), // n * 12 bytes @@ -183,115 +130,42 @@ particles_emit( )?; ``` -Writes to slots `[head, head+n) mod capacity` via `compute::Buffer::write_buffer_cpu`, -then advances the field's `emit_head`. Two writes when wrapping. No GPU-side allocator, -no atomics, no compaction. - -When the ring wraps, oldest particles are overwritten — capacity is a visible contract: +Writes to `[head, head+n) mod capacity` and advances `emit_head`. Two writes +when wrapping. No GPU allocator, no compaction. Capacity is a visible contract: `>= peak_emission_rate × longest_lifespan`. -The user supplies bytes explicitly per attribute. There is no auto-default — if the field -has a `dead` attribute, the user must include it (typically as `n` zero-floats) or new -slots inherit the previous occupant's death. +GPU-driven: -## Lifecycle - -`dead` is a builtin Float attribute (0=alive, non-zero=dead). When the field has it -registered, the pack pass reads it and writes `MeshCullingData::dead` — non-zero means -the slot is skipped in preprocessing and never rendered. +```rust +particles_emit_gpu(p, n, spawn_kernel)?; +``` -Aging is user-managed: write an apply() shader that increments an age attribute and sets -`dead = 1.0` when age exceeds a threshold. The canonical pattern (`particles_lifecycle.rs`): +Auto-binds attribute buffers and `emit_range: vec4 = (base_slot, n, +capacity, 0)`. The kernel derives its target slot from `emit_range`. -```wgsl -@compute @workgroup_size(64) -fn main(@builtin(global_invocation_id) gid: vec3) { - let i = gid.x; - if i >= arrayLength(&age) { return; } - if dead[i] != 0.0 { return; } +No auto-defaults — if the field has a `dead` attribute, the caller must +include it (typically `n` zero-floats) or new slots inherit the previous +occupant's death. - age[i] = age[i] + dt; - let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); - let s = life * life; - scale[i*3+0] = s; scale[i*3+1] = s; scale[i*3+2] = s; // shrink +## Lifecycle - if age[i] > ttl { dead[i] = 1.0; } -} -``` +`dead` is a builtin Float attribute (0=alive, non-zero=dead). When registered, +the pack pass writes it into `MeshCullingData::dead`; non-zero slots are +skipped in preprocessing. -For unemitted ring-buffer slots, seed `dead = 1.0` at field-create time so they don't -render before being emitted into. - -## Compute model - -Default mutation mode is **in-place**. Most particle kernels (NOISE, CURL, drag, -integration) only read their own slot; in-place is correct and 2× cheaper than -ping-pong. Ping-pong (for kernels that read neighbor slots) is not yet shipped. - -Between sequential `apply()` calls, no buffer swap is needed — render-graph barriers -handle ordering. - -## Immediate-mode compatibility - -The "automatic instancing of repeated draw calls with the same material" path remains the -non-Particles instancing escape hatch. A user looping `translate; sphere()` gets -auto-instancing via `Mesh3d` for free, no Particles needed. Particles is for cases where compute -matters or populations are large + dynamic. - -`GpuBatchedMesh3d` (used by Particles's transient draw entity) and `Mesh3d` are mutually -exclusive on one entity by upstream design. - -## v1 non-goals - -- **Chainable `apply()`** — currently flat function call. Quality of life. -- **Stateful builder methods on Particles** (`particles.color() / field.vertex()`) — the - mesh-seeding path covers most cases. -- **Closure-based `create_particles(|| { sphere(); ... })` recording mode** — would need - shape-API recording infrastructure (sphere/box dispatching into a Geometry instead of - drawing). -- **GPU-driven emission**, sparse alive set / compaction, multi-emitter pools, cross-field - operations. -- **Per-instance attributes via `@location`** — upstream supports only the transform; the - tag side-channel into a storage buffer is the only path for non-transform per-instance - data. -- **Auto-default attribute reset on `particles_emit`**. -- **User-configurable PBR properties** on `ParticlesMaterial` (roughness, metallic) via - `material_set`. -- **Built-in compute kernels** (NOISE, CURL, etc.) — packaged WGSL. -- **Ping-pong apply**. - -## Architectural notes - -- **Pack pass schedule.** The original design intent was to tie pack to the `particles(f, - shape)` draw verb call (lazy, one-shot). The implementation runs pack as standard - render-schedule systems triggered by the `ParticlesDraw` marker on transient draw entities. - Same effect (pack only fires when there's something to draw), simpler integration. -- **Per-particle color material.** The original design intent was to extend - `ProcessingMaterial`. The implementation is two standalone material types - (`ParticlesMaterial`, `ParticlesMaterial`). Standalone was cleaner; ambient `fill()` - doesn't auto-tint particles, but the user explicitly opts in via the dedicated factory. -- **Persistent draw entity.** The Particles's `draw_entity` must persist across frames — the - upstream batching queue processes mesh instance batches one frame after the reservation - is created, so despawning per-frame would lose the entity before queueing. +Aging is user-managed via an apply kernel that increments age and flips +`dead` when age exceeds ttl. See `particles_lifecycle.rs`. Seed `dead = 1.0` +for unemitted ring slots so they don't render before being filled. ## Examples -- `particles_basic` — 1000 spheres on a 10×10×10 grid, static positions, default material. -- `particles_animated` — same grid, rotating around Y via per-frame compute apply. -- `particles_oriented` — 125 cubes with per-particle quaternion rotation + per-particle scale. -- `particles_colored` — RGB-gradient cube via `ParticlesMaterial` (unlit). -- `particles_colored_pbr` — same, lit with `ParticlesMaterial`. -- `particles_emit` — continuous ring-buffer emission in a spiral. -- `particles_lifecycle` — fountain that emits particles with aging + shrink-on-death. -- `particles_from_mesh` — particles positioned at the vertices of a source sphere mesh. - -## Fixed bugs (during development) - -- **`bevy_naga_reflect` struct uniform encoding.** `type_size` previously aligned every - struct member to 16 bytes (so 4 f32s claimed 64 bytes). `write_to_buffer` used - `encase::UniformBuffer::write` which resets to offset 0 each call — only the last - member's bytes survived. Both fixed in the local checkout at - `~/src/github.com/tychedelia/bevy_naga_reflect`. libprocessing's `Cargo.toml` points at - the local checkout via `path =` until the fix is pushed back. -- **`mode_3d` near-plane.** Was `camera_z / 10` (~60 units), which clipped particles when - the camera was moved closer via `transform_set_position`. Changed to fixed `near = 1.0`. +- `particles_basic` — sphere-mesh-seeded particle cloud, PBR per-particle color. +- `particles_animated` — 10×10×10 grid rotating around Y via custom apply. +- `particles_oriented` — per-particle quaternion + scale. +- `particles_colored` / `particles_colored_pbr` — explicit material setup. +- `particles_emit` — continuous CPU ring-buffer emission. +- `particles_emit_gpu` — fountain spawned by a compute kernel. +- `particles_lifecycle` — emit + age + shrink-on-death. +- `particles_from_mesh` — sphere mesh as position source. +- `particles_noise` — built-in noise kernel jittering positions. +- `particles_stress` — 1M cubes on a grid, R/G/B lights, transform spin.