From 467a53d63bbbedfd00fa4842ed103eb2eba8ca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Fri, 7 Aug 2026 21:58:20 -0700 Subject: [PATCH 1/3] . --- Cargo.lock | 1 + crates/processing_core/src/constants.rs | 12 + crates/processing_glfw/src/lib.rs | 200 +++-- .../examples/custom_material.py | 2 +- crates/processing_pyo3/examples/feedback.py | 56 ++ .../processing_pyo3/examples/flocking_duck.py | 2 +- .../processing_pyo3/examples/flocking_gpu.py | 2 +- crates/processing_pyo3/examples/gltf_load.py | 2 +- crates/processing_pyo3/examples/materials.py | 2 +- .../processing_pyo3/examples/multi_window.py | 50 ++ .../examples/particles_animated.py | 2 +- .../examples/particles_basic.py | 2 +- .../examples/particles_emit.py | 2 +- .../examples/particles_emit_gpu.py | 2 +- .../examples/particles_from_mesh.py | 2 +- .../examples/particles_lifecycle.py | 2 +- .../examples/particles_noise.py | 2 +- .../examples/particles_scatter_volume.py | 2 +- crates/processing_pyo3/examples/text.py | 33 + crates/processing_pyo3/mewnala/__init__.py | 9 +- crates/processing_pyo3/src/constants.rs | 4 + crates/processing_pyo3/src/graphics.rs | 753 ++++++++++++++++-- crates/processing_pyo3/src/lib.rs | 581 ++++++++++++-- crates/processing_pyo3/src/surface.rs | 21 + crates/processing_render/Cargo.toml | 2 + .../shaders/processing/filter.wesl | 6 + crates/processing_render/src/image.rs | 73 +- crates/processing_render/src/lib.rs | 99 ++- .../processing_render/src/render/command.rs | 61 ++ crates/processing_render/src/render/filter.rs | 2 + .../src/render/filters/composite.wgsl | 87 ++ .../src/render/filters/feedback.wgsl | 34 + crates/processing_render/src/surface.rs | 32 +- 33 files changed, 1925 insertions(+), 217 deletions(-) create mode 100644 crates/processing_pyo3/examples/feedback.py create mode 100644 crates/processing_pyo3/examples/multi_window.py create mode 100644 crates/processing_pyo3/examples/text.py create mode 100644 crates/processing_render/src/render/filters/composite.wgsl create mode 100644 crates/processing_render/src/render/filters/feedback.wgsl diff --git a/Cargo.lock b/Cargo.lock index 9b293a1b..56159367 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6196,6 +6196,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "wesl", + "wgpu", "windows 0.58.0", ] diff --git a/crates/processing_core/src/constants.rs b/crates/processing_core/src/constants.rs index 0e0413b1..9e7f4ad8 100644 --- a/crates/processing_core/src/constants.rs +++ b/crates/processing_core/src/constants.rs @@ -27,6 +27,18 @@ pub const CLOSE: bool = true; pub const LEFT: &str = "left"; pub const RIGHT: &str = "right"; +pub const TOP: &str = "top"; // vertical text align +pub const BOTTOM: &str = "bottom"; +pub const BASELINE: &str = "baseline"; + +pub const NORMAL: &str = "normal"; // text style +pub const ITALIC: &str = "italic"; +pub const BOLD: &str = "bold"; +pub const BOLD_ITALIC: &str = "bold_italic"; + +pub const WORD: &str = "word"; // text wrap mode +pub const CHAR: &str = "char"; + pub const NEAREST: &str = "nearest"; pub const CLAMP: &str = "clamp"; pub const REPEAT: &str = "repeat"; diff --git a/crates/processing_glfw/src/lib.rs b/crates/processing_glfw/src/lib.rs index e2dd3a80..9bf6a2f2 100644 --- a/crates/processing_glfw/src/lib.rs +++ b/crates/processing_glfw/src/lib.rs @@ -17,8 +17,15 @@ use processing_input::{ }; use processing_render::surface::{MonitorWorkarea, WindowControls}; +/// A single GLFW instance drives every window (GLFW's event pump is global). The +/// main window is `windows[0]`; `create_window` appends more. pub struct GlfwContext { glfw: Glfw, + windows: Vec, +} + +/// Per-window state. One `GlfwContext` owns many of these on the shared instance. +struct ManagedWindow { window: PWindow, events: GlfwReceiver<(f64, WindowEvent)>, surface: Option, @@ -58,15 +65,31 @@ impl Default for AppliedWindow { } impl GlfwContext { - pub fn new(width: u32, height: u32) -> Result { + pub fn new(width: u32, height: u32, transparent: bool) -> Result { let mut glfw = glfw::init(glfw::fail_on_errors).unwrap(); + let main = Self::spawn_window(&mut glfw, width, height, transparent, "Processing"); + Ok(Self { + glfw, + windows: vec![main], + }) + } + /// Create a GLFW window on the shared instance and return its per-window state. + fn spawn_window( + glfw: &mut Glfw, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> ManagedWindow { glfw.window_hint(glfw::WindowHint::ClientApi(glfw::ClientApiHint::NoApi)); glfw.window_hint(glfw::WindowHint::Visible(false)); - glfw.window_hint(glfw::WindowHint::TransparentFramebuffer(true)); + // Window transparency is an explicit opt-in; an opaque framebuffer is the + // default (a transparent-by-default window is surprising and platform-flaky). + glfw.window_hint(glfw::WindowHint::TransparentFramebuffer(transparent)); let (mut window, events) = glfw - .create_window(width, height, "Processing", WindowMode::Windowed) + .create_window(width, height, title, WindowMode::Windowed) .unwrap(); window.set_all_polling(true); @@ -101,14 +124,13 @@ impl GlfwContext { window.show(); - Ok(Self { - glfw, + ManagedWindow { window, events, surface: None, last_applied: AppliedWindow::default(), windowed_geometry: None, - }) + } } fn sync_monitors(&mut self) { @@ -207,67 +229,112 @@ impl GlfwContext { }); } - #[cfg(target_os = "macos")] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_macos; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_macos( - self.window.get_cocoa_window() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// Create the render surface for the main window (index 0). + pub fn create_surface(&mut self, width: u32, height: u32, transparent: bool) -> Result { + self.create_surface_for(0, width, height, transparent) } - #[cfg(target_os = "windows")] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_windows; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_windows( - self.window.get_win32_window() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// Create an additional window on the shared GLFW instance plus its render + /// surface, and return the new window's surface entity. + pub fn add_window( + &mut self, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> Result { + let mw = Self::spawn_window(&mut self.glfw, width, height, transparent, title); + self.windows.push(mw); + let idx = self.windows.len() - 1; + self.create_surface_for(idx, width, height, transparent) } - #[cfg(all(target_os = "linux", feature = "wayland"))] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_wayland; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_wayland( - self.window.get_wayland_window() as u64, - self.glfw.get_wayland_display() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// The surface entity of the main window, if created. + pub fn main_surface(&self) -> Option { + self.windows.first().and_then(|w| w.surface) } - #[cfg(all(target_os = "linux", feature = "x11"))] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_x11; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_x11( - self.window.get_x11_window() as u64, - self.glfw.get_x11_display() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); + fn create_surface_for( + &mut self, + idx: usize, + width: u32, + height: u32, + transparent: bool, + ) -> Result { + let (scale_factor, _) = self.windows[idx].window.get_content_scale(); + + #[cfg(target_os = "macos")] + let entity = { + use processing_render::surface_create_macos; + let handle = self.windows[idx].window.get_cocoa_window() as u64; + surface_create_macos(handle, width, height, scale_factor, transparent)? + }; + #[cfg(target_os = "windows")] + let entity = { + use processing_render::surface_create_windows; + let handle = self.windows[idx].window.get_win32_window() as u64; + surface_create_windows(handle, width, height, scale_factor, transparent)? + }; + #[cfg(all(target_os = "linux", feature = "wayland"))] + let entity = { + use processing_render::surface_create_wayland; + let wh = self.windows[idx].window.get_wayland_window() as u64; + let dh = self.glfw.get_wayland_display() as u64; + surface_create_wayland(wh, dh, width, height, scale_factor, transparent)? + }; + #[cfg(all(target_os = "linux", feature = "x11", not(feature = "wayland")))] + let entity = { + use processing_render::surface_create_x11; + let wh = self.windows[idx].window.get_x11_window() as u64; + let dh = self.glfw.get_x11_display() as u64; + surface_create_x11(wh, dh, width, height, scale_factor, transparent)? + }; + + self.windows[idx].surface = Some(entity); Ok(entity) } pub fn poll_events(&mut self) -> bool { self.glfw.poll_events(); + self.sync_monitors(); + // GLFW's pump is global; flush + sync each window on the shared instance. + // A closed secondary window is dropped; a closed main window ends the loop. + let GlfwContext { glfw, windows } = self; + let mut main_open = true; + let mut i = 0; + while i < windows.len() { + if windows[i].poll(glfw) { + i += 1; + } else if i == 0 { + main_open = false; + i += 1; + } else { + windows[i].window.hide(); + windows.remove(i); + } + } + + // Input is accumulated per-window above; commit it once for the frame. + if input_flush().is_err() { + return false; + } + main_open + } + + /// Content scale (DPI) of the main window. + pub fn content_scale(&self) -> f32 { + self.windows + .first() + .map(|w| w.window.get_content_scale().0) + .unwrap_or(1.0) + } +} + +impl ManagedWindow { + /// Flush this window's events and sync its OS state; returns whether it's + /// still open. Input is committed once per frame by the caller. + fn poll(&mut self, glfw: &mut Glfw) -> bool { let surface = match self.surface { Some(s) => s, None => { @@ -349,22 +416,18 @@ impl GlfwContext { processing_render::surface_resize(surface, width as u32, height as u32).unwrap(); } - let Ok(_) = input_flush() else { - return false; - }; self.sync_cursor(surface); - self.sync_monitors(); - self.sync_window(surface); + self.sync_window(glfw, surface); true } - fn sync_window(&mut self, surface: Entity) { + fn sync_window(&mut self, glfw: &mut Glfw, surface: Entity) { let Some(desired) = read_desired_window(surface) else { return; }; - self.apply_window(&desired); + self.apply_window(glfw, &desired); if desired.iconify { self.window.iconify(); @@ -412,7 +475,7 @@ impl GlfwContext { self.last_applied.position } - fn apply_window(&mut self, desired: &DesiredWindow) { + fn apply_window(&mut self, glfw: &mut Glfw, desired: &DesiredWindow) { let last = &mut self.last_applied; if desired.title != last.title { @@ -462,11 +525,11 @@ impl GlfwContext { last.opacity = opacity; } if desired.fullscreen_on != last.fullscreen_on { - self.apply_fullscreen(desired.fullscreen_on); + self.apply_fullscreen(glfw, desired.fullscreen_on); } } - fn apply_fullscreen(&mut self, target: Option) { + fn apply_fullscreen(&mut self, glfw: &mut Glfw, target: Option) { match target { Some(monitor_entity) => { if self.last_applied.fullscreen_on.is_none() { @@ -476,7 +539,7 @@ impl GlfwContext { } let target_name = monitor_name(monitor_entity); let window = &mut self.window; - let applied = self.glfw.with_connected_monitors(|_, monitors| { + let applied = glfw.with_connected_monitors(|_, monitors| { let Some(monitor) = monitors .iter() .find(|m| m.get_name() == target_name) @@ -506,11 +569,6 @@ impl GlfwContext { } } - pub fn content_scale(&self) -> f32 { - let (s, _) = self.window.get_content_scale(); - s - } - fn sync_cursor(&mut self, surface: Entity) { use bevy::window::CursorGrabMode; diff --git a/crates/processing_pyo3/examples/custom_material.py b/crates/processing_pyo3/examples/custom_material.py index e561008b..bfdaf37a 100644 --- a/crates/processing_pyo3/examples/custom_material.py +++ b/crates/processing_pyo3/examples/custom_material.py @@ -15,7 +15,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(12, 12, 18) - use_material(mat) + material(mat) box(80.0, 80.0, 80.0) run() diff --git a/crates/processing_pyo3/examples/feedback.py b/crates/processing_pyo3/examples/feedback.py new file mode 100644 index 00000000..22eb040b --- /dev/null +++ b/crates/processing_pyo3/examples/feedback.py @@ -0,0 +1,56 @@ +"""Classic TouchDesigner-style feedback patch. + +The TD network is: + + source (this frame) ─┐ + ├─► Composite ─┬─► Out (display) + Feedback ─► Level ───┘ │ + ▲ (decay) │ + └───────────────────────────────┘ feedback taps the composite output + +i.e. out(t) = composite( source(t), decay * out(t-1) ). + +Here the sketch canvas *is* the composite output. Because it isn't cleared each +frame, it persists and feeds back into itself. So: + + - feedback(decay=...) is the Feedback TOP + Level (samples the previous + composite output and fades it, optionally zooming/rotating it); + - the shapes drawn afterwards are this frame's source, composited *over* the + faded feedback; + - the canvas persists, closing the loop. +""" +from mewnala import * +from math import sin, cos + +t = 0.0 + + +def setup(): + size(800, 600) + background(0, 0, 0) # clear once; draw() never clears, so the canvas accumulates + + +def draw(): + global t + + # --- feedback line: previous composite output, decayed + slowly zoomed/rotated --- + feedback(decay=0.94, zoom=1.008, angle=0.006) + + # --- this frame's source, composited over the feedback (drawn on top) --- + no_stroke() + + x = width / 2 + cos(t) * 230 + y = height / 2 + sin(t * 1.3) * 170 + fill(0, 220, 255) + circle(x, y, 44) + + x2 = width / 2 + cos(t * 0.7 + 2.0) * 150 + y2 = height / 2 + sin(t * 1.1) * 120 + fill(255, 90, 200) + circle(x2, y2, 28) + + t += 0.03 + + +# TODO: this should happen implicitly on module load somehow +run() diff --git a/crates/processing_pyo3/examples/flocking_duck.py b/crates/processing_pyo3/examples/flocking_duck.py index 7b5495b1..12cb1959 100644 --- a/crates/processing_pyo3/examples/flocking_duck.py +++ b/crates/processing_pyo3/examples/flocking_duck.py @@ -266,7 +266,7 @@ def draw(): camera_look_at(center[0], center[1], center[2]) background(10, 12, 18) - use_material(mat) + material(mat) particles(p, boid) flock_pass.set( diff --git a/crates/processing_pyo3/examples/flocking_gpu.py b/crates/processing_pyo3/examples/flocking_gpu.py index 4faaeca4..9cba06de 100644 --- a/crates/processing_pyo3/examples/flocking_gpu.py +++ b/crates/processing_pyo3/examples/flocking_gpu.py @@ -244,7 +244,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(10, 12, 18) - use_material(mat) + material(mat) particles(p, boid) flock_pass.set( diff --git a/crates/processing_pyo3/examples/gltf_load.py b/crates/processing_pyo3/examples/gltf_load.py index 870687fe..b96d16f9 100644 --- a/crates/processing_pyo3/examples/gltf_load.py +++ b/crates/processing_pyo3/examples/gltf_load.py @@ -36,7 +36,7 @@ def draw(): duck_mat.set(base_color=[r, g, b, 1.0]) background(25) - use_material(duck_mat) + material(duck_mat) draw_geometry(duck_geo) frame += 1 diff --git a/crates/processing_pyo3/examples/materials.py b/crates/processing_pyo3/examples/materials.py index 194ee70e..2a608a98 100644 --- a/crates/processing_pyo3/examples/materials.py +++ b/crates/processing_pyo3/examples/materials.py @@ -21,7 +21,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(12, 12, 18) - use_material(mat) + material(mat) sphere(50.0) run() diff --git a/crates/processing_pyo3/examples/multi_window.py b/crates/processing_pyo3/examples/multi_window.py new file mode 100644 index 00000000..92316716 --- /dev/null +++ b/crates/processing_pyo3/examples/multi_window.py @@ -0,0 +1,50 @@ +from mewnala import * +from math import sin, cos, pi + +second_window = None +N = 7 + +def points(w, h, t): + pts = [] + for i in range(N): + angle = t * (0.3 + i * 0.05) + i * (2 * pi / N) + radius = min(w, h) * (0.18 + 0.12 * sin(t * 0.7 + i)) + x = w / 2 + cos(angle) * radius + y = h / 2 + sin(angle * 1.3) * radius + pts.append((x, y)) + return pts + + +def setup(): + global second_window + size(480, 480) + second_window = create_window(360, 360, "Second window") + + +def draw(): + t = frame_count * 0.03 + + background(20, 16, 28) + no_stroke() + for i, (x, y) in enumerate(points(width, height, t)): + fill(255, 120 + i * 15, 80) + circle(x, y, 26) + + g = second_window + g.background(10, 12, 24) + g.stroke(120, 180, 255) + g.stroke_weight(1.5) + pts = points(g.width, g.height, t) + link = (g.width * 0.35) ** 2 + for i in range(N): + for j in range(i + 1, N): + (x1, y1), (x2, y2) = pts[i], pts[j] + if (x1 - x2) ** 2 + (y1 - y2) ** 2 < link: + g.line(x1, y1, x2, y2) + g.no_stroke() + g.fill(220, 235, 255) + for (x, y) in pts: + g.circle(x, y, 6) + + +run() diff --git a/crates/processing_pyo3/examples/particles_animated.py b/crates/processing_pyo3/examples/particles_animated.py index 26b209eb..d84ddd6a 100644 --- a/crates/processing_pyo3/examples/particles_animated.py +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -61,7 +61,7 @@ def draw(): background(15, 15, 20) fill(230, 128, 75) - use_material(mat) + material(mat) particles(p, sphere) spin.set(dt=0.01) diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py index d992b42d..44ddde34 100644 --- a/crates/processing_pyo3/examples/particles_basic.py +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -37,7 +37,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(15, 15, 20) - use_material(mat) + material(mat) particles(p, particle) diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py index 23dad914..4153e416 100644 --- a/crates/processing_pyo3/examples/particles_emit.py +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -34,7 +34,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(15, 15, 20) - use_material(mat) + material(mat) particles(p, sphere) burst = 4 diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py index 943a991b..d70027e7 100644 --- a/crates/processing_pyo3/examples/particles_emit_gpu.py +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -160,7 +160,7 @@ def draw(): camera_look_at(0.0, 2.0, 0.0) background(10, 10, 18) - use_material(mat) + material(mat) particles(p, particle) t = elapsed_time diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py index 4c9f762f..b6e9a5a9 100644 --- a/crates/processing_pyo3/examples/particles_from_mesh.py +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -37,7 +37,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(15, 15, 20) - use_material(mat) + material(mat) particles(p, particle) diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py index eae7df01..8b5f437f 100644 --- a/crates/processing_pyo3/examples/particles_lifecycle.py +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -84,7 +84,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(10, 10, 18) - use_material(mat) + material(mat) particles(p, sphere) positions = [] diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py index c1e7dd8b..1eb0aa60 100644 --- a/crates/processing_pyo3/examples/particles_noise.py +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -39,7 +39,7 @@ def draw(): camera_look_at(0.0, 0.0, 0.0) background(15, 15, 20) - use_material(mat) + material(mat) particles(p, particle) noise.set(scale=0.25, strength=0.02, time=elapsed_time * 0.5) diff --git a/crates/processing_pyo3/examples/particles_scatter_volume.py b/crates/processing_pyo3/examples/particles_scatter_volume.py index 30ca4490..045b79e5 100644 --- a/crates/processing_pyo3/examples/particles_scatter_volume.py +++ b/crates/processing_pyo3/examples/particles_scatter_volume.py @@ -44,7 +44,7 @@ def setup(): def draw(): background(8, 8, 13) - use_material(mat) + material(mat) particles(p, particle) seed = (int(elapsed_time * 1000.0) ^ 0xC0FFEE) & 0xFFFFFFFF diff --git a/crates/processing_pyo3/examples/text.py b/crates/processing_pyo3/examples/text.py new file mode 100644 index 00000000..fcad5e91 --- /dev/null +++ b/crates/processing_pyo3/examples/text.py @@ -0,0 +1,33 @@ +"""Drawing type through the global text API (text, text_size, text_align, ...).""" +from mewnala import * +from math import sin + + +def setup(): + size(640, 360) + + +def draw(): + background(16, 16, 24) + + # Centered title. + fill(255) + text_align(CENTER, CENTER) + text_size(56) + text("hello, processing", width / 2, height / 2) + + # Pulsing subtitle. + pulse = 150 + 105 * sin(frame_count * 0.05) + fill(120, pulse, 255) + text_size(20) + text("global text now works", width / 2, height / 2 + 60) + + # Bottom-left frame counter, left/baseline aligned. + fill(180) + text_align(LEFT, BASELINE) + text_size(14) + text(f"frame {frame_count}", 16, height - 16) + + +# TODO: this should happen implicitly on module load somehow +run() diff --git a/crates/processing_pyo3/mewnala/__init__.py b/crates/processing_pyo3/mewnala/__init__.py index 318c81da..f3d6f78a 100644 --- a/crates/processing_pyo3/mewnala/__init__.py +++ b/crates/processing_pyo3/mewnala/__init__.py @@ -1,5 +1,10 @@ from .mewnala import * +# `from .mewnala import *` above binds Processing globals that shadow Python +# builtins (e.g. `set`, `filter`, `get`), so this module reaches builtins it needs +# through an explicit alias rather than the shadowed names. +import builtins as _builtins + # re-export the native submodules as submodules of this module, if they exist # this allows users to import from `mewnala.math` without needing to know about # the internal structure of the native module @@ -112,10 +117,10 @@ def __getattr__(name): def __dir__(): - return sorted(set(list(globals().keys()) + list(_DYNAMIC))) + return sorted(_builtins.set(list(globals().keys()) + list(_DYNAMIC))) __all__ = sorted( - {n for n in dir(_native) if not n.startswith("_")} | set(_DYNAMIC) + {n for n in dir(_native) if not n.startswith("_")} | _builtins.set(_DYNAMIC) ) del _sys, _name, _sub diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs index 2d4fbbd6..60c8be28 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -38,6 +38,10 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("DILATE", crate::filter::DILATE_U8)?; add!(m, LEFT, RIGHT); + // text align (LEFT/RIGHT/CENTER above), style, and wrap constants + add!(m, TOP, BOTTOM, BASELINE); + add!(m, NORMAL, ITALIC, BOLD, BOLD_ITALIC); + add!(m, WORD, CHAR); add!(m, NEAREST, CLAMP, REPEAT, MIRROR); add!(m, SRGB, LINEAR, HSL, HSV, HWB, OKLAB, OKLCH, LAB, LCH, XYZ); add!( diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 171d1600..3e98cfbf 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -3,8 +3,8 @@ use crate::glfw::GlfwContext; use crate::input; use crate::math::{extract_vec2, extract_vec3, extract_vec4}; use bevy::{ - color::{ColorToPacked, Srgba}, - math::{Vec3, Vec4}, + color::{ColorToPacked, LinearRgba, Srgba}, + math::{Affine3A, Mat4, Vec3, Vec4}, prelude::Entity, render::render_resource::{Extent3d, TextureFormat}, }; @@ -12,12 +12,194 @@ use processing::prelude::*; use pyo3::{ exceptions::{PyRuntimeError, PyValueError}, prelude::*, - types::{PyDict, PyTuple}, + types::{PyDict, PyList, PyTuple}, }; +use std::sync::Mutex; #[cfg(feature = "cuda")] use crate::cuda::CudaImage; +/// Flatten `*args` of numbers (or a single list/tuple of numbers) into a `Vec`. +fn flatten_floats(args: &Bound<'_, PyTuple>) -> PyResult> { + if args.len() == 1 { + if let Ok(seq) = args.get_item(0)?.extract::>() { + return Ok(seq); + } + } + let mut out = Vec::with_capacity(args.len()); + for item in args.iter() { + out.push(item.extract::()?); + } + Ok(out) +} + +/// Build a Python list of `Color` objects from linear-RGBA pixels. +fn pixels_to_pylist(py: Python<'_>, pixels: &[LinearRgba]) -> PyResult> { + let colors = pixels + .iter() + .map(|p| crate::color::PyColor(bevy::prelude::Color::LinearRgba(*p))); + Ok(PyList::new(py, colors)?.unbind()) +} + +/// Convert a Python sequence of colors (as returned by `load_pixels()`, or +/// individual `Color`/`[r,g,b,a]` values) into linear-RGBA pixels. +fn pyseq_to_pixels(seq: &Bound<'_, PyAny>) -> PyResult> { + let mut out = Vec::with_capacity(seq.len().unwrap_or(0)); + for item in seq.try_iter()? { + let item = item?; + let color = if let Ok(c) = item.extract::() { + c.0 + } else if let Ok(rgba) = item.extract::<[f32; 4]>() { + bevy::prelude::Color::LinearRgba(LinearRgba::new(rgba[0], rgba[1], rgba[2], rgba[3])) + } else if let Ok(rgb) = item.extract::<[f32; 3]>() { + bevy::prelude::Color::LinearRgba(LinearRgba::new(rgb[0], rgb[1], rgb[2], 1.0)) + } else { + return Err(PyValueError::new_err( + "pixels must contain Color values or [r, g, b, (a)] sequences", + )); + }; + out.push(LinearRgba::from(color)); + } + Ok(out) +} + +/// Resolve the sequence `update_pixels()` should write: an explicit argument if +/// given, otherwise the buffer cached by `load_pixels()`. +fn resolve_pixels_arg<'py>( + py: Python<'py>, + pixels: Option<&Bound<'py, PyAny>>, + cache: &Mutex>>, +) -> PyResult> { + match pixels { + Some(p) => Ok(p.clone()), + None => { + let cached = cache.lock().unwrap().as_ref().map(|l| l.clone_ref(py)); + match cached { + Some(list) => Ok(list.into_bound(py).into_any()), + None => Err(PyRuntimeError::new_err( + "update_pixels() called before load_pixels()", + )), + } + } + } +} + +fn rt_err(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) +} + +/// Composite mode code for `mask()` (see `filters/composite.wgsl`). +const COMPOSITE_MODE_MASK: u32 = 10; +/// Composite mode code for `copy()` — REPLACE (matches `BlendMode::Replace`). +const COMPOSITE_MODE_REPLACE: u32 = 9; + +/// Normalize a pixel rect `(x, y, w, h)` to uv-space `(x0, y0, x1, y1)`; `None` +/// means the full `(0, 0, 1, 1)` extent. +fn normalize_rect(rect: Option<[f32; 4]>, w: f32, h: f32) -> [f32; 4] { + match rect { + None => [0.0, 0.0, 1.0, 1.0], + Some([x, y, rw, rh]) => [x / w, y / h, (x + rw) / w, (y + rh) / h], + } +} + +/// Normalize a source pixel rect against the source image's own dimensions. +fn normalize_src_rect(entity: Entity, rect: Option<[f32; 4]>) -> PyResult<[f32; 4]> { + match rect { + None => Ok([0.0, 0.0, 1.0, 1.0]), + Some(r) => { + let (w, h) = image_size(entity).map_err(rt_err)?; + Ok(normalize_rect(Some(r), w as f32, h as f32)) + } + } +} + +/// Resolve a composite source (`Image`, `Webcam`, or `Graphics`) to a +/// texture-bearing entity, plus the graphics entity that must be flushed first +/// when the source is a graphics (so its latest content is what gets sampled). +/// A `Graphics` source uses its render-target surface entity, which carries an +/// `Image` component for offscreen graphics. +fn resolve_composite_source(src: &Bound<'_, PyAny>) -> PyResult<(Entity, Option)> { + if let Ok(img) = src.extract::() { + return Ok((img.entity, None)); + } + if let Ok(g) = src.extract::>() { + return Ok((g.surface.entity, Some(g.entity))); + } + Err(PyValueError::new_err( + "composite source must be an Image, Webcam, or Graphics", + )) +} + +/// Run the built-in composite filter: blend the `src` texture into the `dst` +/// graphics target using `mode`, with normalized src/dst rects. This is the +/// shader-based composite pass shared by `copy`/`blend`/`mask`. +fn composite_apply( + dst: Entity, + src: Entity, + mode: u32, + src_rect: [f32; 4], + dst_rect: [f32; 4], + opacity: f32, +) -> PyResult<()> { + use shader_value::ShaderValue; + let filter = filter_composite().map_err(rt_err)?; + filter_set(filter, "src", ShaderValue::Texture(src)).map_err(rt_err)?; + filter_set(filter, "mode", ShaderValue::UInt(mode)).map_err(rt_err)?; + filter_set(filter, "opacity", ShaderValue::Float(opacity)).map_err(rt_err)?; + filter_set(filter, "src_rect", ShaderValue::Float4(src_rect)).map_err(rt_err)?; + filter_set(filter, "dst_rect", ShaderValue::Float4(dst_rect)).map_err(rt_err)?; + graphics_apply_filter(dst, filter).map_err(rt_err) +} + +/// Write raw sRGB RGBA bytes to a PNG file on disk. +fn write_png_file(path: &str, width: u32, height: u32, rgba: &[u8]) -> PyResult<()> { + let lower = path.to_lowercase(); + if !lower.ends_with(".png") { + return Err(PyValueError::new_err(format!( + "save() currently supports only .png files, got {path:?}" + ))); + } + let file = std::fs::File::create(path) + .map_err(|e| PyRuntimeError::new_err(format!("create {path}: {e}")))?; + let writer = std::io::BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + let mut writer = encoder + .write_header() + .map_err(|e| PyRuntimeError::new_err(format!("PNG header: {e}")))?; + writer + .write_image_data(rgba) + .map_err(|e| PyRuntimeError::new_err(format!("PNG write: {e}"))) +} + +/// Parse Processing-style `applyMatrix`/`setMatrix` arguments into an `Affine3A`: +/// 6 values for a 2D affine (`n00 n01 n02 n10 n11 n12`) or 16 values for a full +/// 3D matrix in row-major order (matching Processing; glam is column-major). +fn affine_from_matrix_args(args: &Bound<'_, PyTuple>) -> PyResult { + let v = flatten_floats(args)?; + let mat = match v.len() { + 6 => Mat4::from_cols_array(&[ + v[0], v[3], 0.0, 0.0, // + v[1], v[4], 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + v[2], v[5], 0.0, 1.0, + ]), + 16 => { + let mut arr = [0.0f32; 16]; + arr.copy_from_slice(&v); + Mat4::from_cols_array(&arr).transpose() + } + n => { + return Err(PyValueError::new_err(format!( + "matrix expects 6 (2D) or 16 (3D) values, got {n}" + ))); + } + }; + Ok(Affine3A::from_mat4(mat)) +} + #[cfg(feature = "cuda")] fn cuda_import_from_interface( entity: bevy::prelude::Entity, @@ -66,6 +248,12 @@ impl PyBlendMode { name: Some(mode.name()), } } + + /// The composite-shader mode code (the `BlendMode` discriminant) for this + /// blend mode, or `None` for custom modes with no named preset. + pub(crate) fn composite_mode(&self) -> Option { + self.name.and_then(BlendMode::from_name).map(|m| m as u32) + } } #[pymethods] @@ -324,6 +512,18 @@ fn path_commands_to_py( #[derive(Debug)] pub struct Image { pub(crate) entity: Entity, + /// Cached `pixels` list populated by `load_pixels()` and written back by + /// `update_pixels()`, mirroring Processing's `PImage.pixels[]`. + pixel_cache: Mutex>>, +} + +impl Image { + pub(crate) fn wrap(entity: Entity) -> Self { + Self { + entity, + pixel_cache: Mutex::new(None), + } + } } pub(crate) struct ImageRef { @@ -361,6 +561,107 @@ impl Image { image_set_sampler(self.entity, sampler.filter, sampler.wrap_x, sampler.wrap_y) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + + /// Image width in pixels. + #[getter] + fn width(&self) -> PyResult { + image_size(self.entity) + .map(|(w, _)| w) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Image height in pixels. + #[getter] + fn height(&self) -> PyResult { + image_size(self.entity) + .map(|(_, h)| h) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read a single pixel as a `Color` (Processing `get`). + fn get(&self, x: u32, y: u32) -> PyResult { + let (w, h) = image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if x >= w || y >= h { + return Err(PyValueError::new_err(format!( + "pixel ({x}, {y}) out of bounds for {w}x{h} image" + ))); + } + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let p = pixels[(y * w + x) as usize]; + Ok(crate::color::PyColor(bevy::prelude::Color::LinearRgba(p))) + } + + /// Write a single pixel (Processing `set`). Accepts a `Color`, hex string, + /// or color components in the default (sRGB) color mode. + #[pyo3(signature = (x, y, *args))] + fn set(&self, x: u32, y: u32, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode(args, &ColorMode::default())?; + image_update_region(self.entity, x, y, 1, 1, &[LinearRgba::from(color)]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read all pixels into the `pixels` list (Processing `loadPixels`). + fn load_pixels(&self, py: Python<'_>) -> PyResult> { + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let list = pixels_to_pylist(py, &pixels)?; + *self.pixel_cache.lock().unwrap() = Some(list.clone_ref(py)); + Ok(list) + } + + /// The pixel buffer as a list of `Color` (Processing `pixels[]`). Auto-loads + /// on first access if `load_pixels()` has not been called. + #[getter] + fn pixels(&self, py: Python<'_>) -> PyResult> { + let cached = self + .pixel_cache + .lock() + .unwrap() + .as_ref() + .map(|list| list.clone_ref(py)); + match cached { + Some(list) => Ok(list), + None => self.load_pixels(py), + } + } + + /// Write the `pixels` list back to the image (Processing `updatePixels`). If + /// `pixels` is omitted, the cached buffer from `load_pixels()` is used. + #[pyo3(signature = (pixels=None))] + fn update_pixels(&self, py: Python<'_>, pixels: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let seq = resolve_pixels_arg(py, pixels, &self.pixel_cache)?; + let data = pyseq_to_pixels(&seq)?; + image_update(self.entity, &data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Copies a source into this image via a GPU blit (a sampling copy that + /// handles differing size and format). `src` may be another `Image` or a + /// `Graphics` render target — e.g. render into an offscreen graphics, then + /// `img.copy_from(g)` to pull the result into a plain, sampleable image. + fn copy_from(&self, src: &Bound<'_, PyAny>) -> PyResult<()> { + if let Ok(img) = src.extract::>() { + image_copy_from(self.entity, img.entity, false).map_err(rt_err) + } else if let Ok(g) = src.extract::>() { + image_copy_from(self.entity, g.entity, true).map_err(rt_err) + } else { + Err(PyValueError::new_err( + "copy_from() expects an Image or Graphics source", + )) + } + } + + /// Save the image to a PNG file (Processing `save`). + fn save(&self, filename: &str) -> PyResult<()> { + let (w, h) = image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let rgba: Vec = pixels + .iter() + .flat_map(|p| Srgba::from(*p).to_u8_array()) + .collect(); + write_png_file(filename, w, h, &rgba) + } } impl Drop for Image { @@ -484,6 +785,9 @@ pub struct Graphics { pub width: u32, #[pyo3(get)] pub height: u32, + /// Cached `pixels` list populated by `load_pixels()` and written back by + /// `update_pixels()`, mirroring Processing's `pixels[]`. + pixel_cache: Mutex>>, } impl Drop for Graphics { @@ -492,9 +796,60 @@ impl Drop for Graphics { } } +impl Graphics { + /// Create an offscreen graphics buffer in the already-running app (no window, + /// no `init`). Backs `create_graphics()` / `new_offscreen()`. The caller must + /// ensure the app exists (i.e. `size()` was called first). + pub(crate) fn wrap_offscreen(width: u32, height: u32) -> PyResult { + // sRGB by default: it plays well with PNG export and blits. + let texture_format = TextureFormat::Rgba8UnormSrgb; + let surface_entity = surface_create_offscreen(width, height, 1.0, texture_format) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Self::from_surface(surface_entity, width, height, texture_format, None) + } + + /// Wrap an existing surface entity in a `Graphics` (camera + draw state). The + /// `glfw_ctx` is `None` for offscreen and secondary windows — the main + /// surface owns the shared `GlfwContext` that drives every window. + pub(crate) fn from_surface( + surface_entity: Entity, + width: u32, + height: u32, + texture_format: TextureFormat, + glfw_ctx: Option, + ) -> PyResult { + let graphics = graphics_create(surface_entity, width, height, texture_format) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { + entity: graphics, + surface: Surface { + entity: surface_entity, + glfw_ctx, + }, + width, + height, + pixel_cache: Mutex::new(None), + }) + } + + /// Wrap a secondary window's surface in a `Graphics` (HDR window target). The + /// window lives on the main surface's shared `GlfwContext`, so this holds no + /// `glfw_ctx` of its own. + pub(crate) fn wrap_window(surface_entity: Entity, width: u32, height: u32) -> PyResult { + Self::from_surface( + surface_entity, + width, + height, + TextureFormat::Rgba16Float, + None, + ) + } +} + #[pymethods] impl Graphics { #[new] + #[allow(clippy::too_many_arguments)] pub fn new( width: u32, height: u32, @@ -502,9 +857,10 @@ impl Graphics { sketch_root_path: &str, sketch_file_name: &str, log_level: Option<&str>, + transparent: bool, ) -> PyResult { - let mut glfw_ctx = - GlfwContext::new(width, height).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let mut glfw_ctx = GlfwContext::new(width, height, transparent) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let mut config = Config::new(); config.set(ConfigKey::AssetRootPath, asset_path.to_string()); @@ -516,7 +872,7 @@ impl Graphics { init(config).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let surface = glfw_ctx - .create_surface(width, height) + .create_surface(width, height, transparent) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let surface = Surface { @@ -527,11 +883,17 @@ impl Graphics { let graphics = graphics_create(surface.entity, width, height, TextureFormat::Rgba16Float) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + // The Window component (applied each frame by the sync loop) otherwise + // defaults its title; match the GLFW create-time title. + surface_set_title(surface.entity, "Processing".to_string()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity: graphics, surface, width, height, + pixel_cache: Mutex::new(None), }) } @@ -548,28 +910,7 @@ impl Graphics { config.set(ConfigKey::LogLevel, level.to_string()); } init(config).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - // todo: allow caller to specify texture format? we use an sRGB format by default since - // it plays well with converting to PNG - let texture_format = TextureFormat::Rgba8UnormSrgb; - - let surface_entity = surface_create_offscreen(width, height, 1.0, texture_format) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - let surface = Surface { - entity: surface_entity, - glfw_ctx: None, - }; - - let graphics = graphics_create(surface.entity, width, height, texture_format) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - Ok(Self { - entity: graphics, - surface, - width, - height, - }) + Self::wrap_offscreen(width, height) } #[getter] @@ -627,6 +968,86 @@ impl Graphics { Ok(png_buf) } + /// Save the current canvas to a PNG file (Processing `save`). + pub fn save(&self, filename: &str) -> PyResult<()> { + let raw = graphics_readback_raw(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let rgba = match raw.format { + TextureFormat::Rgba8UnormSrgb => raw.bytes, + _ => { + let pixels = graphics_readback(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + pixels + .iter() + .flat_map(|pixel| Srgba::from(*pixel).to_u8_array()) + .collect() + } + }; + write_png_file(filename, raw.width, raw.height, &rgba) + } + + /// Read a single pixel as a `Color` (Processing `get`). + pub fn get(&self, x: u32, y: u32) -> PyResult { + if x >= self.width || y >= self.height { + return Err(PyValueError::new_err(format!( + "pixel ({x}, {y}) out of bounds for {}x{} canvas", + self.width, self.height + ))); + } + let pixels = graphics_readback(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let p = pixels + .get((y * self.width + x) as usize) + .ok_or_else(|| PyValueError::new_err("pixel out of bounds"))?; + Ok(crate::color::PyColor(bevy::prelude::Color::LinearRgba(*p))) + } + + /// Write a single pixel (Processing `set`). + #[pyo3(signature = (x, y, *args))] + pub fn set(&self, x: u32, y: u32, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; + graphics_update_region(self.entity, x, y, 1, 1, &[LinearRgba::from(color)]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read all pixels into the `pixels` list (Processing `loadPixels`). + pub fn load_pixels(&self, py: Python<'_>) -> PyResult> { + let pixels = graphics_readback(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let list = pixels_to_pylist(py, &pixels)?; + *self.pixel_cache.lock().unwrap() = Some(list.clone_ref(py)); + Ok(list) + } + + /// The pixel buffer as a list of `Color` (Processing `pixels[]`). Auto-loads + /// on first access if `load_pixels()` has not been called. + #[getter] + pub fn pixels(&self, py: Python<'_>) -> PyResult> { + let cached = self + .pixel_cache + .lock() + .unwrap() + .as_ref() + .map(|list| list.clone_ref(py)); + match cached { + Some(list) => Ok(list), + None => self.load_pixels(py), + } + } + + /// Write the `pixels` list back to the canvas (Processing `updatePixels`). + /// If `pixels` is omitted, the cached buffer from `load_pixels()` is used. + #[pyo3(signature = (pixels=None))] + pub fn update_pixels(&self, py: Python<'_>, pixels: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let seq = resolve_pixels_arg(py, pixels, &self.pixel_cache)?; + let data = pyseq_to_pixels(&seq)?; + graphics_update(self.entity, &data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn poll_for_sketch_update(&self) -> PyResult { match poll_for_sketch_updates().map_err(|_| PyRuntimeError::new_err("SKETCH UPDATE ERR"))? { Some(sketch) => Ok(Sketch { @@ -755,17 +1176,44 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn rect( - &self, - x: f32, - y: f32, - w: f32, - h: f32, - tl: f32, - tr: f32, - br: f32, - bl: f32, - ) -> PyResult<()> { + /// Draws a rectangle. Accepts `(x, y, w, h)`, `(x, y, w, h, radius)` for a + /// uniform corner radius, or `(x, y, w, h, tl, tr, br, bl)` for per-corner + /// radii — matching Processing's `rect()` overloads. + #[pyo3(signature = (*args))] + pub fn rect(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let (x, y, w, h, tl, tr, br, bl) = match args.len() { + 4 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + (x, y, w, h, 0.0, 0.0, 0.0, 0.0) + } + 5 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + let r = args.get_item(4)?.extract()?; + (x, y, w, h, r, r, r, r) + } + 8 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + let tl = args.get_item(4)?.extract()?; + let tr = args.get_item(5)?.extract()?; + let br = args.get_item(6)?.extract()?; + let bl = args.get_item(7)?.extract()?; + (x, y, w, h, tl, tr, br, bl) + } + n => { + return Err(pyo3::exceptions::PyTypeError::new_err(format!( + "rect() takes 4, 5, or 8 arguments ({n} given)" + ))); + } + }; graphics_record_command( self.entity, DrawCommand::Rect { @@ -1075,8 +1523,12 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn text_style(&self, style: u8) -> PyResult<()> { - graphics_text_style(self.entity, style).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn text_style(&self, style: &str) -> PyResult<()> { + use processing::prelude::TextStyle; + let style = TextStyle::parse(style) + .ok_or_else(|| PyValueError::new_err(format!("unknown text style: {style:?}")))?; + graphics_text_style(self.entity, style as u8) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] @@ -1200,16 +1652,18 @@ impl Graphics { } #[pyo3(signature = (h, v=None))] - pub fn text_align(&self, h: u8, v: Option) -> PyResult<()> { + pub fn text_align(&self, h: &str, v: Option<&str>) -> PyResult<()> { use processing::prelude::{TextAlignH, TextAlignV}; - graphics_record_command( - self.entity, - DrawCommand::TextAlign { - h: TextAlignH::from(h), - v: TextAlignV::from(v.unwrap_or(0)), - }, - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + let h = TextAlignH::parse(h) + .ok_or_else(|| PyValueError::new_err(format!("unknown horizontal text align: {h:?}")))?; + let v = match v { + Some(v) => TextAlignV::parse(v).ok_or_else(|| { + PyValueError::new_err(format!("unknown vertical text align: {v:?}")) + })?, + None => TextAlignV::default(), + }; + graphics_record_command(self.entity, DrawCommand::TextAlign { h, v }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } pub fn text_leading(&self, leading: f32) -> PyResult<()> { @@ -1217,9 +1671,11 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn text_wrap(&self, mode: u8) -> PyResult<()> { + pub fn text_wrap(&self, mode: &str) -> PyResult<()> { use processing::prelude::TextWrapMode; - graphics_record_command(self.entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) + let mode = TextWrapMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown text wrap mode: {mode:?}")))?; + graphics_record_command(self.entity, DrawCommand::TextWrap(mode)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -1287,7 +1743,7 @@ impl Graphics { /// The path is relative to the sketch's assets directory. pub fn load_image(&self, file: &str) -> PyResult { match image_load(file) { - Ok(image) => Ok(Image { entity: image }), + Ok(image) => Ok(Image::wrap(image)), Err(e) => Err(PyRuntimeError::new_err(format!("{e}"))), } } @@ -1373,7 +1829,7 @@ impl Graphics { let data = vec![0u8; (width * height * 4) as usize]; let entity = image_create(size, data, TextureFormat::Rgba8UnormSrgb) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Image { entity }) + Ok(Image::wrap(entity)) } pub fn push_matrix(&self) -> PyResult<()> { @@ -1477,6 +1933,105 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + /// Multiply the current matrix by another (Processing `applyMatrix`). + /// Accepts 6 values (2D affine) or 16 values (3D, row-major). + #[pyo3(signature = (*args))] + pub fn apply_matrix(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let affine = affine_from_matrix_args(args)?; + graphics_record_command(self.entity, DrawCommand::ApplyMatrix(affine)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Replace the current matrix (Processing `setMatrix`). + /// Accepts 6 values (2D affine) or 16 values (3D, row-major). + #[pyo3(signature = (*args))] + pub fn set_matrix(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let affine = affine_from_matrix_args(args)?; + graphics_record_command(self.entity, DrawCommand::ResetMatrix) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + graphics_record_command(self.entity, DrawCommand::ApplyMatrix(affine)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Return the current transformation matrix as 16 floats in row-major order. + pub fn get_matrix(&self) -> PyResult<[f32; 16]> { + let m = graphics_get_matrix(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(m.transpose().to_cols_array()) + } + + /// Screen-space x of a model-space coordinate (Processing `screenX`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_x(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Screen-space y of a model-space coordinate (Processing `screenY`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_y(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Screen-space depth of a model-space coordinate (Processing `screenZ`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_z(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.z) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space x of a model-space coordinate (Processing `modelX`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_x(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space y of a model-space coordinate (Processing `modelY`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_y(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space z of a model-space coordinate (Processing `modelZ`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_z(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.z) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space x of a screen coordinate at the given depth (Processing `worldX`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_x(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space y of a screen coordinate at the given depth (Processing `worldY`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_y(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space z of a screen coordinate at the given depth (Processing `worldZ`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_z(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.z) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn draw_box(&self, width: f32, height: f32, depth: f32) -> PyResult<()> { graphics_record_command( self.entity, @@ -1631,7 +2186,7 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn use_material(&self, material: &crate::material::Material) -> PyResult<()> { + pub fn material(&self, material: &crate::material::Material) -> PyResult<()> { graphics_record_command(self.entity, DrawCommand::Material(material.entity)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -1667,9 +2222,91 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn set_material(&self, material: &crate::material::Material) -> PyResult<()> { - graphics_record_command(self.entity, DrawCommand::Material(material.entity)) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + /// Composites a source onto this graphics with a blend mode (Processing + /// `blend`). `src` is any sampleable input — an `Image`, webcam, or another + /// `Graphics` (which is flushed first). Optional `src_rect`/`dst_rect` are + /// `(x, y, w, h)` pixel regions (default full extent); the source is scaled + /// into the dst region. + #[pyo3(signature = (src, mode, *, src_rect=None, dst_rect=None, opacity=1.0))] + pub fn blend( + &self, + src: &Bound<'_, PyAny>, + mode: &PyBlendMode, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + opacity: f32, + ) -> PyResult<()> { + let code = mode.composite_mode().ok_or_else(|| { + PyValueError::new_err( + "blend(): custom blend modes are unsupported here; use a named mode \ + (BLEND, ADD, SUBTRACT, DARKEST, LIGHTEST, DIFFERENCE, EXCLUSION, \ + MULTIPLY, SCREEN, REPLACE)", + ) + })?; + let (src_entity, flush) = resolve_composite_source(src)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + let sr = normalize_src_rect(src_entity, src_rect)?; + let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); + composite_apply(self.entity, src_entity, code, sr, dr, opacity) + } + + /// Copies a source onto this graphics, replacing pixels (Processing `copy`). + /// `src` may be an `Image`, webcam, or `Graphics`. See `blend` for the + /// `src_rect`/`dst_rect` semantics. + #[pyo3(signature = (src, *, src_rect=None, dst_rect=None))] + pub fn copy( + &self, + src: &Bound<'_, PyAny>, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + ) -> PyResult<()> { + let (src_entity, flush) = resolve_composite_source(src)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + let sr = normalize_src_rect(src_entity, src_rect)?; + let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); + composite_apply(self.entity, src_entity, COMPOSITE_MODE_REPLACE, sr, dr, 1.0) + } + + /// Feedback pass: re-samples this graphics' previous frame with a + /// zoom/rotate/offset transform and a per-frame `decay`, writing it back. + /// On a context you don't clear each frame, call this at the start of + /// `draw()` and then draw new content on top to get feedback trails. + #[pyo3(signature = (*, decay=0.95, zoom=1.0, angle=0.0, offset=(0.0, 0.0)))] + pub fn feedback( + &self, + decay: f32, + zoom: f32, + angle: f32, + offset: (f32, f32), + ) -> PyResult<()> { + use shader_value::ShaderValue; + let filter = filter_feedback().map_err(rt_err)?; + filter_set(filter, "decay", ShaderValue::Float(decay)).map_err(rt_err)?; + filter_set(filter, "zoom", ShaderValue::Float(zoom)).map_err(rt_err)?; + filter_set(filter, "angle", ShaderValue::Float(angle)).map_err(rt_err)?; + filter_set(filter, "offset", ShaderValue::Float2([offset.0, offset.1])).map_err(rt_err)?; + graphics_apply_filter(self.entity, filter).map_err(rt_err) + } + + /// Applies a mask source's brightness as this graphics' alpha channel + /// (Processing `mask`). `mask` may be an `Image`, webcam, or `Graphics`. + pub fn mask(&self, mask: &Bound<'_, PyAny>) -> PyResult<()> { + let (src_entity, flush) = resolve_composite_source(mask)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + composite_apply( + self.entity, + src_entity, + COMPOSITE_MODE_MASK, + [0.0, 0.0, 1.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + 1.0, + ) } #[pyo3(name = "color_mode", signature = (mode, max1=None, max2=None, max3=None, max_alpha=None))] diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index d5dd6c31..c5481432 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -39,11 +39,73 @@ use pyo3::{ BoundObject, exceptions::PyRuntimeError, prelude::*, - types::{PyDict, PyTuple}, + types::{PyDict, PyList, PyTuple}, }; use shader::Shader; use std::ffi::{CStr, CString}; +/// Register a window `Graphics` in the module's `_windows` list so the run loop +/// draws + presents it each frame. +fn register_window(module: &Bound<'_, PyModule>, window: &Py) -> PyResult<()> { + let list = match module.getattr("_windows") { + Ok(existing) if !existing.is_none() => existing + .cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + _ => { + let list = PyList::empty(module.py()); + module.setattr("_windows", &list)?; + list + } + }; + list.append(window)?; + Ok(()) +} + +/// All window `Graphics` the run loop should drive: the main canvas (`_graphics`) +/// followed by any `create_window` results (`_windows`). +fn collect_windows(module: &Bound<'_, PyModule>) -> PyResult>> { + let mut out = Vec::new(); + if let Ok(main) = module.getattr("_graphics") + && !main.is_none() + { + out.push( + main.cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))? + .unbind(), + ); + } + if let Ok(windows) = module.getattr("_windows") + && let Ok(list) = windows.cast_into::() + { + for item in list.iter() { + out.push( + item.cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))? + .unbind(), + ); + } + } + Ok(out) +} + +/// Replace the first run of `#` characters in `pattern` with a zero-padded frame +/// number (Processing `saveFrame` semantics, e.g. `frame-####.png`). +fn substitute_frame_number(pattern: &str, n: u32) -> String { + match pattern.find('#') { + Some(start) => { + let hashes = pattern[start..].chars().take_while(|c| *c == '#').count(); + format!( + "{}{:0width$}{}", + &pattern[..start], + n, + &pattern[start + hashes..], + width = hashes + ) + } + None => pattern.to_string(), + } +} + use bevy::log::warn; use gltf::Gltf; use std::cell::{Cell, RefCell}; @@ -171,7 +233,12 @@ fn dispatch_event_callbacks(locals: &Bound<'_, PyAny>) -> PyResult<()> { Ok(()) } -fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32) -> PyResult<()> { +fn create_graphics_context( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + transparent: bool, +) -> PyResult<()> { let py = module.py(); let env = detect_environment(py)?; @@ -210,6 +277,7 @@ fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32 sketch_root.as_str(), sketch_file.as_str(), log_level, + transparent, )?; module.setattr("_graphics", graphics)?; @@ -235,6 +303,7 @@ fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32 sketch_root.as_str(), sketch_file.as_str(), log_level, + transparent, )?; module.setattr("_graphics", graphics)?; } @@ -250,7 +319,7 @@ fn ensure_graphics(module: &Bound<'_, PyModule>) -> PyResult<()> { if get_graphics(module)?.is_some() { return Ok(()); } - create_graphics_context(module, DEFAULT_WIDTH, DEFAULT_HEIGHT) + create_graphics_context(module, DEFAULT_WIDTH, DEFAULT_HEIGHT, false) } macro_rules! graphics { @@ -742,9 +811,14 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module)] - fn size(module: &Bound<'_, PyModule>, width: u32, height: u32) -> PyResult<()> { - create_graphics_context(module, width, height)?; + #[pyo3(pass_module, signature = (width, height, *, transparent=false))] + fn size( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + transparent: bool, + ) -> PyResult<()> { + create_graphics_context(module, width, height, transparent)?; let py = module.py(); let sys = PyModule::import(py, "sys")?; @@ -866,22 +940,24 @@ mod mewnala { } first_frame = false; - get_graphics_mut(module)? - .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? - .begin_draw()?; - processing::prelude::advance_frame_count() .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + // One draw() per frame drives every window. Globals target the + // primary window; any additional window is drawn via its own + // Graphics methods, exactly like an offscreen buffer. Each window + // is begun before draw() and presented after. + let windows = collect_windows(module)?; + for wg in &windows { + wg.bind(py).borrow().begin_draw()?; + } sync_globals(module, &globals)?; - draw_fn_ref .call0() .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - get_graphics(module)? - .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? - .end_draw()?; + for wg in &windows { + wg.bind(py).borrow().end_draw()?; + } update_loop_state(|s| s.redraw_requested = false); } @@ -1197,40 +1273,7 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn rect(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let (x, y, w, h, tl, tr, br, bl) = match args.len() { - 4 => { - let x = args.get_item(0)?.extract()?; - let y = args.get_item(1)?.extract()?; - let w = args.get_item(2)?.extract()?; - let h = args.get_item(3)?.extract()?; - (x, y, w, h, 0.0, 0.0, 0.0, 0.0) - } - 5 => { - let x = args.get_item(0)?.extract()?; - let y = args.get_item(1)?.extract()?; - let w = args.get_item(2)?.extract()?; - let h = args.get_item(3)?.extract()?; - let r = args.get_item(4)?.extract()?; - (x, y, w, h, r, r, r, r) - } - 8 => { - let x = args.get_item(0)?.extract()?; - let y = args.get_item(1)?.extract()?; - let w = args.get_item(2)?.extract()?; - let h = args.get_item(3)?.extract()?; - let tl = args.get_item(4)?.extract()?; - let tr = args.get_item(5)?.extract()?; - let br = args.get_item(6)?.extract()?; - let bl = args.get_item(7)?.extract()?; - (x, y, w, h, tl, tr, br, bl) - } - n => { - return Err(pyo3::exceptions::PyTypeError::new_err(format!( - "rect() takes 4, 5, or 8 arguments ({n} given)" - ))); - } - }; - graphics!(module).rect(x, y, w, h, tl, tr, br, bl) + graphics!(module).rect(args) } /// Loads an image from a file and returns an Image object. @@ -1298,6 +1341,50 @@ mod mewnala { graphics.create_image(width, height) } + /// Creates an offscreen graphics buffer (Processing `createGraphics`). The + /// returned `Graphics` renders in the current app — draw into it, use it as a + /// composite source (`blend`/`copy`), blit it into an image (`copy_from`), or + /// display it with `image()`. Call `size()` first. + #[pyfunction] + #[pyo3(pass_module)] + fn create_graphics( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + ) -> PyResult { + get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; + Graphics::wrap_offscreen(width, height) + } + + /// Opens an additional window (libprocessing extension; not in Processing). + /// Returns a window-backed `Graphics` — draw to it with its own methods + /// inside your normal `draw()`, exactly like an offscreen `create_graphics` + /// buffer (globals target the primary window). The run loop presents every + /// window each frame. Call `size()` first. + #[pyfunction] + #[pyo3(pass_module, signature = (width, height, title="Processing", *, transparent=false))] + fn create_window( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + title: &str, + transparent: bool, + ) -> PyResult> { + // The main surface owns the shared GLFW instance; add a window to it. + let surface_entity = { + let mut main = get_graphics_mut(module)? + .ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; + main.surface.add_window(width, height, transparent, title)? + }; + // The GLFW window is created with the title, but the Window component + // (which the per-frame sync applies) defaults otherwise — set it too. + ::processing::prelude::surface_set_title(surface_entity, title.to_string()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let window = Py::new(module.py(), Graphics::wrap_window(surface_entity, width, height)?)?; + register_window(module, &window)?; + Ok(window) + } + fn apply_light_transform( light: &Light, position: Option, @@ -1382,8 +1469,277 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module, signature = (material))] - fn use_material(module: &Bound<'_, PyModule>, material: &Bound<'_, Material>) -> PyResult<()> { - graphics!(module).use_material(&*material.extract::>()?) + fn material(module: &Bound<'_, PyModule>, material: &Bound<'_, Material>) -> PyResult<()> { + graphics!(module).material(&*material.extract::>()?) + } + + /// Creates a new material, mirroring Processing's `createMaterial()`. + /// + /// With no arguments a PBR material is created; pass a `Shader` for a custom + /// material. Any additional keyword arguments (albedo, roughness, metallic, + /// emissive, unlit, ...) are applied immediately. + #[pyfunction] + #[pyo3(signature = (shader=None, **kwargs))] + fn create_material( + shader: Option<&Shader>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + Material::new(shader, kwargs) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (kind, *args, **kwargs))] + fn filter( + module: &Bound<'_, PyModule>, + kind: Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + graphics!(module).filter(kind, args, kwargs) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (src, mode, *, src_rect=None, dst_rect=None, opacity=1.0))] + fn blend( + module: &Bound<'_, PyModule>, + src: &Bound<'_, PyAny>, + mode: &graphics::PyBlendMode, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + opacity: f32, + ) -> PyResult<()> { + graphics!(module).blend(src, mode, src_rect, dst_rect, opacity) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (src, *, src_rect=None, dst_rect=None))] + fn copy( + module: &Bound<'_, PyModule>, + src: &Bound<'_, PyAny>, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + ) -> PyResult<()> { + graphics!(module).copy(src, src_rect, dst_rect) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn mask(module: &Bound<'_, PyModule>, mask: &Bound<'_, PyAny>) -> PyResult<()> { + graphics!(module).mask(mask) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (*, decay=0.95, zoom=1.0, angle=0.0, offset=(0.0, 0.0)))] + fn feedback( + module: &Bound<'_, PyModule>, + decay: f32, + zoom: f32, + angle: f32, + offset: (f32, f32), + ) -> PyResult<()> { + graphics!(module).feedback(decay, zoom, angle, offset) + } + + // --- Text --- + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, *args, max_w=None, max_h=None))] + fn text( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + args: &Bound<'_, PyTuple>, + max_w: Option, + max_h: Option, + ) -> PyResult<()> { + graphics!(module).text(content, x, y, args, max_w, max_h) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_size(module: &Bound<'_, PyModule>, size: f32) -> PyResult<()> { + graphics!(module).text_size(size) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (h, v=None))] + fn text_align(module: &Bound<'_, PyModule>, h: &str, v: Option<&str>) -> PyResult<()> { + graphics!(module).text_align(h, v) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_leading(module: &Bound<'_, PyModule>, leading: f32) -> PyResult<()> { + graphics!(module).text_leading(leading) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (font=None))] + fn text_font(module: &Bound<'_, PyModule>, font: Option<&Font>) -> PyResult<()> { + graphics!(module).text_font(font) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_style(module: &Bound<'_, PyModule>, style: &str) -> PyResult<()> { + graphics!(module).text_style(style) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_wrap(module: &Bound<'_, PyModule>, mode: &str) -> PyResult<()> { + graphics!(module).text_wrap(mode) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_width(module: &Bound<'_, PyModule>, content: &str) -> PyResult { + graphics!(module).text_width(content) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_ascent(module: &Bound<'_, PyModule>) -> PyResult { + graphics!(module).text_ascent() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_descent(module: &Bound<'_, PyModule>) -> PyResult { + graphics!(module).text_descent() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, max_w=None, max_h=None))] + fn text_bounds( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult<(f32, f32, f32, f32)> { + graphics!(module).text_bounds(content, x, y, max_w, max_h) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_line_count(module: &Bound<'_, PyModule>, content: &str) -> PyResult { + graphics!(module).text_line_count(content) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_weight(module: &Bound<'_, PyModule>, weight: f32) -> PyResult<()> { + graphics!(module).text_weight(weight) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_variation(module: &Bound<'_, PyModule>, tag: &str, value: f32) -> PyResult<()> { + graphics!(module).text_variation(tag, value) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn clear_text_variations(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).clear_text_variations() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (tag, value=None))] + fn text_feature( + module: &Bound<'_, PyModule>, + tag: &str, + value: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + graphics!(module).text_feature(tag, value) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn no_text_feature(module: &Bound<'_, PyModule>, tag: &str) -> PyResult<()> { + graphics!(module).no_text_feature(tag) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn clear_text_features(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).clear_text_features() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_glyph_colors( + module: &Bound<'_, PyModule>, + colors: Vec>, + ) -> PyResult<()> { + graphics!(module).text_glyph_colors(colors) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn load_font(module: &Bound<'_, PyModule>, path: &str) -> PyResult { + graphics!(module).load_font(path) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn create_font(module: &Bound<'_, PyModule>, name: &str) -> PyResult { + graphics!(module).create_font(name) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn list_fonts(module: &Bound<'_, PyModule>) -> PyResult> { + graphics!(module).list_fonts() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_paths( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + graphics!(module).text_to_paths(content, x, y) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_contours( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + graphics!(module).text_to_contours(content, x, y) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, sample_factor=None))] + fn text_to_points( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + sample_factor: Option, + ) -> PyResult> { + graphics!(module).text_to_points(content, x, y, sample_factor) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_model( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + depth: f32, + ) -> PyResult { + graphics!(module).text_to_model(content, x, y, depth) } #[pyfunction] @@ -1585,6 +1941,129 @@ mod mewnala { graphics!(module).reset_matrix() } + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn apply_matrix(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).apply_matrix(args) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn set_matrix(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).set_matrix(args) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn get_matrix(module: &Bound<'_, PyModule>) -> PyResult<[f32; 16]> { + graphics!(module).get_matrix() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_x(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_x(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_y(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_y(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_z(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_z(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_x(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_x(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_y(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_y(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_z(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_z(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_x(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_x(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_y(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_y(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_z(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_z(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn get(module: &Bound<'_, PyModule>, x: u32, y: u32) -> PyResult { + graphics!(module).get(x, y) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, *args))] + fn set( + module: &Bound<'_, PyModule>, + x: u32, + y: u32, + args: &Bound<'_, PyTuple>, + ) -> PyResult<()> { + graphics!(module).set(x, y, args) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn load_pixels(module: &Bound<'_, PyModule>, py: Python<'_>) -> PyResult> { + graphics!(module).load_pixels(py) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (pixels=None))] + fn update_pixels( + module: &Bound<'_, PyModule>, + py: Python<'_>, + pixels: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + graphics!(module).update_pixels(py, pixels) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn save(module: &Bound<'_, PyModule>, filename: &str) -> PyResult<()> { + graphics!(module).save(filename) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (filename=None))] + fn save_frame(module: &Bound<'_, PyModule>, filename: Option<&str>) -> PyResult<()> { + let count = ::processing::prelude::frame_count() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let name = match filename { + Some(f) => substitute_frame_number(f, count), + None => format!("screen-{count:04}.png"), + }; + graphics!(module).save(&name) + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn scale(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { diff --git a/crates/processing_pyo3/src/surface.rs b/crates/processing_pyo3/src/surface.rs index 81f00932..906ccda1 100644 --- a/crates/processing_pyo3/src/surface.rs +++ b/crates/processing_pyo3/src/surface.rs @@ -13,6 +13,27 @@ pub struct Surface { pub(crate) glfw_ctx: Option, } +impl Surface { + /// Add a window on the shared GLFW instance (valid only on the main surface, + /// which owns the `GlfwContext`). Returns the new window's surface entity. + pub(crate) fn add_window( + &mut self, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> PyResult { + match &mut self.glfw_ctx { + Some(ctx) => ctx + .add_window(width, height, transparent, title) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))), + None => Err(PyRuntimeError::new_err( + "create_window() requires a windowed sketch (call size() first)", + )), + } + } +} + #[pymethods] impl Surface { pub fn poll_events(&mut self) -> bool { diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index 4198e4af..146a9bbe 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -14,6 +14,8 @@ x11 = ["bevy/x11"] [dependencies] bevy = { workspace = true } bevy_naga_reflect = { workspace = true } +# direct wgpu dep to reach `wgpu::util::TextureBlitter` (unifies to bevy's wgpu) +wgpu = "29" naga = { workspace = true } wesl = { workspace = true } lyon = "1.0" diff --git a/crates/processing_render/shaders/processing/filter.wesl b/crates/processing_render/shaders/processing/filter.wesl index 21d62fb3..61a9bc50 100644 --- a/crates/processing_render/shaders/processing/filter.wesl +++ b/crates/processing_render/shaders/processing/filter.wesl @@ -20,6 +20,12 @@ fn sample(uv: vec2) -> vec4 { return textureSample(screen_texture, texture_sampler, uv); } +// Sample an arbitrary input texture (e.g. a composite's source operand) with the +// shared engine sampler, so composite/multi-input filters need not bind their own. +fn sample_texture(tex: texture_2d, uv: vec2) -> vec4 { + return textureSample(tex, texture_sampler, uv); +} + fn resolution() -> vec2 { return params.resolution; } fn texel_size() -> vec2 { return params.texel_size; } fn pass_index() -> u32 { return params.pass_index; } diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index af30f5e7..ce03db59 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -31,7 +31,70 @@ use processing_core::error::{ProcessingError, Result}; pub struct ImagePlugin; impl Plugin for ImagePlugin { - fn build(&self, _app: &mut App) {} + fn build(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +/// Where a blit reads its source pixels: another image, or a graphics render +/// target (via its render-world `ViewTarget`). +pub enum BlitSource { + Image(Handle), + Graphics(Entity), +} + +/// Caches one `wgpu::util::TextureBlitter` per destination texture format. Lives +/// in the render world; blitters are cheap to reuse and expensive to rebuild. +#[derive(Resource, Default)] +pub struct BlitterCache { + blitters: std::collections::HashMap, +} + +/// Blit `source` into the destination image (a sampling copy: handles differing +/// size and format, unlike a raw `copy_texture_to_texture`). Runs in the render +/// world; the destination image's texture is used as the render target. +pub fn blit( + In((dst_handle, source)): In<(Handle, BlitSource)>, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + view_targets: Query<(&bevy::render::sync_world::MainEntity, &bevy::render::view::ViewTarget)>, + mut cache: ResMut, +) -> Result<()> { + let dst = gpu_images + .get(&dst_handle) + .ok_or(ProcessingError::ImageNotFound)?; + let dst_view: &wgpu::TextureView = &dst.texture_view; + let format = dst.texture_descriptor.format; + + let src_view: &wgpu::TextureView = match &source { + BlitSource::Image(handle) => { + &gpu_images + .get(handle) + .ok_or(ProcessingError::ImageNotFound)? + .texture_view + } + BlitSource::Graphics(entity) => view_targets + .iter() + .find(|(main, _)| ***main == *entity) + .map(|(_, vt)| vt.main_texture_view()) + .ok_or(ProcessingError::GraphicsNotFound)?, + }; + + let device = render_device.wgpu_device(); + let blitter = cache + .blitters + .entry(format) + .or_insert_with(|| wgpu::util::TextureBlitter::new(device, format)); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("processing_blit"), + }); + blitter.copy(device, &mut encoder, src_view, dst_view); + render_queue.submit(std::iter::once(encoder.finish())); + Ok(()) } #[derive(Component)] @@ -331,6 +394,14 @@ pub fn prepare_update_region( Ok((data, px_size)) } +/// Get the pixel dimensions `(width, height)` of an image. +pub fn dimensions(In(entity): In, p_images: Query<&Image>) -> Result<(u32, u32)> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + Ok((p_image.size.width, p_image.size.height)) +} + pub fn destroy( In(entity): In, mut commands: Commands, diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 20a8e20b..1a35cc1b 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -113,12 +113,13 @@ pub fn surface_create_macos( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_macos, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -131,12 +132,13 @@ pub fn surface_create_windows( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_windows, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -150,12 +152,13 @@ pub fn surface_create_wayland( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_wayland, - (window_handle, display_handle, width, height, scale_factor), + (window_handle, display_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -169,12 +172,13 @@ pub fn surface_create_x11( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_x11, - (window_handle, display_handle, width, height, scale_factor), + (window_handle, display_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -189,6 +193,7 @@ pub fn surface_create_linux( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { // prefer wayland, since x11 may also be available under xwayland let nonempty = |name| std::env::var_os(name).is_some_and(|v| !v.is_empty()); @@ -197,20 +202,48 @@ pub fn surface_create_linux( #[cfg(all(feature = "wayland", feature = "x11"))] { if is_wayland { - surface_create_wayland(window_handle, display_handle, width, height, scale_factor) + surface_create_wayland( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) } else { - surface_create_x11(window_handle, display_handle, width, height, scale_factor) + surface_create_x11( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) } } #[cfg(all(feature = "wayland", not(feature = "x11")))] { let _ = is_wayland; - surface_create_wayland(window_handle, display_handle, width, height, scale_factor) + surface_create_wayland( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) } #[cfg(all(not(feature = "wayland"), feature = "x11"))] { let _ = is_wayland; - surface_create_x11(window_handle, display_handle, width, height, scale_factor) + surface_create_x11( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) } #[cfg(not(any(feature = "wayland", feature = "x11")))] { @@ -220,6 +253,7 @@ pub fn surface_create_linux( width, height, scale_factor, + transparent, is_wayland, ); Err(processing_core::error::ProcessingError::InvalidArgument( @@ -235,12 +269,13 @@ pub fn surface_create_web( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_web, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -293,7 +328,7 @@ pub fn surface_create_from_canvas( // TODO: not sure if this is right to force here let scale_factor = 1.0; - surface_create_web(canvas_ptr, width, height, scale_factor) + surface_create_web(canvas_ptr, width, height, scale_factor, false) } pub fn surface_destroy(graphics_entity: Entity) -> error::Result<()> { @@ -567,6 +602,8 @@ builtin_filter!(filter_posterize, POSTERIZE); builtin_filter!(filter_opaque, OPAQUE); builtin_filter!(filter_erode, ERODE); builtin_filter!(filter_dilate, DILATE); +builtin_filter!(filter_composite, COMPOSITE); +builtin_filter!(filter_feedback, FEEDBACK); pub fn filter_set_passes(entity: Entity, passes: u32) -> error::Result<()> { app_mut(|app| { @@ -1103,6 +1140,48 @@ pub fn image_resize(entity: Entity, new_size: Extent3d) -> error::Result<()> { }) } +/// Get the pixel dimensions `(width, height)` of an image. +pub fn image_size(entity: Entity) -> error::Result<(u32, u32)> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(image::dimensions, entity) + .unwrap() + }) +} + +/// Blit a source into a destination image (a sampling copy that handles differing +/// size/format). `src` is either an image or, when `src_is_graphics`, a graphics +/// render target (which is flushed first so its latest content is copied). +pub fn image_copy_from(dst: Entity, src: Entity, src_is_graphics: bool) -> error::Result<()> { + app_mut(|app| { + let source = if src_is_graphics { + crate::graphics::flush(app, src)?; + image::BlitSource::Graphics(src) + } else { + // Ensure both images are extracted to the render world (a graphics + // source is covered by its flush above; a bare image source is not). + app.update(); + let handle = app + .world() + .get::(src) + .ok_or(error::ProcessingError::ImageNotFound)? + .handle + .clone(); + image::BlitSource::Image(handle) + }; + let dst_handle = app + .world() + .get::(dst) + .ok_or(error::ProcessingError::ImageNotFound)? + .handle + .clone(); + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(image::blit, (dst_handle, source)) + .unwrap() + }) +} + /// Read back image data from GPU to CPU. pub fn image_readback(entity: Entity) -> error::Result> { app_mut(|app| { diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index ea824857..0c8773ff 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -24,6 +24,17 @@ impl From for TextAlignH { } } +impl TextAlignH { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::LEFT) => Some(Self::Left), + _ if s.eq_ignore_ascii_case(consts::CENTER) => Some(Self::Center), + _ if s.eq_ignore_ascii_case(consts::RIGHT) => Some(Self::Right), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum TextAlignV { @@ -46,6 +57,18 @@ impl From for TextAlignV { } } +impl TextAlignV { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::BASELINE) => Some(Self::Baseline), + _ if s.eq_ignore_ascii_case(consts::TOP) => Some(Self::Top), + _ if s.eq_ignore_ascii_case(consts::CENTER) => Some(Self::Center), + _ if s.eq_ignore_ascii_case(consts::BOTTOM) => Some(Self::Bottom), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum TextWrapMode { @@ -64,6 +87,16 @@ impl From for TextWrapMode { } } +impl TextWrapMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::WORD) => Some(Self::Word), + _ if s.eq_ignore_ascii_case(consts::CHAR) => Some(Self::Char), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum TextStyle { @@ -86,6 +119,18 @@ impl From for TextStyle { } } +impl TextStyle { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::NORMAL) => Some(Self::Normal), + _ if s.eq_ignore_ascii_case(consts::ITALIC) => Some(Self::Italic), + _ if s.eq_ignore_ascii_case(consts::BOLD) => Some(Self::Bold), + _ if s.eq_ignore_ascii_case(consts::BOLD_ITALIC) => Some(Self::BoldItalic), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum StrokeCapMode { @@ -373,6 +418,22 @@ impl BlendMode { } } + pub fn from_name(name: &str) -> Option { + match name { + "BLEND" => Some(Self::Blend), + "ADD" => Some(Self::Add), + "SUBTRACT" => Some(Self::Subtract), + "DARKEST" => Some(Self::Darkest), + "LIGHTEST" => Some(Self::Lightest), + "DIFFERENCE" => Some(Self::Difference), + "EXCLUSION" => Some(Self::Exclusion), + "MULTIPLY" => Some(Self::Multiply), + "SCREEN" => Some(Self::Screen), + "REPLACE" => Some(Self::Replace), + _ => None, + } + } + pub fn to_blend_state(self) -> Option { use BlendFactor::*; use BlendOperation::*; diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs index efc28a6e..52a2a9b4 100644 --- a/crates/processing_render/src/render/filter.rs +++ b/crates/processing_render/src/render/filter.rs @@ -41,6 +41,8 @@ pub mod builtin { pub const ERODE: &str = include_str!("filters/erode.wgsl"); pub const DILATE: &str = include_str!("filters/dilate.wgsl"); pub const BLUR: &str = include_str!("filters/blur.wgsl"); + pub const COMPOSITE: &str = include_str!("filters/composite.wgsl"); + pub const FEEDBACK: &str = include_str!("filters/feedback.wgsl"); } #[derive(Component)] diff --git a/crates/processing_render/src/render/filters/composite.wgsl b/crates/processing_render/src/render/filters/composite.wgsl new file mode 100644 index 00000000..2d4196ac --- /dev/null +++ b/crates/processing_render/src/render/filters/composite.wgsl @@ -0,0 +1,87 @@ +// Composite (blend) filter: combines the destination surface (operand A, sampled +// from the screen texture) with a source input texture (operand B, `src`) using a +// blend mode. This is the shader-based composite the fixed-function blend path +// cannot express exactly (MULTIPLY, DIFFERENCE, EXCLUSION, MASK). +// +// `mode` matches the `BlendMode` enum discriminants: +// 0 BLEND, 1 ADD, 2 SUBTRACT, 3 DARKEST, 4 LIGHTEST, 5 DIFFERENCE, +// 6 EXCLUSION, 7 MULTIPLY, 8 SCREEN, 9 REPLACE (copy), 10 MASK. +// +// `src_rect`/`dst_rect` are normalized (uv) rectangles (x0, y0, x1, y1). Only +// pixels inside `dst_rect` are affected; the source is sampled across `src_rect`, +// so a smaller/larger dst_rect scales the source into place. +import processing::filter::{sample, sample_texture, FullscreenVertexOutput}; + +struct CompositeParams { + src_rect: vec4, + dst_rect: vec4, + mode: u32, + opacity: f32, + _p0: f32, + _p1: f32, +} + +@group(1) @binding(0) var src: texture_2d; +@group(1) @binding(1) var composite_params: CompositeParams; + +fn luminance(c: vec3) -> f32 { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +// Per-channel blend of destination `d` and source `s` for the non-alpha-defined +// modes (BLEND/REPLACE/MASK are handled in `composite`). +fn blend_rgb(d: vec3, s: vec3, mode: u32) -> vec3 { + switch mode { + case 1u: { return d + s; } // ADD + case 2u: { return d - s; } // SUBTRACT + case 3u: { return min(d, s); } // DARKEST + case 4u: { return max(d, s); } // LIGHTEST + case 5u: { return abs(d - s); } // DIFFERENCE + case 6u: { return d + s - 2.0 * d * s; } // EXCLUSION + case 7u: { return d * s; } // MULTIPLY + case 8u: { return 1.0 - (1.0 - d) * (1.0 - s); } // SCREEN + default: { return s; } + } +} + +fn composite(d: vec4, s: vec4, mode: u32, opacity: f32) -> vec4 { + // BLEND: straight-alpha source-over. + if (mode == 0u) { + let a = s.a * opacity; + let out_a = a + d.a * (1.0 - a); + var rgb = vec3(0.0); + if (out_a > 0.0) { + rgb = (s.rgb * a + d.rgb * d.a * (1.0 - a)) / out_a; + } + return vec4(rgb, out_a); + } + // REPLACE / copy. + if (mode == 9u) { + return mix(d, s, opacity); + } + // MASK: keep destination color, take alpha from the source's luminance. + if (mode == 10u) { + return vec4(d.rgb, d.a * mix(1.0, luminance(s.rgb), opacity)); + } + // Remaining modes define a blended rgb, applied over the destination scaled + // by the (opacity-weighted) source alpha. + let a = s.a * opacity; + let blended = clamp(blend_rgb(d.rgb, s.rgb, mode), vec3(0.0), vec3(1.0)); + return vec4(mix(d.rgb, blended, a), d.a); +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let d = sample(in.uv); + + let dmin = composite_params.dst_rect.xy; + let dmax = composite_params.dst_rect.zw; + if (in.uv.x < dmin.x || in.uv.y < dmin.y || in.uv.x > dmax.x || in.uv.y > dmax.y) { + return d; + } + + let local = (in.uv - dmin) / max(dmax - dmin, vec2(1e-6)); + let suv = mix(composite_params.src_rect.xy, composite_params.src_rect.zw, local); + let s = sample_texture(src, suv); + return composite(d, s, composite_params.mode, composite_params.opacity); +} diff --git a/crates/processing_render/src/render/filters/feedback.wgsl b/crates/processing_render/src/render/filters/feedback.wgsl new file mode 100644 index 00000000..9e43cbb1 --- /dev/null +++ b/crates/processing_render/src/render/filters/feedback.wgsl @@ -0,0 +1,34 @@ +// Feedback filter: samples the surface's *previous* frame (operand A) with a +// zoom/rotate/offset transform and a per-frame decay, writing it back. On a +// graphics context that isn't cleared each frame, applying this at the start of +// draw() and then drawing new content on top produces feedback trails +// (TouchDesigner Feedback-TOP style). This is just a filter whose input is the +// target's own history. +import processing::filter::{sample, FullscreenVertexOutput}; + +struct FeedbackParams { + offset: vec2, + zoom: f32, + angle: f32, + decay: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} + +@group(1) @binding(0) var feedback: FeedbackParams; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let center = vec2(0.5, 0.5); + var uv = in.uv - center; + + // Rotate then inverse-zoom around the center (sampling the previous frame). + let s = sin(feedback.angle); + let c = cos(feedback.angle); + uv = mat2x2(c, -s, s, c) * uv; + uv = uv / max(feedback.zoom, 0.0001); + + uv = uv + center - feedback.offset; + return sample(uv) * feedback.decay; +} diff --git a/crates/processing_render/src/surface.rs b/crates/processing_render/src/surface.rs index 144dfd79..20f29e2f 100644 --- a/crates/processing_render/src/surface.rs +++ b/crates/processing_render/src/surface.rs @@ -106,6 +106,7 @@ fn spawn_surface( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> Result { let glfw_window = GlfwWindow { window_handle: raw_window_handle, @@ -118,13 +119,17 @@ fn spawn_surface( let physical_width = (width as f32 * scale_factor) as u32; let physical_height = (height as f32 * scale_factor) as u32; - // only enable swapchain level transparency on platforms we know support it - // in theory all platforms should support it, but in practice some have weird issues + // Window transparency is an explicit opt-in (default opaque). When requested, + // pick the swapchain composite mode the platform expects. // TODO: dxgi swapchain for windows https://github.com/gfx-rs/wgpu/issues/3486 - let (transparent, composite_alpha_mode) = match &raw_window_handle { - RawWindowHandle::AppKit(_) => (true, CompositeAlphaMode::PostMultiplied), - RawWindowHandle::Wayland(_) => (true, CompositeAlphaMode::PreMultiplied), - _ => (false, CompositeAlphaMode::Opaque), + let (transparent, composite_alpha_mode) = if transparent { + match &raw_window_handle { + RawWindowHandle::AppKit(_) => (true, CompositeAlphaMode::PostMultiplied), + RawWindowHandle::Wayland(_) => (true, CompositeAlphaMode::PreMultiplied), + _ => (true, CompositeAlphaMode::Auto), + } + } else { + (false, CompositeAlphaMode::Opaque) }; Ok(commands @@ -149,7 +154,7 @@ fn spawn_surface( /// * `window_handle` - A pointer to the NSWindow (from GLFW's `get_cocoa_window()`) #[cfg(target_os = "macos")] pub fn create_surface_macos( - In((window_handle, width, height, scale_factor)): In<(u64, u32, u32, f32)>, + In((window_handle, width, height, scale_factor, transparent)): In<(u64, u32, u32, f32, bool)>, mut commands: Commands, ) -> Result { use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle}; @@ -190,6 +195,7 @@ pub fn create_surface_macos( width, height, scale_factor, + transparent, ) } @@ -199,7 +205,7 @@ pub fn create_surface_macos( /// * `window_handle` - The HWND value (from GLFW's `get_win32_window()`) #[cfg(target_os = "windows")] pub fn create_surface_windows( - In((window_handle, width, height, scale_factor)): In<(u64, u32, u32, f32)>, + In((window_handle, width, height, scale_factor, transparent)): In<(u64, u32, u32, f32, bool)>, mut commands: Commands, ) -> Result { use std::num::NonZeroIsize; @@ -238,6 +244,7 @@ pub fn create_surface_windows( width, height, scale_factor, + transparent, ) } @@ -248,7 +255,7 @@ pub fn create_surface_windows( /// * `display_handle` - The wl_display pointer (from GLFW's `get_wayland_display()`) #[cfg(all(target_os = "linux", feature = "wayland"))] pub fn create_surface_wayland( - In((window_handle, display_handle, width, height, scale_factor)): In<(u64, u64, u32, u32, f32)>, + In((window_handle, display_handle, width, height, scale_factor, transparent)): In<(u64, u64, u32, u32, f32, bool)>, mut commands: Commands, ) -> Result { use raw_window_handle::{WaylandDisplayHandle, WaylandWindowHandle}; @@ -276,6 +283,7 @@ pub fn create_surface_wayland( width, height, scale_factor, + transparent, ) } @@ -286,7 +294,7 @@ pub fn create_surface_wayland( /// * `display_handle` - The X11 Display pointer (from GLFW's `get_x11_display()`) #[cfg(all(target_os = "linux", feature = "x11"))] pub fn create_surface_x11( - In((window_handle, display_handle, width, height, scale_factor)): In<(u64, u64, u32, u32, f32)>, + In((window_handle, display_handle, width, height, scale_factor, transparent)): In<(u64, u64, u32, u32, f32, bool)>, mut commands: Commands, ) -> Result { use raw_window_handle::{XlibDisplayHandle, XlibWindowHandle}; @@ -314,6 +322,7 @@ pub fn create_surface_x11( width, height, scale_factor, + transparent, ) } @@ -323,7 +332,7 @@ pub fn create_surface_x11( /// * `window_handle` - A pointer to the HtmlCanvasElement #[cfg(target_arch = "wasm32")] pub fn create_surface_web( - In((window_handle, width, height, scale_factor)): In<(u64, u32, u32, f32)>, + In((window_handle, width, height, scale_factor, transparent)): In<(u64, u32, u32, f32, bool)>, mut commands: Commands, ) -> Result { use raw_window_handle::{WebCanvasWindowHandle, WebDisplayHandle}; @@ -343,6 +352,7 @@ pub fn create_surface_web( width, height, scale_factor, + transparent, ) } From 1d5d4624fbb418d456af36fbef3bf542e2f07cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Sun, 9 Aug 2026 10:57:27 -0700 Subject: [PATCH 2/3] Externalize all example shaders. --- assets/shaders/flocking_duck_flock.wesl | 82 +++++++++ assets/shaders/flocking_duck_integrate.wesl | 51 ++++++ assets/shaders/flocking_gpu_flock.wesl | 70 ++++++++ assets/shaders/flocking_gpu_integrate.wesl | 55 ++++++ assets/shaders/particles_animated_spin.wesl | 21 +++ assets/shaders/particles_emit_gpu_motion.wesl | 37 ++++ assets/shaders/particles_emit_gpu_spawn.wesl | 65 +++++++ assets/shaders/particles_lifecycle_aging.wesl | 33 ++++ .../processing_pyo3/examples/animated_mesh.py | 2 +- crates/processing_pyo3/examples/compute.py | 28 +-- .../examples/custom_material.py | 4 +- crates/processing_pyo3/examples/flocking.py | 2 +- .../processing_pyo3/examples/flocking_duck.py | 167 ++---------------- .../processing_pyo3/examples/flocking_gpu.py | 154 ++-------------- .../examples/geometry_methods.py | 2 +- crates/processing_pyo3/examples/materials.py | 9 +- .../examples/particles_animated.py | 32 +--- .../examples/particles_basic.py | 8 +- .../examples/particles_emit.py | 8 +- .../examples/particles_emit_gpu.py | 127 +------------ .../examples/particles_from_mesh.py | 8 +- .../examples/particles_lifecycle.py | 64 ++----- .../examples/particles_noise.py | 8 +- .../examples/particles_scatter_volume.py | 9 +- .../examples/particles_stress.py | 6 +- crates/processing_pyo3/src/compute.rs | 15 +- crates/processing_pyo3/src/graphics.rs | 8 +- crates/processing_pyo3/src/lib.rs | 46 +++++ crates/processing_pyo3/src/material.rs | 13 +- crates/processing_pyo3/src/particles.rs | 95 ++++++++-- crates/processing_pyo3/src/shader.rs | 7 +- crates/processing_render/src/compute.rs | 117 +----------- crates/processing_render/src/lib.rs | 1 + .../processing_render/src/material/custom.rs | 32 +++- .../processing_render/src/shader_property.rs | 49 ++++- 35 files changed, 733 insertions(+), 702 deletions(-) create mode 100644 assets/shaders/flocking_duck_flock.wesl create mode 100644 assets/shaders/flocking_duck_integrate.wesl create mode 100644 assets/shaders/flocking_gpu_flock.wesl create mode 100644 assets/shaders/flocking_gpu_integrate.wesl create mode 100644 assets/shaders/particles_animated_spin.wesl create mode 100644 assets/shaders/particles_emit_gpu_motion.wesl create mode 100644 assets/shaders/particles_emit_gpu_spawn.wesl create mode 100644 assets/shaders/particles_lifecycle_aging.wesl diff --git a/assets/shaders/flocking_duck_flock.wesl b/assets/shaders/flocking_duck_flock.wesl new file mode 100644 index 00000000..3e3fec97 --- /dev/null +++ b/assets/shaders/flocking_duck_flock.wesl @@ -0,0 +1,82 @@ +struct Params { + neighbor_dist: f32, + separation_dist: f32, + max_speed: f32, + max_force: f32, + home_radius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var home: array; +@group(0) @binding(3) var steer: array; +@group(0) @binding(4) var params: Params; + +fn limit(v: vec3, max_len: f32) -> vec3 { + let len = length(v); + if len > max_len { return v * (max_len / len); } + return v; +} + +// Reynolds: steering = desired - velocity +fn steer_toward(desired: vec3, vel: vec3) -> vec3 { + let len = length(desired); + if len < 1e-6 { return vec3(0.0); } + return limit(desired * (params.max_speed / len) - vel, params.max_force); +} + +@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + + var separation = vec3(0.0); + var alignment = vec3(0.0); + var cohesion = vec3(0.0); + var separation_count = 0u; + var neighbor_count = 0u; + + for (var j = 0u; j < count; j = j + 1u) { + if j == i { continue; } + let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); + let d = distance(pos, other); + if d > 0.0 && d < params.separation_dist { + // Point away from the neighbor, weighted by closeness + separation = separation + normalize(pos - other) / d; + separation_count = separation_count + 1u; + } + if d < params.neighbor_dist { + alignment = alignment + + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); + cohesion = cohesion + other; + neighbor_count = neighbor_count + 1u; + } + } + + var force = vec3(0.0); + if separation_count > 0u { + force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; + } + if neighbor_count > 0u { + force = force + steer_toward(alignment, vel); + force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); + } + + // The tether: inside home_radius the weight is < 1 and flocking wins; + // past it the pull grows quadratically until it dominates everything. + let home_pos = vec3(home[i * 3u], home[i * 3u + 1u], home[i * 3u + 2u]); + let to_home = home_pos - pos; + let w = length(to_home) / params.home_radius; + force = force + steer_toward(to_home, vel) * min(w * w, 8.0); + + steer[i * 3u] = force.x; + steer[i * 3u + 1u] = force.y; + steer[i * 3u + 2u] = force.z; +} diff --git a/assets/shaders/flocking_duck_integrate.wesl b/assets/shaders/flocking_duck_integrate.wesl new file mode 100644 index 00000000..a38e92b6 --- /dev/null +++ b/assets/shaders/flocking_duck_integrate.wesl @@ -0,0 +1,51 @@ +struct Params { + dt: f32, + max_speed: f32, + _pad0: f32, + _pad1: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var rotation: array; +@group(0) @binding(4) var params: Params; + +// shortest-arc quaternion rotating the mesh's +Z axis onto dir +fn quat_z_to(dir: vec3) -> vec4 { + let z = vec3(0.0, 0.0, 1.0); + let d = dot(z, dir); + if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } + return normalize(vec4(cross(z, dir), 1.0 + d)); +} + +@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); + + vel = vel + force * params.dt; + let speed = length(vel); + if speed > params.max_speed { vel = vel * (params.max_speed / speed); } + pos = pos + vel * params.dt; + + position[i * 3u] = pos.x; + position[i * 3u + 1u] = pos.y; + position[i * 3u + 2u] = pos.z; + velocity[i * 3u] = vel.x; + velocity[i * 3u + 1u] = vel.y; + velocity[i * 3u + 2u] = vel.z; + + if speed > 1e-6 { + let q = quat_z_to(vel / speed); + rotation[i * 4u] = q.x; + rotation[i * 4u + 1u] = q.y; + rotation[i * 4u + 2u] = q.z; + rotation[i * 4u + 3u] = q.w; + } +} diff --git a/assets/shaders/flocking_gpu_flock.wesl b/assets/shaders/flocking_gpu_flock.wesl new file mode 100644 index 00000000..e0852c19 --- /dev/null +++ b/assets/shaders/flocking_gpu_flock.wesl @@ -0,0 +1,70 @@ +struct Params { + neighbor_dist: f32, + separation_dist: f32, + max_speed: f32, + max_force: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var params: Params; + +fn limit(v: vec3, max_len: f32) -> vec3 { + let len = length(v); + if len > max_len { return v * (max_len / len); } + return v; +} + +// Reynolds: steering = desired - velocity +fn steer_toward(desired: vec3, vel: vec3) -> vec3 { + let len = length(desired); + if len < 1e-6 { return vec3(0.0); } + return limit(desired * (params.max_speed / len) - vel, params.max_force); +} + +@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + + var separation = vec3(0.0); + var alignment = vec3(0.0); + var cohesion = vec3(0.0); + var separation_count = 0u; + var neighbor_count = 0u; + + for (var j = 0u; j < count; j = j + 1u) { + if j == i { continue; } + let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); + let d = distance(pos, other); + if d > 0.0 && d < params.separation_dist { + // Point away from the neighbor, weighted by closeness + separation = separation + normalize(pos - other) / d; + separation_count = separation_count + 1u; + } + if d < params.neighbor_dist { + alignment = alignment + + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); + cohesion = cohesion + other; + neighbor_count = neighbor_count + 1u; + } + } + + var force = vec3(0.0); + if separation_count > 0u { + force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; + } + if neighbor_count > 0u { + force = force + steer_toward(alignment, vel); + force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); + } + + steer[i * 3u] = force.x; + steer[i * 3u + 1u] = force.y; + steer[i * 3u + 2u] = force.z; +} diff --git a/assets/shaders/flocking_gpu_integrate.wesl b/assets/shaders/flocking_gpu_integrate.wesl new file mode 100644 index 00000000..bccbc162 --- /dev/null +++ b/assets/shaders/flocking_gpu_integrate.wesl @@ -0,0 +1,55 @@ +struct Params { + dt: f32, + max_speed: f32, + bound: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var rotation: array; +@group(0) @binding(4) var params: Params; + +// shortest-arc quaternion rotating the mesh's +Z axis onto dir +fn quat_z_to(dir: vec3) -> vec4 { + let z = vec3(0.0, 0.0, 1.0); + let d = dot(z, dir); + if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } + return normalize(vec4(cross(z, dir), 1.0 + d)); +} + +@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); + + vel = vel + force * params.dt; + let speed = length(vel); + if speed > params.max_speed { vel = vel * (params.max_speed / speed); } + pos = pos + vel * params.dt; + + // wrap into [-bound, bound]: ((p + b) mod 2b + 2b) mod 2b - b + let span = 2.0 * params.bound; + pos = ((pos + params.bound) % span + span) % span - params.bound; + + position[i * 3u] = pos.x; + position[i * 3u + 1u] = pos.y; + position[i * 3u + 2u] = pos.z; + velocity[i * 3u] = vel.x; + velocity[i * 3u + 1u] = vel.y; + velocity[i * 3u + 2u] = vel.z; + + if speed > 1e-6 { + let q = quat_z_to(vel / speed); + rotation[i * 4u] = q.x; + rotation[i * 4u + 1u] = q.y; + rotation[i * 4u + 2u] = q.z; + rotation[i * 4u + 3u] = q.w; + } +} diff --git a/assets/shaders/particles_animated_spin.wesl b/assets/shaders/particles_animated_spin.wesl new file mode 100644 index 00000000..cf137c27 --- /dev/null +++ b/assets/shaders/particles_animated_spin.wesl @@ -0,0 +1,21 @@ +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; +} diff --git a/assets/shaders/particles_emit_gpu_motion.wesl b/assets/shaders/particles_emit_gpu_motion.wesl new file mode 100644 index 00000000..fc334987 --- /dev/null +++ b/assets/shaders/particles_emit_gpu_motion.wesl @@ -0,0 +1,37 @@ +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 life: 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 life[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 remaining = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = remaining * remaining; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { life[i] = 0.0; } +} diff --git a/assets/shaders/particles_emit_gpu_spawn.wesl b/assets/shaders/particles_emit_gpu_spawn.wesl new file mode 100644 index 00000000..15d8f2a4 --- /dev/null +++ b/assets/shaders/particles_emit_gpu_spawn.wesl @@ -0,0 +1,65 @@ +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 life: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_base: u32; +@group(0) @binding(8) var emit_count: u32; +@group(0) @binding(9) var emit_capacity: u32; + +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 >= emit_count { return; } + let slot = (emit_base + local_i) % emit_capacity; + + let seed = emit_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; + life[slot] = 1.0; +} diff --git a/assets/shaders/particles_lifecycle_aging.wesl b/assets/shaders/particles_lifecycle_aging.wesl new file mode 100644 index 00000000..4ef6816d --- /dev/null +++ b/assets/shaders/particles_lifecycle_aging.wesl @@ -0,0 +1,33 @@ +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var life: 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 life[i] <= 0.0 { + return; + } + + age[i] = age[i] + dt; + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + let remaining = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = remaining * remaining; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + life[i] = 0.0; + } +} diff --git a/crates/processing_pyo3/examples/animated_mesh.py b/crates/processing_pyo3/examples/animated_mesh.py index 5001cb54..934fd8b7 100644 --- a/crates/processing_pyo3/examples/animated_mesh.py +++ b/crates/processing_pyo3/examples/animated_mesh.py @@ -11,7 +11,7 @@ def setup(): global geometry size(800, 600) mode_3d() - geometry = Geometry() + geometry = create_geometry() for z in range(grid_size): for x in range(grid_size): px = x * spacing - offset diff --git a/crates/processing_pyo3/examples/compute.py b/crates/processing_pyo3/examples/compute.py index 26fb5e0d..59d56229 100644 --- a/crates/processing_pyo3/examples/compute.py +++ b/crates/processing_pyo3/examples/compute.py @@ -5,7 +5,7 @@ g = Graphics.new_offscreen(1, 1, "", None) g.begin_draw() -shader = Shader(""" +shader = create_shader(""" @group(0) @binding(0) var output: array; @@ -18,8 +18,8 @@ } """) -buf = Buffer(size=16) -compute = Compute(shader) +buf = create_buffer(size=16) +compute = create_compute(shader) compute.set(output=buf) compute.dispatch(1, 1, 1) @@ -29,7 +29,7 @@ print("PASS") -buf2 = Buffer(data=[10.0, 20.0, 30.0, 40.0]) +buf2 = create_buffer(data=[10.0, 20.0, 30.0, 40.0]) assert len(buf2) == 4 assert buf2[0] == 10.0 assert buf2[-1] == 40.0 @@ -44,7 +44,7 @@ print("PASS") -double_shader = Shader(""" +double_shader = create_shader(""" @group(0) @binding(0) var data: array; @@ -54,8 +54,8 @@ } """) -buf3 = Buffer(data=[1.0, 2.0, 3.0, 4.0]) -compute3 = Compute(double_shader) +buf3 = create_buffer(data=[1.0, 2.0, 3.0, 4.0]) +compute3 = create_compute(double_shader) compute3.set(data=buf3) compute3.dispatch(1, 1, 1) assert buf3.read() == [2.0, 4.0, 6.0, 8.0] @@ -67,7 +67,7 @@ print("PASS") -wg_shader = Shader(""" +wg_shader = create_shader(""" @group(0) @binding(0) var output: array; @@ -77,15 +77,15 @@ } """) -buf5 = Buffer(size=32) -compute5 = Compute(wg_shader) +buf5 = create_buffer(size=32) +compute5 = create_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(""" +copy_shader = create_shader(""" @group(0) @binding(0) var src: array; @group(0) @binding(1) var dst: array; @@ -95,9 +95,9 @@ } """) -src_buf = Buffer(data=[1.0, 2.0, 3.0, 4.0]) -dst_buf = Buffer(size=16) -compute6 = Compute(copy_shader) +src_buf = create_buffer(data=[1.0, 2.0, 3.0, 4.0]) +dst_buf = create_buffer(size=16) +compute6 = create_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] diff --git a/crates/processing_pyo3/examples/custom_material.py b/crates/processing_pyo3/examples/custom_material.py index bfdaf37a..9e44fc11 100644 --- a/crates/processing_pyo3/examples/custom_material.py +++ b/crates/processing_pyo3/examples/custom_material.py @@ -7,8 +7,8 @@ def setup(): size(800, 600) mode_3d() - shader = Shader.load("shaders/custom_material.wesl") - mat = Material(shader, color=[1.0, 0.2, 0.4, 1.0]) + shader = load_shader("shaders/custom_material.wesl") + mat = create_material(shader, color=[1.0, 0.2, 0.4, 1.0]) def draw(): camera_position(0.0, 0.0, 200.0) diff --git a/crates/processing_pyo3/examples/flocking.py b/crates/processing_pyo3/examples/flocking.py index 9f652537..ea87b8f7 100644 --- a/crates/processing_pyo3/examples/flocking.py +++ b/crates/processing_pyo3/examples/flocking.py @@ -30,7 +30,7 @@ def draw(): title_elapsed = elapsed_time - title_last_time if title_elapsed >= 0.5: fps = (frame_count - title_last_frame) / title_elapsed - window_title(f"GPU Flocking Duck — {boid_count:,} boids — {fps:.0f} FPS") + window_title(f"Flocking — {boid_count:,} boids — {fps:.0f} FPS") title_last_time = elapsed_time title_last_frame = frame_count diff --git a/crates/processing_pyo3/examples/flocking_duck.py b/crates/processing_pyo3/examples/flocking_duck.py index 12cb1959..3af396ab 100644 --- a/crates/processing_pyo3/examples/flocking_duck.py +++ b/crates/processing_pyo3/examples/flocking_duck.py @@ -12,146 +12,9 @@ # Pass 1: Reynolds' three rules plus the tether. Every boid reads the whole # flock's state and writes only its steering force, so no boid ever sees a # half-updated neighbor. -FLOCK_SHADER = """ -struct Params { - neighbor_dist: f32, - separation_dist: f32, - max_speed: f32, - max_force: f32, - home_radius: f32, - _pad0: f32, - _pad1: f32, - _pad2: f32, -} - -@group(0) @binding(0) var position: array; -@group(0) @binding(1) var velocity: array; -@group(0) @binding(2) var home: array; -@group(0) @binding(3) var steer: array; -@group(0) @binding(4) var params: Params; - -fn limit(v: vec3, max_len: f32) -> vec3 { - let len = length(v); - if len > max_len { return v * (max_len / len); } - return v; -} - -// Reynolds: steering = desired - velocity -fn steer_toward(desired: vec3, vel: vec3) -> vec3 { - let len = length(desired); - if len < 1e-6 { return vec3(0.0); } - return limit(desired * (params.max_speed / len) - vel, params.max_force); -} - -@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); - let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); - - var separation = vec3(0.0); - var alignment = vec3(0.0); - var cohesion = vec3(0.0); - var separation_count = 0u; - var neighbor_count = 0u; - - for (var j = 0u; j < count; j = j + 1u) { - if j == i { continue; } - let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); - let d = distance(pos, other); - if d > 0.0 && d < params.separation_dist { - // Point away from the neighbor, weighted by closeness - separation = separation + normalize(pos - other) / d; - separation_count = separation_count + 1u; - } - if d < params.neighbor_dist { - alignment = alignment - + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); - cohesion = cohesion + other; - neighbor_count = neighbor_count + 1u; - } - } - - var force = vec3(0.0); - if separation_count > 0u { - force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; - } - if neighbor_count > 0u { - force = force + steer_toward(alignment, vel); - force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); - } - - // The tether: inside home_radius the weight is < 1 and flocking wins; - // past it the pull grows quadratically until it dominates everything. - let home_pos = vec3(home[i * 3u], home[i * 3u + 1u], home[i * 3u + 2u]); - let to_home = home_pos - pos; - let w = length(to_home) / params.home_radius; - force = force + steer_toward(to_home, vel) * min(w * w, 8.0); - - steer[i * 3u] = force.x; - steer[i * 3u + 1u] = force.y; - steer[i * 3u + 2u] = force.z; -} -""" # Pass 2: integrate the steering force and point each instanced boid along # its velocity. No wrapping — the tether is the only containment needed. -INTEGRATE_SHADER = """ -struct Params { - dt: f32, - max_speed: f32, - _pad0: f32, - _pad1: f32, -} - -@group(0) @binding(0) var position: array; -@group(0) @binding(1) var velocity: array; -@group(0) @binding(2) var steer: array; -@group(0) @binding(3) var rotation: array; -@group(0) @binding(4) var params: Params; - -// shortest-arc quaternion rotating the mesh's +Z axis onto dir -fn quat_z_to(dir: vec3) -> vec4 { - let z = vec3(0.0, 0.0, 1.0); - let d = dot(z, dir); - if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } - return normalize(vec4(cross(z, dir), 1.0 + d)); -} - -@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); - var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); - let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); - - vel = vel + force * params.dt; - let speed = length(vel); - if speed > params.max_speed { vel = vel * (params.max_speed / speed); } - pos = pos + vel * params.dt; - - position[i * 3u] = pos.x; - position[i * 3u + 1u] = pos.y; - position[i * 3u + 2u] = pos.z; - velocity[i * 3u] = vel.x; - velocity[i * 3u + 1u] = vel.y; - velocity[i * 3u + 2u] = vel.z; - - if speed > 1e-6 { - let q = quat_z_to(vel / speed); - rotation[i * 4u] = q.x; - rotation[i * 4u + 1u] = q.y; - rotation[i * 4u + 2u] = q.z; - rotation[i * 4u + 3u] = q.w; - } -} -""" p = None boid = None @@ -170,7 +33,7 @@ # boid pointing down +Z. The fold keeps the boid visible edge-on and gives # each wing its own normal, so the flock glints as it banks. def boid_geometry(half_width, length, droop): - g = Geometry() + g = create_geometry() n = (half_width * half_width + droop * droop) ** 0.5 nose = (0.0, 0.0, length * 0.5) tail = (0.0, 0.0, -length * 0.5) @@ -198,26 +61,22 @@ def setup(): gltf = load_gltf("gltf/Duck.glb") duck = gltf.geometry("LOD3spShape") - velocity_attr = Attribute("velocity", AttributeFormat.Float3) - home_attr = Attribute("home", AttributeFormat.Float3) - steer_attr = Attribute("steer", AttributeFormat.Float3) - - p = Particles( + p = create_particles( geometry=duck, attributes=[ Attribute.position(), Attribute.rotation(), Attribute.color(), - velocity_attr, - home_attr, - steer_attr, + Attribute.velocity(), + Attribute("home", AttributeFormat.Float3), + Attribute("steer", AttributeFormat.Float3), ], ) # The duck's vertices become the boids' homes. Every tuning constant is # derived from the mesh's bounding box, so the sketch doesn't care what # units the model was authored in. - homes = p.buffer(Attribute.position()).read() + homes = p.buffer("position").read() boid_count = len(homes) window_title(f"GPU Flocking Duck — {boid_count:,} boids") lo = [min(v[i] for v in homes) for i in range(3)] @@ -226,7 +85,7 @@ def setup(): extent = sum((hi[i] - lo[i]) ** 2 for i in range(3)) ** 0.5 max_speed = 0.15 * extent - p.buffer(home_attr).write(homes) + p.buffer("home").write(homes) velocities = [] rotations = [] @@ -237,17 +96,17 @@ def setup(): c = hsva(uniform(38.0, 58.0), 0.85, 1.0) colors.append([c.r, c.g, c.b, 1.0]) - p.buffer(velocity_attr).write(velocities) - p.buffer(Attribute.rotation()).write(rotations) - color_buf = p.buffer(Attribute.color()) + p.buffer("velocity").write(velocities) + p.buffer("rotation").write(rotations) + color_buf = p.buffer("color") color_buf.write(colors) s = 0.008 * extent boid = boid_geometry(1.2 * s, 3.5 * s, 0.4 * s) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=color_buf) - flock_pass = Compute(Shader(FLOCK_SHADER)) - integrate_pass = Compute(Shader(INTEGRATE_SHADER)) + flock_pass = create_compute(load_shader("shaders/flocking_duck_flock.wesl")) + integrate_pass = create_compute(load_shader("shaders/flocking_duck_integrate.wesl")) def draw(): diff --git a/crates/processing_pyo3/examples/flocking_gpu.py b/crates/processing_pyo3/examples/flocking_gpu.py index 9cba06de..01afff47 100644 --- a/crates/processing_pyo3/examples/flocking_gpu.py +++ b/crates/processing_pyo3/examples/flocking_gpu.py @@ -18,138 +18,9 @@ # Pass 1: every boid reads the whole flock's state and writes only its # steering force. Splitting the read from the write mirrors the CPU # example's two loops — no boid sees a half-updated neighbor. -FLOCK_SHADER = """ -struct Params { - neighbor_dist: f32, - separation_dist: f32, - max_speed: f32, - max_force: f32, -} - -@group(0) @binding(0) var position: array; -@group(0) @binding(1) var velocity: array; -@group(0) @binding(2) var steer: array; -@group(0) @binding(3) var params: Params; - -fn limit(v: vec3, max_len: f32) -> vec3 { - let len = length(v); - if len > max_len { return v * (max_len / len); } - return v; -} - -// Reynolds: steering = desired - velocity -fn steer_toward(desired: vec3, vel: vec3) -> vec3 { - let len = length(desired); - if len < 1e-6 { return vec3(0.0); } - return limit(desired * (params.max_speed / len) - vel, params.max_force); -} - -@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); - let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); - - var separation = vec3(0.0); - var alignment = vec3(0.0); - var cohesion = vec3(0.0); - var separation_count = 0u; - var neighbor_count = 0u; - - for (var j = 0u; j < count; j = j + 1u) { - if j == i { continue; } - let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); - let d = distance(pos, other); - if d > 0.0 && d < params.separation_dist { - // Point away from the neighbor, weighted by closeness - separation = separation + normalize(pos - other) / d; - separation_count = separation_count + 1u; - } - if d < params.neighbor_dist { - alignment = alignment - + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); - cohesion = cohesion + other; - neighbor_count = neighbor_count + 1u; - } - } - - var force = vec3(0.0); - if separation_count > 0u { - force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; - } - if neighbor_count > 0u { - force = force + steer_toward(alignment, vel); - force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); - } - - steer[i * 3u] = force.x; - steer[i * 3u + 1u] = force.y; - steer[i * 3u + 2u] = force.z; -} -""" # Pass 2: integrate the steering force, wrap at the box edges, and point # each instanced boid along its velocity via the rotation quaternion. -INTEGRATE_SHADER = """ -struct Params { - dt: f32, - max_speed: f32, - bound: f32, - _pad: f32, -} - -@group(0) @binding(0) var position: array; -@group(0) @binding(1) var velocity: array; -@group(0) @binding(2) var steer: array; -@group(0) @binding(3) var rotation: array; -@group(0) @binding(4) var params: Params; - -// shortest-arc quaternion rotating the mesh's +Z axis onto dir -fn quat_z_to(dir: vec3) -> vec4 { - let z = vec3(0.0, 0.0, 1.0); - let d = dot(z, dir); - if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } - return normalize(vec4(cross(z, dir), 1.0 + d)); -} - -@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 pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); - var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); - let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); - - vel = vel + force * params.dt; - let speed = length(vel); - if speed > params.max_speed { vel = vel * (params.max_speed / speed); } - pos = pos + vel * params.dt; - - // wrap into [-bound, bound]: ((p + b) mod 2b + 2b) mod 2b - b - let span = 2.0 * params.bound; - pos = ((pos + params.bound) % span + span) % span - params.bound; - - position[i * 3u] = pos.x; - position[i * 3u + 1u] = pos.y; - position[i * 3u + 2u] = pos.z; - velocity[i * 3u] = vel.x; - velocity[i * 3u + 1u] = vel.y; - velocity[i * 3u + 2u] = vel.z; - - if speed > 1e-6 { - let q = quat_z_to(vel / speed); - rotation[i * 4u] = q.x; - rotation[i * 4u + 1u] = q.y; - rotation[i * 4u + 2u] = q.z; - rotation[i * 4u + 3u] = q.w; - } -} -""" p = None boid = None @@ -164,7 +35,7 @@ # boid pointing down +Z. The fold keeps the boid visible edge-on and gives # each wing its own normal, so the flock glints as it banks. def boid_geometry(half_width, length, droop): - g = Geometry() + g = create_geometry() n = (half_width * half_width + droop * droop) ** 0.5 nose = (0.0, 0.0, length * 0.5) tail = (0.0, 0.0, -length * 0.5) @@ -190,17 +61,14 @@ def setup(): directional_light((0.95, 0.9, 0.85), 800.0) - velocity_attr = Attribute("velocity", AttributeFormat.Float3) - steer_attr = Attribute("steer", AttributeFormat.Float3) - - p = Particles( + p = create_particles( capacity=BOID_COUNT, attributes=[ Attribute.position(), Attribute.rotation(), Attribute.color(), - velocity_attr, - steer_attr, + Attribute.velocity(), + Attribute("steer", AttributeFormat.Float3), ], ) @@ -215,17 +83,17 @@ def setup(): c = hsva(uniform(190.0, 280.0), 0.7, 1.0) colors.append([c.r, c.g, c.b, 1.0]) - p.buffer(Attribute.position()).write(positions) - p.buffer(Attribute.rotation()).write(rotations) - p.buffer(velocity_attr).write(velocities) - color_buf = p.buffer(Attribute.color()) + p.buffer("position").write(positions) + p.buffer("rotation").write(rotations) + p.buffer("velocity").write(velocities) + color_buf = p.buffer("color") color_buf.write(colors) boid = boid_geometry(0.4, 1.3, 0.15) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=color_buf) - flock_pass = Compute(Shader(FLOCK_SHADER)) - integrate_pass = Compute(Shader(INTEGRATE_SHADER)) + flock_pass = create_compute(load_shader("shaders/flocking_gpu_flock.wesl")) + integrate_pass = create_compute(load_shader("shaders/flocking_gpu_integrate.wesl")) def draw(): diff --git a/crates/processing_pyo3/examples/geometry_methods.py b/crates/processing_pyo3/examples/geometry_methods.py index 5f864554..c281a4ed 100644 --- a/crates/processing_pyo3/examples/geometry_methods.py +++ b/crates/processing_pyo3/examples/geometry_methods.py @@ -8,7 +8,7 @@ def setup(): size(640, 480) mode_3d() - geometry = Geometry() + geometry = create_geometry() geometry.normal(0.0, 0.0, 1.0) diff --git a/crates/processing_pyo3/examples/materials.py b/crates/processing_pyo3/examples/materials.py index 2a608a98..1924fb59 100644 --- a/crates/processing_pyo3/examples/materials.py +++ b/crates/processing_pyo3/examples/materials.py @@ -11,10 +11,11 @@ def setup(): p_light = point_light((1.0, 1.0, 1.0), 100000.0, 800.0, 0.0) p_light.position(200.0, 200.0, 400.0) - mat = Material() - mat.set(roughness=0.3) - mat.set(metallic=0.8) - mat.set(base_color=[1.0, 0.85, 0.57, 1.0]) + mat = create_material( + roughness=0.3, + metallic=0.8, + base_color=[1.0, 0.85, 0.57, 1.0], + ) def draw(): camera_position(0.0, 0.0, 200.0) diff --git a/crates/processing_pyo3/examples/particles_animated.py b/crates/processing_pyo3/examples/particles_animated.py index d84ddd6a..b80c9335 100644 --- a/crates/processing_pyo3/examples/particles_animated.py +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -5,30 +5,6 @@ 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 p, sphere, mat, spin @@ -47,12 +23,12 @@ def setup(): for z in range(10): positions.extend([x - 4.5, y - 4.5, z - 4.5]) - p = Particles(capacity=capacity, attributes=[Attribute.position()]) - pos_buf = p.buffer(Attribute.position()) + p = create_particles(capacity=capacity, attributes=[Attribute.position()]) + pos_buf = p.buffer("position") pos_buf.write(positions) - mat = Material(roughness=0.4) - spin = Compute(Shader(SPIN_SHADER)) + mat = create_material(roughness=0.4) + spin = create_compute(load_shader("shaders/particles_animated_spin.wesl")) def draw(): diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py index 44ddde34..ae897b85 100644 --- a/crates/processing_pyo3/examples/particles_basic.py +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -14,13 +14,13 @@ def setup(): directional_light((0.95, 0.9, 0.85), 600.0) source = Geometry.sphere(5.0, 32, 24) - p = Particles( + p = create_particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = p.buffer(Attribute.uv()) - color_buf = p.buffer(Attribute.color()) + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") colors = [] for uv in uv_buf.read(): @@ -29,7 +29,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py index 4153e416..4a3db9dc 100644 --- a/crates/processing_pyo3/examples/particles_emit.py +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -16,16 +16,16 @@ def setup(): sphere = Geometry.sphere(0.08, 8, 6) capacity = 2000 - p = Particles( + p = create_particles( capacity=capacity, attributes=[Attribute.position(), Attribute.color()], ) - pos_buf = p.buffer(Attribute.position()) + pos_buf = p.buffer("position") pos_buf.write([1.0e6] * (capacity * 3)) - color_buf = p.buffer(Attribute.color()) - mat = Material.unlit(albedo=color_buf) + color_buf = p.buffer("color") + mat = create_material(unlit=True, albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py index d70027e7..22093ad3 100644 --- a/crates/processing_pyo3/examples/particles_emit_gpu.py +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -14,114 +14,6 @@ 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 life: 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; - life[slot] = 1.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 life: 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 life[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 { life[i] = 0.0; } -} -""" - def setup(): global p, particle, mat, spawn, motion @@ -133,26 +25,25 @@ def setup(): particle = Geometry.sphere(0.12, 8, 6) - velocity_attr = Attribute("velocity", AttributeFormat.Float3) - age_attr = Attribute("age", AttributeFormat.Float) - - p = Particles( + # Attributes a compute shader binds must exist when its bind group is built, + # so declare them (all built-in here). `velocity`/`age` are built-ins now, so + # no custom `Attribute(...)` is needed. + p = create_particles( capacity=CAPACITY, attributes=[ Attribute.position(), + Attribute.velocity(), Attribute.color(), Attribute.scale(), + Attribute.age(), Attribute.life(), - velocity_attr, - age_attr, ], ) - color_buf = p.buffer(Attribute.color()) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=p.buffer("color")) - spawn = Compute(Shader(SPAWN_SHADER)) - motion = Compute(Shader(MOTION_SHADER)) + spawn = create_compute(load_shader("shaders/particles_emit_gpu_spawn.wesl")) + motion = create_compute(load_shader("shaders/particles_emit_gpu_motion.wesl")) def draw(): diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py index b6e9a5a9..f7e5d64a 100644 --- a/crates/processing_pyo3/examples/particles_from_mesh.py +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -14,13 +14,13 @@ def setup(): directional_light((0.95, 0.9, 0.85), 200.0) source = Geometry.sphere(5.0, 32, 24) - p = Particles( + p = create_particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = p.buffer(Attribute.uv()) - color_buf = p.buffer(Attribute.color()) + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") colors = [] for uv in uv_buf.read(): @@ -29,7 +29,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=color_buf) def draw(): diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py index 8b5f437f..d6ecdd2e 100644 --- a/crates/processing_pyo3/examples/particles_lifecycle.py +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -5,57 +5,15 @@ sphere = None mat = None aging = None -position_attr = None -color_attr = None -scale_attr = None -life_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 life: 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 life[i] <= 0.0 { - return; - } - - age[i] = age[i] + dt; - position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; - - let remaining = clamp(1.0 - age[i] / ttl, 0.0, 1.0); - let s = remaining * remaining; - scale[i * 3u + 0u] = s; - scale[i * 3u + 1u] = s; - scale[i * 3u + 2u] = s; - - if age[i] > ttl { - life[i] = 0.0; - } -} -""" - def setup(): global p, sphere, mat, aging - global position_attr, color_attr, scale_attr, life_attr, age_attr size(900, 700) mode_3d() @@ -63,19 +21,19 @@ def setup(): sphere = Geometry.sphere(0.1, 8, 6) capacity = 800 - position_attr = Attribute.position() - color_attr = Attribute.color() - scale_attr = Attribute.scale() - life_attr = Attribute.life() - age_attr = Attribute("age", AttributeFormat.Float) - - p = Particles( + p = create_particles( capacity=capacity, - attributes=[position_attr, color_attr, scale_attr, life_attr, age_attr], + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute.scale(), + Attribute.life(), + Attribute.age(), + ], ) - color_buf = p.buffer(color_attr) - mat = Material.unlit(albedo=color_buf) - aging = Compute(Shader(AGING_SHADER)) + color_buf = p.buffer("color") + mat = create_material(unlit=True, albedo=color_buf) + aging = create_compute(load_shader("shaders/particles_lifecycle_aging.wesl")) def draw(): diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py index 1eb0aa60..ba2aa993 100644 --- a/crates/processing_pyo3/examples/particles_noise.py +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -15,13 +15,13 @@ def setup(): directional_light((0.95, 0.9, 0.85), 200.0) source = Geometry.sphere(5.0, 32, 24) - p = Particles( + p = create_particles( geometry=source, attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) - uv_buf = p.buffer(Attribute.uv()) - color_buf = p.buffer(Attribute.color()) + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") colors = [] for uv in uv_buf.read(): @@ -30,7 +30,7 @@ def setup(): color_buf.write(colors) particle = Geometry.sphere(0.18, 10, 8) - mat = Material.pbr(albedo=color_buf) + mat = create_material(albedo=color_buf) noise = Particles.noise() diff --git a/crates/processing_pyo3/examples/particles_scatter_volume.py b/crates/processing_pyo3/examples/particles_scatter_volume.py index 045b79e5..878409cd 100644 --- a/crates/processing_pyo3/examples/particles_scatter_volume.py +++ b/crates/processing_pyo3/examples/particles_scatter_volume.py @@ -26,20 +26,19 @@ def setup(): particle = Geometry.sphere(0.15, 4, 3) - age_attr = Attribute("age", AttributeFormat.Float) - p = Particles( + p = create_particles( capacity=CAPACITY, attributes=[ Attribute.position(), Attribute.scale(), Attribute.life(), - age_attr, + Attribute.age(), ], ) - mat = Material.unlit(albedo=[1.0, 1.0, 1.0, 1.0]) + mat = create_material(unlit=True, albedo=[1.0, 1.0, 1.0, 1.0]) decay = Particles.attr_linear() - decay.set(op=p.buffer(Attribute.scale()), scale=0.985, offset=0.0) + decay.set(op=p.buffer("scale"), scale=0.985, offset=0.0) def draw(): diff --git a/crates/processing_pyo3/examples/particles_stress.py b/crates/processing_pyo3/examples/particles_stress.py index a0de597f..b86a3ec4 100644 --- a/crates/processing_pyo3/examples/particles_stress.py +++ b/crates/processing_pyo3/examples/particles_stress.py @@ -24,17 +24,17 @@ def setup(): 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( + p = create_particles( geometry=Geometry.grid(GRID, GRID, GRID, SPACING), attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], ) p.apply(Particles.noise(), scale=1.0 / SPACING, strength=SPACING * 0.6) - color_buf = p.buffer(Attribute.color()) + color_buf = p.buffer("color") color_buf.write([ [c.r, c.g, c.b, 1.0] - for uv in p.buffer(Attribute.uv()).read() + for uv in p.buffer("uv").read() for c in [hsva(uv[0] * 360.0, 0.85, 1.0)] ]) diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index efad0a69..283af410 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -31,11 +31,8 @@ impl Buffer { } } -#[pymethods] impl Buffer { - #[new] - #[pyo3(signature = (size=None, data=None))] - pub fn new(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + pub(crate) fn create(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; @@ -55,7 +52,10 @@ impl Buffer { borrowed: false, }) } +} +#[pymethods] +impl Buffer { pub fn __len__(&self) -> usize { match &self.element_type { Some(et) => et @@ -275,15 +275,16 @@ impl Compute { } } -#[pymethods] impl Compute { - #[new] - pub fn new(shader: &Shader) -> PyResult { + pub(crate) fn create(shader: &Shader) -> PyResult { let entity = compute_create(shader.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } +} +#[pymethods] +impl Compute { #[pyo3(signature = (**kwargs))] pub fn set(&self, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>) -> PyResult<()> { let Some(kwargs) = kwargs else { diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 3e98cfbf..4901c6e5 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -694,11 +694,8 @@ pub struct Sketch { pub source: String, } -#[pymethods] impl Geometry { - #[new] - #[pyo3(signature = (**kwargs))] - pub fn new(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + pub(crate) fn create(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { let topology = match kwargs.and_then(|k| k.get_item("topology").ok().flatten()) { Some(t) => { let s = t.extract::()?; @@ -712,7 +709,10 @@ impl Geometry { geometry_create(topology).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity: geometry }) } +} +#[pymethods] +impl Geometry { #[pyo3(signature = (*args))] pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { let v = extract_vec4(args)?; diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index c5481432..30772834 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -1385,6 +1385,52 @@ mod mewnala { Ok(window) } + /// Creates a GPU particle system (Processing `createParticles`). `attributes` + /// defaults to `position`; other built-in attributes (`velocity`, `color`, + /// `scale`, `life`, `age`, ...) and declared custom ones materialize on + /// demand when you call `buffer("name")`. + #[pyfunction] + #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] + fn create_particles( + capacity: Option, + attributes: Option>>, + geometry: Option<&Geometry>, + ) -> PyResult { + super::particles::Particles::create(capacity, attributes, geometry) + } + + /// Creates a compute pass from a shader (Processing-style `createCompute`). + #[pyfunction] + fn create_compute(shader: &Shader) -> PyResult { + Compute::create(shader) + } + + /// Creates a GPU storage buffer, empty (`size` bytes) or from initial `data`. + #[pyfunction] + #[pyo3(signature = (size=None, data=None))] + fn create_buffer(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + Buffer::create(size, data) + } + + /// Creates a mesh builder (Processing `createShape`-style). + #[pyfunction] + #[pyo3(signature = (**kwargs))] + fn create_geometry(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + Geometry::create(kwargs) + } + + /// Creates a shader from WGSL/WESL source. + #[pyfunction] + fn create_shader(source: &str) -> PyResult { + Shader::from_source(source) + } + + /// Loads a shader from a file (Processing `loadShader`). + #[pyfunction] + fn load_shader(path: &str) -> PyResult { + Shader::from_path(path) + } + fn apply_light_transform( light: &Light, position: Option, diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 2316ffd0..8e033674 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -1,6 +1,6 @@ use bevy::prelude::Entity; use processing::prelude::*; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyInt}; use pyo3::{exceptions::PyRuntimeError, prelude::*}; use crate::color::PyColor; @@ -18,12 +18,17 @@ pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult() { return Ok(shader_value::ShaderValue::Texture(img_ref.entity)); } + if let Ok(int_val) = value.cast::() { + if let Ok(v) = int_val.extract::() { + return Ok(shader_value::ShaderValue::Int(v)); + } + if let Ok(v) = int_val.extract::() { + return Ok(shader_value::ShaderValue::UInt(v)); + } + } if let Ok(v) = value.extract::() { return Ok(shader_value::ShaderValue::Float(v)); } - if let Ok(v) = value.extract::() { - return Ok(shader_value::ShaderValue::Int(v)); - } if let Ok(v) = value.extract::>() { return Ok(shader_value::ShaderValue::Float4(v.0.to_array())); diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index e2a6c2f4..b812df77 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -2,7 +2,10 @@ use bevy::prelude::Entity; use processing::prelude::*; use processing_render::geometry; use pyo3::types::PyDict; -use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, +}; use std::collections::HashMap; use crate::compute::{Buffer, Compute}; @@ -103,6 +106,18 @@ impl Attribute { entity: geometry_attribute_life(), } } + #[staticmethod] + pub fn velocity() -> Self { + Self { + entity: geometry_attribute_velocity(), + } + } + #[staticmethod] + pub fn age() -> Self { + Self { + entity: geometry_attribute_age(), + } + } #[getter] pub fn name(&self) -> PyResult { @@ -137,23 +152,57 @@ impl Particles { } Ok(map) } -} -#[pymethods] -impl Particles { - /// pass `capacity` for empty buffers, or `geometry` to seed from a source mesh. - #[new] - #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] - pub fn new( + /// Resolve a built-in attribute name to its `Attribute`. + fn builtin_attribute(name: &str) -> Option { + Some(match name { + "position" => Attribute::position(), + "velocity" => Attribute::velocity(), + "normal" => Attribute::normal(), + "color" => Attribute::color(), + "uv" => Attribute::uv(), + "rotation" => Attribute::rotation(), + "scale" => Attribute::scale(), + "life" => Attribute::life(), + "age" => Attribute::age(), + _ => return None, + }) + } + + /// Resolve a `buffer()` argument (an attribute name string or an `Attribute`) + /// to its attribute entity. Built-in names map to their factory; other names + /// must have been declared as custom attributes at construction. + fn resolve_attribute(&self, attribute: &Bound<'_, PyAny>) -> PyResult { + if let Ok(attr) = attribute.extract::() { + return Ok(attr.entity); + } + if let Ok(name) = attribute.extract::() { + if let Some(attr) = Self::builtin_attribute(&name) { + return Ok(attr.entity); + } + if let Some((entity, _)) = self.name_to_attr.get(&name) { + return Ok(*entity); + } + return Err(PyValueError::new_err(format!( + "\"{name}\" is not a built-in attribute; pass its Attribute to buffer()" + ))); + } + Err(PyTypeError::new_err( + "buffer() expects an attribute name or an Attribute", + )) + } + + /// Build a particle system (backs `create_particles`). Attributes default to + /// `position`; the rest (built-in or declared custom) materialize on demand. + pub(crate) fn create( capacity: Option, attributes: Option>>, geometry: Option<&Geometry>, ) -> PyResult { - let attrs: Vec = attributes - .unwrap_or_default() - .iter() - .map(|a| (**a).clone()) - .collect(); + let attrs: Vec = match attributes { + Some(list) => list.iter().map(|a| (**a).clone()).collect(), + None => vec![Attribute::position()], + }; let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); let entity = match (capacity, geometry) { @@ -163,12 +212,12 @@ impl Particles { .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, (None, None) => { return Err(PyRuntimeError::new_err( - "Particles requires either capacity or geometry", + "create_particles() requires either capacity or geometry", )); } (Some(_), Some(_)) => { return Err(PyRuntimeError::new_err( - "Particles accepts capacity or geometry, not both", + "create_particles() accepts capacity or geometry, not both", )); } }; @@ -178,7 +227,10 @@ impl Particles { name_to_attr: Particles::build_name_index(&attrs)?, }) } +} +#[pymethods] +impl Particles { #[getter] pub fn capacity(&self) -> PyResult { particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -202,10 +254,15 @@ impl Particles { Ok(()) } - pub fn buffer(&self, attribute: &Attribute) -> PyResult> { - let buf = particles_buffer(self.entity, attribute.entity) + /// The GPU buffer for an attribute, materialized on demand. `attribute` is a + /// built-in name (`"position"`, `"velocity"`, `"color"`, `"scale"`, `"life"`, + /// `"age"`, `"normal"`, `"uv"`, `"rotation"`), a declared custom attribute's + /// name, or an `Attribute`. + pub fn buffer(&self, attribute: &Bound<'_, PyAny>) -> PyResult { + let attr_entity = self.resolve_attribute(attribute)?; + let buf = particles_ensure_attribute(self.entity, attr_entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - let (_, fmt) = geometry_attribute_info(attribute.entity) + let (_, fmt) = geometry_attribute_info(attr_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), @@ -213,7 +270,7 @@ impl Particles { AttributeFormat::Float3 => shader_value::ShaderValue::Float3([0.0; 3]), AttributeFormat::Float4 => shader_value::ShaderValue::Float4([0.0; 4]), }; - Ok(buf.map(|e| Buffer::from_entity(e, Some(element_type)))) + Ok(Buffer::from_entity(buf, Some(element_type))) } #[pyo3(signature = (compute, **kwargs))] diff --git a/crates/processing_pyo3/src/shader.rs b/crates/processing_pyo3/src/shader.rs index 8960575b..42bef234 100644 --- a/crates/processing_pyo3/src/shader.rs +++ b/crates/processing_pyo3/src/shader.rs @@ -7,16 +7,13 @@ pub struct Shader { pub(crate) entity: Entity, } -#[pymethods] impl Shader { - #[new] - pub fn new(source: &str) -> PyResult { + pub(crate) fn from_source(source: &str) -> PyResult { let entity = shader_create(source).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } - #[staticmethod] - pub fn load(path: &str) -> PyResult { + pub(crate) fn from_path(path: &str) -> PyResult { let entity = shader_load(path).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index cfa0cd9a..4a3e53b4 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeSet, HashMap}; use bevy::asset::{AssetId, RenderAssetUsages}; use bevy::mesh::MeshVertexAttribute; -use bevy::reflect::PartialReflect; use bevy::{ prelude::*, render::{ @@ -24,9 +23,7 @@ use bevy::{ use bevy_naga_reflect::dynamic_shader::DynamicShader; use crate::geometry::{Attribute, Geometry}; -use crate::image::Image as PImage; -use crate::material::custom::{Shader, apply_reflect_field, shader_value_to_reflect}; -use crate::shader_value::ShaderValue; +use crate::material::custom::Shader; use processing_core::error::{ProcessingError, Result}; pub struct ComputePlugin; @@ -295,118 +292,6 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { Err(ProcessingError::PipelineNotReady(MAX_WAIT)) } -pub fn set_compute_property( - In((entity, name, value)): In<(Entity, String, ShaderValue)>, - mut computes: Query<&mut Compute>, - mut p_buffers: Query<&mut Buffer>, - p_images: Query<&PImage>, -) -> Result<()> { - use bevy_naga_reflect::reflect::ParameterCategory; - - let mut compute = computes - .get_mut(entity) - .map_err(|_| ProcessingError::ComputeNotFound)?; - - match value { - ShaderValue::Buffer(buf_entity) => { - let category = compute - .shader - .reflection() - .parameter(&name) - .map(|p| p.category()) - .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - let ParameterCategory::Storage { read_only } = category else { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {category:?}, got Buffer", - ))); - }; - let mut buffer = p_buffers - .get_mut(buf_entity) - .map_err(|_| ProcessingError::BufferNotFound)?; - compute.shader.insert(&name, buffer.handle.clone()); - if !read_only { - buffer.bound_rw = true; - } - Ok(()) - } - ShaderValue::MeshAttribute(geom_entity, attribute_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 MeshAttribute", - ))); - }; - if !read_only { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` is read-write; mesh attribute buffers can only bind as read-only", - ))); - } - compute.mesh_bindings.insert( - name, - MeshBindingRef::Attribute { - geom: geom_entity, - attribute: attribute_entity, - }, - ); - Ok(()) - } - ShaderValue::MeshIndex(geom_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 MeshIndex", - ))); - }; - if !read_only { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` is read-write; mesh index buffer can only bind as read-only", - ))); - } - compute - .mesh_bindings - .insert(name, MeshBindingRef::Index { geom: geom_entity }); - Ok(()) - } - ShaderValue::Texture(img_entity) => { - let category = compute - .shader - .reflection() - .parameter(&name) - .map(|p| p.category()) - .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; - if !matches!( - category, - ParameterCategory::Texture - | ParameterCategory::StorageTexture - | ParameterCategory::Sampler - ) { - return Err(ProcessingError::InvalidArgument(format!( - "property `{name}` expects {category:?}, got Texture", - ))); - } - let image = p_images - .get(img_entity) - .map_err(|_| ProcessingError::ImageNotFound)?; - compute.shader.insert(&name, image.handle.clone()); - Ok(()) - } - v => { - let reflect_value: Box = shader_value_to_reflect(&v)?; - apply_reflect_field(&mut compute.shader, &name, &*reflect_value) - } - } -} - pub fn dispatch( In((pipeline_id, layout_descriptors, shader, mesh_bindings, x, y, z)): In<( CachedComputePipelineId, diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 1a35cc1b..286b7935 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -26,6 +26,7 @@ pub use particles::{ FALLOFF_INVERSE, FALLOFF_LINEAR, FALLOFF_QUADRATIC, FALLOFF_SMOOTHSTEP, particles_apply, particles_attribute_add, particles_buffer, particles_capacity, particles_create, particles_create_from_geometry, particles_destroy, particles_emit, particles_emit_gpu, + particles_ensure_attribute, particles_kernel_age, particles_kernel_attr_combine, particles_kernel_attr_linear, particles_kernel_attr_lookup1d, particles_kernel_attr_lookup2d, particles_kernel_attr_mix, particles_kernel_attract, particles_kernel_bounds_box, particles_kernel_bounds_geometry, diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 7b7ae81c..e7fe6052 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -289,7 +289,7 @@ pub(crate) fn apply_reflect_field( value: &dyn PartialReflect, ) -> Result<()> { if let Some(field) = shader.field_mut(name) { - field.apply(value); + apply_field_coerced(field, value); return Ok(()); } @@ -299,13 +299,41 @@ pub(crate) fn apply_reflect_field( && let ReflectMut::Struct(s) = param.reflect_mut() && let Some(field) = s.field_mut(name) { - field.apply(value); + apply_field_coerced(field, value); return Ok(()); } Err(ProcessingError::UnknownShaderProperty(name.to_string())) } +fn reflect_scalar_as_f64(value: &dyn PartialReflect) -> Option { + if let Some(v) = value.try_downcast_ref::() { + Some(*v as f64) + } else if let Some(v) = value.try_downcast_ref::() { + Some(*v as f64) + } else if let Some(v) = value.try_downcast_ref::() { + Some(*v as f64) + } else { + None + } +} + +fn apply_field_coerced(field: &mut dyn PartialReflect, value: &dyn PartialReflect) { + if let Some(n) = reflect_scalar_as_f64(value) { + if field.try_downcast_ref::().is_some() { + field.apply((n as f32).as_partial_reflect()); + return; + } else if field.try_downcast_ref::().is_some() { + field.apply((n as u32).as_partial_reflect()); + return; + } else if field.try_downcast_ref::().is_some() { + field.apply((n as i32).as_partial_reflect()); + return; + } + } + field.apply(value); +} + pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result> { Ok(match value { ShaderValue::Float(v) => Box::new(*v), diff --git a/crates/processing_render/src/shader_property.rs b/crates/processing_render/src/shader_property.rs index c5047302..f0c50f5a 100644 --- a/crates/processing_render/src/shader_property.rs +++ b/crates/processing_render/src/shader_property.rs @@ -2,13 +2,51 @@ use bevy::prelude::*; use bevy_naga_reflect::dynamic_shader::DynamicShader; use bevy_naga_reflect::reflect::ParameterCategory; -use crate::compute::{Buffer, Compute}; +use crate::compute::{Buffer, Compute, MeshBindingRef}; use crate::image::Image as PImage; use crate::material::custom::{apply_reflect_field, shader_value_to_reflect}; use crate::render::filter::Filter; use crate::shader_value::ShaderValue; use processing_core::error::{ProcessingError, Result}; +fn require_read_only_storage(shader: &DynamicShader, name: &str, kind: &str) -> Result<()> { + let category = shader + .reflection() + .parameter(name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.to_string()))?; + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got {kind}", + ))); + }; + if !read_only { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` is read-write; {kind} buffers can only bind as read-only", + ))); + } + Ok(()) +} + +fn bind_compute_mesh(compute: &mut Compute, name: String, value: ShaderValue) -> Result<()> { + match value { + ShaderValue::MeshAttribute(geom, attribute) => { + require_read_only_storage(&compute.shader, &name, "mesh attribute")?; + compute + .mesh_bindings + .insert(name, MeshBindingRef::Attribute { geom, attribute }); + } + ShaderValue::MeshIndex(geom) => { + require_read_only_storage(&compute.shader, &name, "mesh index")?; + compute + .mesh_bindings + .insert(name, MeshBindingRef::Index { geom }); + } + _ => unreachable!("bind_compute_mesh only handles MeshAttribute/MeshIndex"), + } + Ok(()) +} + pub(crate) fn apply_shader_value( shader: &mut DynamicShader, name: &str, @@ -72,7 +110,14 @@ pub fn set_property( p_images: Query<&PImage>, ) -> Result { if let Ok(mut compute) = computes.get_mut(entity) { - apply_shader_value(&mut compute.shader, &name, value, &mut p_buffers, &p_images)?; + match value { + ShaderValue::MeshAttribute(..) | ShaderValue::MeshIndex(..) => { + bind_compute_mesh(&mut compute, name, value)?; + } + other => { + apply_shader_value(&mut compute.shader, &name, other, &mut p_buffers, &p_images)?; + } + } return Ok(true); } if let Ok(mut filter) = filters.get_mut(entity) { From f6a9c4bf48e51bfa78143e10a97069fa44492860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Tue, 11 Aug 2026 15:02:20 -0700 Subject: [PATCH 3/3] Particle updates --- Cargo.lock | 584 +++++++++-------- Cargo.toml | 4 + assets/shaders/density_color.wesl | 23 + assets/shaders/gen_colored_surface.wesl | 65 ++ assets/shaders/gen_surface.wesl | 60 ++ assets/shaders/plexus_curve.wesl | 42 ++ assets/shaders/plexus_link.wesl | 114 ++++ crates/processing_core/src/constants.rs | 62 ++ crates/processing_ffi/src/lib.rs | 155 ++++- .../processing_pyo3/examples/flocking_gpu.py | 34 +- .../examples/particles_density.py | 54 ++ .../examples/particles_gpu_surface.py | 37 ++ .../examples/particles_gpu_surface_lit.py | 40 ++ .../examples/particles_lines.py | 47 ++ .../examples/particles_lissajous.py | 85 +++ .../examples/particles_plexus.py | 74 +++ .../examples/particles_points.py | 37 ++ .../examples/particles_scatter_volume.py | 8 +- .../examples/particles_sphere.py | 39 ++ .../examples/particles_surface.py | 55 ++ crates/processing_pyo3/src/compute.rs | 52 +- crates/processing_pyo3/src/constants.rs | 28 + crates/processing_pyo3/src/graphics.rs | 63 +- crates/processing_pyo3/src/lib.rs | 30 +- crates/processing_pyo3/src/particles.rs | 609 +++++++++++++++-- .../shaders/processing/particles.wesl | 28 + crates/processing_render/src/compute.rs | 20 + .../src/geometry/attribute.rs | 25 +- crates/processing_render/src/geometry/mod.rs | 1 + crates/processing_render/src/image.rs | 5 +- crates/processing_render/src/lib.rs | 68 +- .../processing_render/src/material/custom.rs | 188 +++++- .../src/particles/algebra.rs | 613 ++++++++++++++++++ .../src/particles/compact.rs | 66 ++ .../processing_render/src/particles/emit.rs | 97 ++- .../processing_render/src/particles/grid.rs | 112 ++++ .../src/particles/kernels/attract.wgsl | 16 +- .../src/particles/kernels/bitonic.wgsl | 29 + .../src/particles/kernels/compact_flag.wgsl | 15 + .../particles/kernels/compact_scatter.wgsl | 12 + .../src/particles/kernels/field.wgsl | 18 +- .../src/particles/kernels/flock.wgsl | 141 ++-- .../src/particles/kernels/grid_clear.wgsl | 8 + .../src/particles/kernels/grid_copy.wgsl | 9 + .../src/particles/kernels/grid_count.wgsl | 24 + .../src/particles/kernels/grid_scatter.wgsl | 26 + .../src/particles/kernels/impulse.wgsl | 16 +- .../src/particles/kernels/mod.rs | 12 + .../src/particles/kernels/neighbor.wgsl | 94 +++ .../src/particles/kernels/reduce.wgsl | 52 ++ .../src/particles/kernels/scan_add.wgsl | 13 + .../src/particles/kernels/scan_block.wgsl | 34 + .../src/particles/kernels/vortex.wgsl | 16 +- crates/processing_render/src/particles/mod.rs | 178 ++++- .../src/particles/point.wgsl | 55 ++ .../src/particles/point_render.rs | 482 ++++++++++++++ .../processing_render/src/particles/reduce.rs | 83 +++ .../processing_render/src/particles/scan.rs | 89 +++ .../processing_render/src/particles/sort.rs | 58 ++ .../processing_render/src/render/command.rs | 3 +- crates/processing_render/src/render/mod.rs | 117 +++- .../processing_render/src/shader_property.rs | 4 +- crates/processing_render/src/shader_value.rs | 22 +- crates/processing_render/src/surface.rs | 26 +- crates/processing_wasm/src/lib.rs | 3 +- examples/alias_spike.rs | 77 +++ 66 files changed, 4803 insertions(+), 553 deletions(-) create mode 100644 assets/shaders/density_color.wesl create mode 100644 assets/shaders/gen_colored_surface.wesl create mode 100644 assets/shaders/gen_surface.wesl create mode 100644 assets/shaders/plexus_curve.wesl create mode 100644 assets/shaders/plexus_link.wesl create mode 100644 crates/processing_pyo3/examples/particles_density.py create mode 100644 crates/processing_pyo3/examples/particles_gpu_surface.py create mode 100644 crates/processing_pyo3/examples/particles_gpu_surface_lit.py create mode 100644 crates/processing_pyo3/examples/particles_lines.py create mode 100644 crates/processing_pyo3/examples/particles_lissajous.py create mode 100644 crates/processing_pyo3/examples/particles_plexus.py create mode 100644 crates/processing_pyo3/examples/particles_points.py create mode 100644 crates/processing_pyo3/examples/particles_sphere.py create mode 100644 crates/processing_pyo3/examples/particles_surface.py create mode 100644 crates/processing_render/shaders/processing/particles.wesl create mode 100644 crates/processing_render/src/particles/algebra.rs create mode 100644 crates/processing_render/src/particles/compact.rs create mode 100644 crates/processing_render/src/particles/grid.rs create mode 100644 crates/processing_render/src/particles/kernels/bitonic.wgsl create mode 100644 crates/processing_render/src/particles/kernels/compact_flag.wgsl create mode 100644 crates/processing_render/src/particles/kernels/compact_scatter.wgsl create mode 100644 crates/processing_render/src/particles/kernels/grid_clear.wgsl create mode 100644 crates/processing_render/src/particles/kernels/grid_copy.wgsl create mode 100644 crates/processing_render/src/particles/kernels/grid_count.wgsl create mode 100644 crates/processing_render/src/particles/kernels/grid_scatter.wgsl create mode 100644 crates/processing_render/src/particles/kernels/neighbor.wgsl create mode 100644 crates/processing_render/src/particles/kernels/reduce.wgsl create mode 100644 crates/processing_render/src/particles/kernels/scan_add.wgsl create mode 100644 crates/processing_render/src/particles/kernels/scan_block.wgsl create mode 100644 crates/processing_render/src/particles/point.wgsl create mode 100644 crates/processing_render/src/particles/point_render.rs create mode 100644 crates/processing_render/src/particles/reduce.rs create mode 100644 crates/processing_render/src/particles/scan.rs create mode 100644 crates/processing_render/src/particles/sort.rs create mode 100644 examples/alias_spike.rs diff --git a/Cargo.lock b/Cargo.lock index 56159367..c1bf6e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,9 +109,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -179,7 +179,7 @@ dependencies = [ "ndk-sys", "num_enum", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -205,9 +205,9 @@ checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -475,9 +475,9 @@ checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24" [[package]] name = "audioadapter" -version = "3.0.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91f87b70b051c5866680ad79f6743a42ccab264c009d1a71f4d33a3872ae60c8" +checksum = "c75c3943c6c7279bb25a449a8d1727480730ab2efd7b6fd5d6ca51927096e6e4" dependencies = [ "audio-core", "num-traits", @@ -485,9 +485,9 @@ dependencies = [ [[package]] name = "audioadapter-buffers" -version = "3.0.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9097d67933fb083d382ce980430afdb758aada60846010aee6be068c06cef0ca" +checksum = "ece3390b6eb40379094843a1da5aaccc34bc0d85a8cbf68d09fe092fee6de29e" dependencies = [ "audioadapter", "audioadapter-sample", @@ -496,9 +496,9 @@ dependencies = [ [[package]] name = "audioadapter-sample" -version = "3.0.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ab94f2bc04a14e1f49ee5f222f66460e8a1b51627bdfedf34eed394d747938" +checksum = "1592f90413568e259413c21a41a3d571feb1774255c209e7966d98f9db708c90" dependencies = [ "audio-core", "num-traits", @@ -525,7 +525,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -613,7 +613,7 @@ dependencies = [ "ron", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "thread_local", "tracing", "uuid", @@ -624,7 +624,7 @@ name = "bevy_animation_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "quote", "syn 2.0.119", ] @@ -665,7 +665,7 @@ dependencies = [ "ctrlc", "downcast-rs 2.0.2", "log", - "thiserror 2.0.19", + "thiserror 2.0.20", "variadics_please", "wasm-bindgen", "web-sys", @@ -706,7 +706,7 @@ dependencies = [ "ron", "serde", "stackfuture", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", "wasm-bindgen", @@ -719,7 +719,7 @@ name = "bevy_asset_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", "syn 2.0.119", @@ -761,7 +761,7 @@ dependencies = [ "downcast-rs 2.0.2", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu-types", ] @@ -807,7 +807,7 @@ dependencies = [ "derive_more", "encase", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu-types", ] @@ -847,7 +847,7 @@ dependencies = [ "ash", "bevy", "cudarc", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu", "wgpu-hal", "windows 0.58.0", @@ -858,7 +858,7 @@ name = "bevy_derive" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "quote", "syn 2.0.119", ] @@ -934,7 +934,7 @@ dependencies = [ "serde", "slotmap", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "variadics_please", ] @@ -943,7 +943,7 @@ name = "bevy_ecs_macro_logic" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", "syn 2.0.119", @@ -955,7 +955,7 @@ version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_ecs_macro_logic", - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", "syn 2.0.119", @@ -966,7 +966,7 @@ name = "bevy_encase_derive" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "encase_derive_impl", ] @@ -1012,7 +1012,7 @@ dependencies = [ "bevy_platform", "bevy_time", "gilrs", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -1043,7 +1043,7 @@ name = "bevy_gizmos_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "quote", "syn 2.0.119", ] @@ -1106,7 +1106,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wgpu-types", ] @@ -1134,7 +1134,7 @@ dependencies = [ "rectangle-pack", "ruzstd", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wgpu-types", ] @@ -1152,7 +1152,7 @@ dependencies = [ "derive_more", "log", "smol_str", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1168,7 +1168,7 @@ dependencies = [ "bevy_reflect", "bevy_window", "log", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1275,8 +1275,7 @@ dependencies = [ [[package]] name = "bevy_macro_utils" version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "746a19912c6dc1bbe79188778573e8a253d5832c696b2fcb95578c17b29ff7ba" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "proc-macro2", "quote", @@ -1286,8 +1285,9 @@ dependencies = [ [[package]] name = "bevy_macro_utils" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7164fe229422295bba15f1885048f34f4660db069aacb895208f996b0c7784fb" dependencies = [ "proc-macro2", "quote", @@ -1311,7 +1311,7 @@ dependencies = [ "bevy_utils", "encase", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "variadics_please", "wgpu-types", @@ -1322,7 +1322,7 @@ name = "bevy_material_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "quote", "syn 2.0.119", ] @@ -1342,7 +1342,7 @@ dependencies = [ "rand 0.10.2", "rand_distr", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "variadics_please", ] @@ -1368,7 +1368,7 @@ dependencies = [ "glam 0.32.1", "half", "hexasphere", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wgpu-types", ] @@ -1382,7 +1382,7 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" version = "0.2.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#3ec0374aaa3dadf522691154bc143050135f40cd" +source = "git+https://github.com/tychedelia/bevy_naga_reflect#3e59d3698281fcf6c56a6478be3ee12049737218" dependencies = [ "bevy", "naga", @@ -1425,7 +1425,7 @@ dependencies = [ "offset-allocator", "smallvec", "static_assertions", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wgpu-types", ] @@ -1494,7 +1494,7 @@ dependencies = [ "bevy_shader", "bevy_utils", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -1525,7 +1525,7 @@ dependencies = [ "serde", "smallvec", "smol_str", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", "variadics_please", "wgpu-types", @@ -1536,7 +1536,7 @@ name = "bevy_reflect_derive" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "indexmap", "proc-macro2", "quote", @@ -1588,7 +1588,7 @@ dependencies = [ "offset-allocator", "send_wrapper", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "variadics_please", "wasm-bindgen", "weak-table", @@ -1602,7 +1602,7 @@ name = "bevy_render_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1623,7 +1623,7 @@ dependencies = [ "bevy_scene_macros", "bevy_utils", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "variadics_please", ] @@ -1634,7 +1634,7 @@ version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_ecs_macro_logic", - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1643,7 +1643,7 @@ dependencies = [ [[package]] name = "bevy_seedling" version = "0.8.0" -source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#9db62d12daa11a2ffbd9da1565154127eac0a241" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" dependencies = [ "bevy_app", "bevy_asset", @@ -1668,10 +1668,10 @@ dependencies = [ [[package]] name = "bevy_seedling_macros" -version = "0.7.0" -source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#9db62d12daa11a2ffbd9da1565154127eac0a241" +version = "0.8.0" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" dependencies = [ - "bevy_macro_utils 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bevy_macro_utils 0.19.1", "proc-macro2", "quote", "syn 2.0.119", @@ -1689,7 +1689,7 @@ dependencies = [ "naga", "naga_oil", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wesl", "wgpu-naga-bridge", @@ -1773,7 +1773,7 @@ name = "bevy_state_macros" version = "0.19.0" source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils 0.19.0 (git+https://github.com/processing/bevy?branch=main)", + "bevy_macro_utils 0.19.0", "quote", "syn 2.0.119", ] @@ -1819,7 +1819,7 @@ dependencies = [ "smol_str", "swash", "sys-locale", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "wgpu-types", ] @@ -1851,7 +1851,7 @@ dependencies = [ "bevy_utils", "derive_more", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1886,7 +1886,7 @@ dependencies = [ "smallvec", "swash", "taffy", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", ] @@ -2025,7 +2025,7 @@ dependencies = [ "derive_more", "ron", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", ] @@ -2115,9 +2115,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", @@ -2196,13 +2196,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2270,9 +2270,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -2337,18 +2337,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -2647,9 +2647,9 @@ dependencies = [ [[package]] name = "coremidi" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1fa14fb8c3ca83d0d7f22f4afc9ecfe5f40947f01ce639a638a9377c2662dde" +checksum = "a57ede822fdaf19280cf1320a5a5d3a522c75c910d01750af1e8122b6ad2595b" dependencies = [ "block2 0.6.2", "core-foundation 0.10.1", @@ -2854,9 +2854,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "derive_more" @@ -2935,6 +2935,16 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "dlopen2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60768c353d76ba6dd1b6332a5dbbdd3bc2dddadc2935f4cc6a9d0f17ca8ecb6a" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2991,7 +3001,7 @@ checksum = "6e3e0ff2ee0b7aa97428308dd9e1e42369cb22f5fb8dc1c55546637443a60f1e" dependencies = [ "const_panic", "encase_derive", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3145,14 +3155,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "firewheel" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce6a0e2684404563f22daf00caa3672a9fd1503af5c58cd79a544a8221cec6e" dependencies = [ "firewheel-core", "firewheel-cpal", @@ -3160,13 +3171,14 @@ dependencies = [ "firewheel-nodes", "firewheel-rtaudio", "firewheel-symphonium", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "firewheel-core" -version = "0.10.1" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c793ba83a0c4861d467af68844970bd063249189790f00c7feda5019732466b" dependencies = [ "arrayvec", "audioadapter", @@ -3185,15 +3197,16 @@ dependencies = [ "portable-atomic", "ringbuf", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "thunderdome", "wmidi", ] [[package]] name = "firewheel-cpal" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf893a68d8b89fbb6eeca6deb5166fd342cc827bf4ad37d31a973bc861bf038" dependencies = [ "audioadapter-buffers", "bevy_platform", @@ -3201,14 +3214,15 @@ dependencies = [ "firewheel-core", "firewheel-graph", "fixed-resample", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] [[package]] name = "firewheel-graph" -version = "0.10.2" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00257afdba7edc48ce1667fc144d453f7bd17674a78450207507ca286e818b82" dependencies = [ "arrayvec", "audioadapter", @@ -3219,7 +3233,7 @@ dependencies = [ "num-traits", "ringbuf", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "thunderdome", "tracing", "triple_buffer", @@ -3229,7 +3243,7 @@ dependencies = [ [[package]] name = "firewheel-ircam-hrtf" version = "0.5.0" -source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#9db62d12daa11a2ffbd9da1565154127eac0a241" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" dependencies = [ "bevy_ecs", "bevy_reflect", @@ -3240,10 +3254,11 @@ dependencies = [ [[package]] name = "firewheel-macros" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19ca164d0116746e81d86266ad78d0d57e1be18437439ca6c4c8a97160bec2f" dependencies = [ - "bevy_macro_utils 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bevy_macro_utils 0.19.1", "proc-macro2", "quote", "syn 2.0.119", @@ -3252,8 +3267,9 @@ dependencies = [ [[package]] name = "firewheel-nodes" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f4d58cfbb1c65d19e4490ed81b2e16153ab5e355535ef79aef044583adfcba" dependencies = [ "bevy_ecs", "bevy_platform", @@ -3261,28 +3277,30 @@ dependencies = [ "firewheel-core", "num-traits", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "triple_buffer", ] [[package]] name = "firewheel-rtaudio" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a136e3893d1c1b691f355ba7b535e0a7df16ad49b7788aa88805bf473105a755" dependencies = [ "audioadapter-buffers", "bevy_platform", "firewheel-core", "firewheel-graph", "rtaudio", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] [[package]] name = "firewheel-symphonium" -version = "0.10.0" -source = "git+https://github.com/BillyDM/Firewheel?rev=fdf9fbb#fdf9fbb3ec41f9c7c68ed9bad38a37327c5f0c1a" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8261282ec848bcd8e64c715fb6d3778e2208819b1c5c015f04bc8709ac5465f" dependencies = [ "bevy_platform", "firewheel-core", @@ -3291,14 +3309,14 @@ dependencies = [ [[package]] name = "fixed-resample" -version = "0.11.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119588c9d2456c65ddbe9360b8643d77de3c2b6c4a588eb2fdb512d715167be3" +checksum = "e3505607cc0adc388ce98f59707477c02fbd05a2fa0c1b8c4cf42b71a3cb9c34" dependencies = [ "audioadapter", "audioadapter-buffers", "ringbuf", - "rubato 2.0.0", + "rubato 4.0.0", ] [[package]] @@ -3371,6 +3389,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontique" version = "0.7.0" @@ -3461,24 +3488,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3495,32 +3522,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -3791,7 +3818,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.62.2", ] @@ -3973,9 +4000,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3986,45 +4013,44 @@ dependencies = [ ] [[package]] -name = "icu_locale" -version = "2.2.0" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", + "displaydoc", + "litemap", + "serde", "tinystr", + "writeable", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" dependencies = [ - "displaydoc", - "litemap", - "serde", + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", "tinystr", - "writeable", "zerovec", ] [[package]] -name = "icu_locale_data" -version = "2.2.0" +name = "icu_locale_fallback_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4036,16 +4062,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -4056,15 +4083,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -4079,24 +4106,25 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" dependencies = [ "icu_collections", - "icu_locale", + "icu_locale_fallback", "icu_provider", "icu_segmenter_data", "potential_utf", + "smallvec", "utf8_iter", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" [[package]] name = "image" @@ -4256,7 +4284,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -4276,14 +4304,17 @@ dependencies = [ [[package]] name = "jni-min-helper" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed686a71bb92686dd4a49306a6055dacd0ba4751484ff3466f0bb5fae9eac122" +checksum = "913370a00ba1851b0f3369b49d0f3e745da2d20d3f15ecc296a64234ccda45f7" dependencies = [ "android-build", + "dlopen2", "jni 0.21.1", + "libc", "log", "ndk-context", + "process_path", ] [[package]] @@ -4326,9 +4357,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if 1.0.4", "futures-util", @@ -4363,9 +4394,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4560,14 +4591,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.1", + "redox_syscall 0.9.2", ] [[package]] @@ -4600,9 +4631,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4899,7 +4930,7 @@ dependencies = [ "rustc-hash", "serde", "spirv", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-ident", ] @@ -4915,7 +4946,7 @@ dependencies = [ "naga", "regex", "rustc-hash", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "unicode-ident", ] @@ -5033,7 +5064,7 @@ dependencies = [ "nokhwa-bindings-windows", "nokhwa-core", "paste", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5084,7 +5115,7 @@ dependencies = [ "bytes", "image", "mozjpeg", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5215,9 +5246,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -5978,9 +6009,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -5993,9 +6024,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "serde_core", "writeable", @@ -6069,6 +6100,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process_path" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f676f11eb0b3e2ea0fbaee218fa6b806689e2297b8c8adc5bf73df465c4f6171" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "processing" version = "0.0.8" @@ -6104,7 +6145,7 @@ version = "0.0.5" dependencies = [ "bevy", "raw-window-handle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -6276,8 +6317,8 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyo3" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "inventory", "libc", @@ -6290,16 +6331,16 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "libc", "pyo3-build-config", @@ -6307,8 +6348,8 @@ dependencies = [ [[package]] name = "pyo3-introspection" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "anyhow", "goblin", @@ -6318,8 +6359,8 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -6329,8 +6370,8 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.29.0" -source = "git+https://github.com/PyO3/pyo3?branch=main#65bd9ffd3ab2707d94a58af3c5bf9f705b074abb" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "heck", "proc-macro2", @@ -6481,7 +6522,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha", "simd_helpers", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -6569,6 +6610,17 @@ dependencies = [ "font-types 0.11.3", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.3", + "once_cell", +] + [[package]] name = "realfft" version = "3.5.0" @@ -6610,9 +6662,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" dependencies = [ "bitflags 2.13.1", ] @@ -6631,9 +6683,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6669,9 +6721,9 @@ dependencies = [ [[package]] name = "ringbuf" -version = "0.4.8" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7" dependencies = [ "crossbeam-utils", "portable-atomic", @@ -6688,7 +6740,7 @@ dependencies = [ "dasp_sample", "lewton", "num-rational", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -6723,7 +6775,7 @@ checksum = "7384a8837efe4abea0e53f3887b5072cbbd1ee95c298cff4fbd7a9f3ff23a48b" dependencies = [ "bitflags 2.13.1", "rtaudio-sys", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -6751,9 +6803,9 @@ dependencies = [ [[package]] name = "rubato" -version = "2.0.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce96ead1a91f7895704a9f08ea5947dfc8bd7c1f2936a22295b655ec67e5c6ef" +checksum = "f57c655d11e929f05a8663b323ff553f8d9773be05dfdc087795955bedeb8d92" dependencies = [ "audioadapter", "audioadapter-buffers", @@ -7041,6 +7093,16 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -7186,16 +7248,16 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "skrifa 0.42.1", + "skrifa 0.44.0", "yazi", "zeno", ] [[package]] name = "symphonia" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ "lazy_static", "symphonia-codec-pcm", @@ -7208,9 +7270,9 @@ dependencies = [ [[package]] name = "symphonia-codec-pcm" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" dependencies = [ "log", "symphonia-core", @@ -7218,9 +7280,9 @@ dependencies = [ [[package]] name = "symphonia-codec-vorbis" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" dependencies = [ "log", "symphonia-common", @@ -7229,9 +7291,9 @@ dependencies = [ [[package]] name = "symphonia-common" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" dependencies = [ "log", "symphonia-core", @@ -7240,9 +7302,9 @@ dependencies = [ [[package]] name = "symphonia-core" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" dependencies = [ "bitflags 2.13.1", "bytemuck", @@ -7255,9 +7317,9 @@ dependencies = [ [[package]] name = "symphonia-format-ogg" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" dependencies = [ "log", "symphonia-common", @@ -7267,9 +7329,9 @@ dependencies = [ [[package]] name = "symphonia-format-riff" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" dependencies = [ "extended", "log", @@ -7279,9 +7341,9 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" dependencies = [ "lazy_static", "log", @@ -7292,9 +7354,9 @@ dependencies = [ [[package]] name = "symphonium" -version = "0.11.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefe2d9bc8dae6238683ddf49b1de6da6dd2349d3d279ec266d3c4abedaa4702" +checksum = "6cc514081a8a6eb44a4ee5e68d981bf11d976876d0612082121969b31e7d53a8" dependencies = [ "fixed-resample", "symphonia", @@ -7417,11 +7479,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -7437,9 +7499,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -7502,9 +7564,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -7877,9 +7939,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -7890,9 +7952,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -7900,9 +7962,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7910,9 +7972,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -7923,9 +7985,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -8047,9 +8109,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -8096,7 +8158,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "thiserror 2.0.19", + "thiserror 2.0.20", "wesl-macros", "wgsl-parse", "wgsl-types", @@ -8168,7 +8230,7 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-wasm", @@ -8257,7 +8319,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wayland-sys", "web-sys", @@ -8306,7 +8368,7 @@ dependencies = [ "lalrpop-util", "lexical", "logos", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgsl-types", ] @@ -8862,9 +8924,9 @@ checksum = "2b1b1e28ac6301b1c097d21c11130090f7ecc5db9d5ef4e52595ab942f8d76d5" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11" @@ -8910,9 +8972,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xkbcommon-dl" @@ -8935,9 +8997,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "y4m" @@ -8993,18 +9055,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -9034,9 +9096,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -9046,9 +9108,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "serde", "yoke", @@ -9058,13 +9120,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -9075,9 +9137,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" diff --git a/Cargo.toml b/Cargo.toml index 2a2354db..2bae50b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -212,6 +212,10 @@ path = "examples/camera_controllers.rs" name = "compute_readback" path = "examples/compute_readback.rs" +[[example]] +name = "alias_spike" +path = "examples/alias_spike.rs" + [[example]] name = "particles_basic" path = "examples/particles_basic.rs" diff --git a/assets/shaders/density_color.wesl b/assets/shaders/density_color.wesl new file mode 100644 index 00000000..df2f1f2b --- /dev/null +++ b/assets/shaders/density_color.wesl @@ -0,0 +1,23 @@ +struct Params { + scale: f32, +} + +@group(0) @binding(0) var density: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&density) { return; } + + let t = clamp(density[i] * params.scale, 0.0, 1.0); + let cool = vec3(0.10, 0.22, 0.70); + let warm = vec3(1.00, 0.55, 0.15); + let rgb = mix(cool, warm, t) * (0.35 + 1.1 * t); + + color[i * 4u + 0u] = rgb.x; + color[i * 4u + 1u] = rgb.y; + color[i * 4u + 2u] = rgb.z; + color[i * 4u + 3u] = 1.0; +} diff --git a/assets/shaders/gen_colored_surface.wesl b/assets/shaders/gen_colored_surface.wesl new file mode 100644 index 00000000..aba05674 --- /dev/null +++ b/assets/shaders/gen_colored_surface.wesl @@ -0,0 +1,65 @@ +struct Params { + nx: u32, + ny: u32, + time: f32, + extent: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var normal: array; +@group(0) @binding(3) var indices: array; +@group(0) @binding(4) var params: Params; + +fn height(x: f32, z: f32, t: f32) -> f32 { + let r = sqrt(x * x + z * z); + return sin(r * 0.8 - t * 2.0) * 1.6 * exp(-r * 0.10) + + sin(x * 0.5 + t) * 0.4 + + cos(z * 0.6 - t * 1.3) * 0.3; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let nx = params.nx; + let ny = params.ny; + if i >= nx * ny { return; } + + let gx = i % nx; + let gy = i / nx; + let x = (f32(gx) / f32(nx - 1u) - 0.5) * params.extent; + let z = (f32(gy) / f32(ny - 1u) - 0.5) * params.extent; + let t = params.time; + + let h = height(x, z, t); + position[i * 3u + 0u] = x; + position[i * 3u + 1u] = h; + position[i * 3u + 2u] = z; + + let e = 0.12; + let hl = height(x - e, z, t); + let hr = height(x + e, z, t); + let hd = height(x, z - e, t); + let hu = height(x, z + e, t); + let n = normalize(vec3(hl - hr, 2.0 * e, hd - hu)); + normal[i * 3u + 0u] = n.x; + normal[i * 3u + 1u] = n.y; + normal[i * 3u + 2u] = n.z; + + let c = clamp(h * 0.35 + 0.5, 0.0, 1.0); + color[i * 4u + 0u] = 0.15 + 0.75 * c; + color[i * 4u + 1u] = 0.30 + 0.45 * (1.0 - abs(c - 0.5) * 2.0); + color[i * 4u + 2u] = 0.95 - 0.65 * c; + color[i * 4u + 3u] = 1.0; + + if gx + 1u < nx && gy + 1u < ny { + let cell = gy * (nx - 1u) + gx; + let base = cell * 6u; + indices[base + 0u] = i; + indices[base + 1u] = i + 1u; + indices[base + 2u] = i + nx + 1u; + indices[base + 3u] = i; + indices[base + 4u] = i + nx + 1u; + indices[base + 5u] = i + nx; + } +} diff --git a/assets/shaders/gen_surface.wesl b/assets/shaders/gen_surface.wesl new file mode 100644 index 00000000..7932c72f --- /dev/null +++ b/assets/shaders/gen_surface.wesl @@ -0,0 +1,60 @@ +struct Params { + nx: u32, + ny: u32, + time: f32, + extent: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var indices: array; +@group(0) @binding(2) var params: Params; + +fn wave(p: vec2, dir: vec2, freq: f32, speed: f32, t: f32) -> f32 { + return sin(dot(p, normalize(dir)) * freq + t * speed); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let nx = params.nx; + let ny = params.ny; + if i >= nx * ny { return; } + + let gx = i % nx; + let gy = i / nx; + + let u = f32(gx) / f32(nx - 1u); + let v = f32(gy) / f32(ny - 1u); + let x = (u - 0.5) * params.extent; + let z = (v - 0.5) * params.extent; + + let t = params.time; + let p = vec2(x, z); + + let w1 = wave(p, vec2(1.0, 0.3), 0.6, 0.7, t) * 1.0; + let w2 = wave(p, vec2(0.4, 1.0), 0.9, 0.9, t) * 0.6; + let w3 = wave(p, vec2(-0.7, 0.6), 1.7, 1.4, t) * 0.3; + let w4 = wave(p, vec2(0.9, -0.5), 2.3, 1.8, t) * 0.18; + let w5 = wave(p, vec2(-0.2, -1.0), 4.1, 2.6, t) * 0.08; + var h = w1 + w2 + w3 + w4 + w5; + h = h + 0.25 * h * abs(h); + + position[i * 3u + 0u] = x; + position[i * 3u + 1u] = h; + position[i * 3u + 2u] = z; + + if gx + 1u < nx && gy + 1u < ny { + let cell = gy * (nx - 1u) + gx; + let base = cell * 6u; + let v00 = i; + let v10 = i + 1u; + let v01 = i + nx; + let v11 = i + nx + 1u; + indices[base + 0u] = v00; + indices[base + 1u] = v10; + indices[base + 2u] = v11; + indices[base + 3u] = v00; + indices[base + 4u] = v11; + indices[base + 5u] = v01; + } +} \ No newline at end of file diff --git a/assets/shaders/plexus_curve.wesl b/assets/shaders/plexus_curve.wesl new file mode 100644 index 00000000..3885facc --- /dev/null +++ b/assets/shaders/plexus_curve.wesl @@ -0,0 +1,42 @@ +import { lygia::color::space::hsv2rgb::hsv2rgb }; + +struct Params { + count: u32, + time: f32, + fx: f32, // frequency ratios + fy: f32, + fz: f32, + loops: f32, // parameter spans loops * TAU + scale: f32, + hue_mix: f32, // 0 = grayscale (black), 1 = rainbow +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var params: Params; + +const TAU: f32 = 6.28318530718; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= params.count { return; } + + let u = f32(i) / f32(params.count); + let t = u * TAU * params.loops; + let world = vec3( + sin(params.fx * t + params.time * 0.30), + sin(params.fy * t + params.time * 0.47), + sin(params.fz * t + params.time * 0.23), + ) * params.scale; + position[i * 3u + 0u] = world.x; + position[i * 3u + 1u] = world.y; + position[i * 3u + 2u] = world.z; + + let rainbow = hsv2rgb(vec3(fract(u + params.time * 0.03), 0.75, 1.0)); + let rgb = mix(vec3(0.0), rainbow, params.hue_mix); + color[i * 4u + 0u] = rgb.x; + color[i * 4u + 1u] = rgb.y; + color[i * 4u + 2u] = rgb.z; + color[i * 4u + 3u] = 1.0; +} diff --git a/assets/shaders/plexus_link.wesl b/assets/shaders/plexus_link.wesl new file mode 100644 index 00000000..9b375f0b --- /dev/null +++ b/assets/shaders/plexus_link.wesl @@ -0,0 +1,114 @@ +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +struct Params { + connection_radius: f32, + connection_ramp: f32, // falloff exponent + line_alpha: f32, // opacity scale + max_links: u32, // per-particle edge cap +} + +// Auto-bound by apply(): names must be position/color. +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var edge_pos: array; +@group(0) @binding(3) var edge_col: array; +@group(0) @binding(4) var indices: array; +@group(0) @binding(5) var draw_args: array>; +// Bound by grid.bind(). +@group(0) @binding(6) var offsets: array; +@group(0) @binding(7) var sorted: array; +@group(0) @binding(8) var params: Params; +@group(0) @binding(9) var gp: GridParams; + +fn cell_coords(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> vec3 { + let rel = (p - grid_min) / cell_size; + return vec3( + clamp(i32(floor(rel.x)), 0, i32(dims.x) - 1), + clamp(i32(floor(rel.y)), 0, i32(dims.y) - 1), + clamp(i32(floor(rel.z)), 0, i32(dims.z) - 1), + ); +} + +fn cell_index(c: vec3, dims: vec3) -> u32 { + return c.x + c.y * dims.x + c.z * dims.x * dims.y; +} + +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +fn load_rgb(i: u32) -> vec3 { + return vec3(color[i * 4u], color[i * 4u + 1u], color[i * 4u + 2u]); +} + +fn emit_vertex(slot: u32, p: vec3, rgb: vec3, a: f32) { + edge_pos[slot * 3u + 0u] = p.x; + edge_pos[slot * 3u + 1u] = p.y; + edge_pos[slot * 3u + 2u] = p.z; + edge_col[slot * 4u + 0u] = rgb.x; + edge_col[slot * 4u + 1u] = rgb.y; + edge_col[slot * 4u + 2u] = rgb.z; + edge_col[slot * 4u + 3u] = a; + indices[slot] = slot; +} + +@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 cap = arrayLength(&indices); + let pos = load_pos(i); + let rgb = load_rgb(i); + let radius = params.connection_radius; + let r2 = radius * radius; + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + + let reach = max(1, i32(ceil(radius / gp.cell_size))); + let z0 = max(base.z - reach, 0); + let z1 = min(base.z + reach, i32(gp.dims_z) - 1); + let y0 = max(base.y - reach, 0); + let y1 = min(base.y + reach, i32(gp.dims_y) - 1); + let x0 = max(base.x - reach, 0); + let x1 = min(base.x + reach, i32(gp.dims_x) - 1); + + var emitted = 0u; + for (var cz = z0; cz <= z1; cz++) { + for (var cy = y0; cy <= y1; cy++) { + for (var cx = x0; cx <= x1; cx++) { + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + if j <= i { continue; } // one direction per edge + let pj = load_pos(j); + let diff = pos - pj; + let d2 = dot(diff, diff); + if d2 > r2 { continue; } + + if emitted >= params.max_links { return; } + emitted += 1u; + + let d = sqrt(d2); + let a = pow(1.0 / (d / radius + 1.0), params.connection_ramp) * params.line_alpha; + + let slot = atomicAdd(&draw_args[0], 2u); + if slot + 1u < cap { + emit_vertex(slot, pos, rgb, a); + emit_vertex(slot + 1u, pj, load_rgb(j), a); + } + } + } + } + } +} diff --git a/crates/processing_core/src/constants.rs b/crates/processing_core/src/constants.rs index 9e7f4ad8..f8081bce 100644 --- a/crates/processing_core/src/constants.rs +++ b/crates/processing_core/src/constants.rs @@ -55,6 +55,68 @@ pub const LAB: &str = "lab"; pub const LCH: &str = "lch"; pub const XYZ: &str = "xyz"; +pub const MAP: &str = "map"; +pub const COMBINE: &str = "combine"; +pub const MIX: &str = "mix"; +pub const LOOKUP: &str = "lookup"; +pub const REDUCE: &str = "reduce"; +pub const EXTRACT: &str = "extract"; +pub const PACK: &str = "pack"; +pub const GENERATE: &str = "generate"; +pub const NEIGHBOR: &str = "neighbor"; + +pub const COUNT: &str = "count"; +pub const DENSITY: &str = "density"; + +pub const CONSTANT: &str = "constant"; +pub const SMOOTHSTEP: &str = "smoothstep"; +pub const QUADRATIC: &str = "quadratic"; +pub const CUBIC: &str = "cubic"; +pub const INVERSE: &str = "inverse"; + +pub const AFFINE: &str = "affine"; +pub const ABS: &str = "abs"; +pub const NEGATE: &str = "negate"; +pub const FLOOR: &str = "floor"; +pub const SQRT: &str = "sqrt"; +pub const GREATER: &str = "greater"; +pub const LESS: &str = "less"; +pub const GEQ: &str = "geq"; +pub const LEQ: &str = "leq"; +pub const EQ: &str = "eq"; +pub const NEQ: &str = "neq"; + +pub const ADD: &str = "add"; +pub const SUB: &str = "sub"; +pub const MUL: &str = "mul"; +pub const DIV: &str = "div"; +pub const POW: &str = "pow"; + +pub const LENGTH: &str = "length"; +pub const SUM: &str = "sum"; +pub const SUMSQ: &str = "sumsq"; +pub const MEAN: &str = "mean"; +pub const MIN: &str = "min"; +pub const MAX: &str = "max"; + +pub const UNIFORM: &str = "uniform"; +pub const SIGNED: &str = "signed"; +pub const GAUSSIAN: &str = "gaussian"; + +pub const NOISE: &str = "noise"; +pub const TRANSFORM: &str = "transform"; +pub const ATTRACT: &str = "attract"; +pub const DRAG: &str = "drag"; +pub const VORTEX: &str = "vortex"; +pub const FORCE: &str = "force"; +pub const INTEGRATE: &str = "integrate"; +pub const AGE: &str = "age"; +pub const IMPULSE: &str = "impulse"; +pub const ORIENT: &str = "orient"; +pub const FIELD: &str = "field"; +pub const BOUNDS_SPHERE: &str = "bounds_sphere"; +pub const BOUNDS_BOX: &str = "bounds_box"; + pub const PI: f32 = std::f32::consts::PI; pub const TWO_PI: f32 = std::f32::consts::TAU; pub const HALF_PI: f32 = std::f32::consts::FRAC_PI_2; diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 4e6e6771..731722cc 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -43,7 +43,7 @@ pub extern "C" fn processing_surface_create( scale_factor: f32, ) -> u64 { error::clear_error(); - error::check(|| surface_create_macos(window_handle, width, height, scale_factor)) + error::check(|| surface_create_macos(window_handle, width, height, scale_factor, false)) .map(|e| e.to_bits()) .unwrap_or(0) } @@ -2982,49 +2982,65 @@ pub extern "C" fn processing_filter_set_passes(filter_id: u64, passes: u32) { #[unsafe(no_mangle)] pub extern "C" fn processing_filter_blur() -> u64 { error::clear_error(); - error::check(|| filter_blur()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_blur()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_invert() -> u64 { error::clear_error(); - error::check(|| filter_invert()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_invert()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_gray() -> u64 { error::clear_error(); - error::check(|| filter_gray()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_gray()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_threshold() -> u64 { error::clear_error(); - error::check(|| filter_threshold()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_threshold()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_posterize() -> u64 { error::clear_error(); - error::check(|| filter_posterize()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_posterize()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_opaque() -> u64 { error::clear_error(); - error::check(|| filter_opaque()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_opaque()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_erode() -> u64 { error::clear_error(); - error::check(|| filter_erode()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_erode()) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] pub extern "C" fn processing_filter_dilate() -> u64 { error::clear_error(); - error::check(|| filter_dilate()).map(|e| e.to_bits()).unwrap_or(0) + error::check(|| filter_dilate()) + .map(|e| e.to_bits()) + .unwrap_or(0) } /// Create a shader from WGSL source. @@ -3233,6 +3249,124 @@ pub unsafe extern "C" fn processing_shader_set_vec4( }); } +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_ivec2( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Int2([x, y])) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_ivec3( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, + z: i32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Int3([x, y, z]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_ivec4( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, + z: i32, + w: i32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Int4([x, y, z, w]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_uvec2( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::UInt2([x, y])) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_uvec3( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, + z: u32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::UInt3([x, y, z]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_uvec4( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, + z: u32, + w: u32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::UInt4([x, y, z, w]), + ) + }); +} + /// # Safety /// - `name` must be non-null. /// - `value` must point to at least 16 f32 elements (column-major). @@ -3684,7 +3818,8 @@ pub extern "C" fn processing_particles_draw(graphics_id: u64, particles_id: u64, graphics_entity, DrawCommand::Particles { particles: Entity::from_bits(particles_id), - geometry: Entity::from_bits(geometry_id), + geometry: Some(Entity::from_bits(geometry_id)), + topology: geometry::Topology::PointList, }, ) }); diff --git a/crates/processing_pyo3/examples/flocking_gpu.py b/crates/processing_pyo3/examples/flocking_gpu.py index 01afff47..d3119830 100644 --- a/crates/processing_pyo3/examples/flocking_gpu.py +++ b/crates/processing_pyo3/examples/flocking_gpu.py @@ -25,8 +25,7 @@ p = None boid = None mat = None -flock_pass = None -integrate_pass = None +grid = None title_last_time = 0.0 title_last_frame = 0 @@ -53,7 +52,7 @@ def boid_geometry(half_width, length, droop): def setup(): - global p, boid, mat, flock_pass, integrate_pass + global p, boid, mat, grid size(900, 700) window_title(f"GPU Flocking — {BOID_COUNT:,} boids") @@ -68,7 +67,6 @@ def setup(): Attribute.rotation(), Attribute.color(), Attribute.velocity(), - Attribute("steer", AttributeFormat.Float3), ], ) @@ -92,8 +90,12 @@ def setup(): boid = boid_geometry(0.4, 1.3, 0.15) mat = create_material(albedo=color_buf) - flock_pass = create_compute(load_shader("shaders/flocking_gpu_flock.wesl")) - integrate_pass = create_compute(load_shader("shaders/flocking_gpu_integrate.wesl")) + cells = int((2.0 * BOUND) / NEIGHBOR_DIST) + 1 + grid = p.create_grid( + min=[-BOUND, -BOUND, -BOUND], + cell_size=NEIGHBOR_DIST, + dims=[cells, cells, cells], + ) def draw(): @@ -115,16 +117,20 @@ def draw(): material(mat) particles(p, boid) - flock_pass.set( - neighbor_dist=NEIGHBOR_DIST, - separation_dist=SEPARATION_DIST, + p.flock( + grid, + sep_distance=SEPARATION_DIST, + neighbor_distance=NEIGHBOR_DIST, + weight_separation=1.5, + weight_alignment=1.0, + weight_cohesion=1.0, max_speed=MAX_SPEED, - max_force=MAX_FORCE, + max_force=MAX_FORCE * DT, + min_speed=MAX_SPEED * 0.25, ) - p.apply(flock_pass) - - integrate_pass.set(dt=DT, max_speed=MAX_SPEED, bound=BOUND) - p.apply(integrate_pass) + p.apply(INTEGRATE, dt=DT) + p.apply(BOUNDS_BOX, aabb_min=[-BOUND] * 3, aabb_max=[BOUND] * 3, mode=2) + p.apply(ORIENT, forward=[0.0, 0.0, 1.0], up=[0.0, 1.0, 0.0]) run() diff --git a/crates/processing_pyo3/examples/particles_density.py b/crates/processing_pyo3/examples/particles_density.py new file mode 100644 index 00000000..1bbe6463 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_density.py @@ -0,0 +1,54 @@ +from mewnala import * +from random import uniform +from math import cos, sin + +N = 50000 +BOX = 20.0 +RADIUS = 1.5 + +p = None +g = None +tint = None + + +def setup(): + global p, g, tint + size(900, 700) + window_title(f"Neighbour density — {N:,}") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute("density", AttributeFormat.Float), + ], + ) + p.buffer("position").write([[uniform(-BOX, BOX) for _ in range(3)] for _ in range(N)]) + + cells = int(2.0 * BOX / RADIUS) + 2 + g = p.create_grid(min=[-BOX, -BOX, -BOX], cell_size=RADIUS, dims=[cells, cells, cells]) + + tint = create_compute(load_shader("shaders/density_color.wesl")) + + +def draw(): + background(3, 4, 9) + + t = elapsed_time + d = BOX * 2.4 + camera_position(cos(t * 0.08) * d, BOX * 0.5, sin(t * 0.08) * d) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(NOISE, scale=0.12, strength=0.06, time=t * 0.15, divergence_free=1) + p.apply(BOUNDS_BOX, aabb_min=[-BOX] * 3, aabb_max=[BOX] * 3, mode=2) + + p.apply(NEIGHBOR, grid=g, out="density", op=DENSITY, radius=RADIUS) + tint.set(scale=1.0 / 30.0) + p.apply(tint) + + particles(p) + + +run() diff --git a/crates/processing_pyo3/examples/particles_gpu_surface.py b/crates/processing_pyo3/examples/particles_gpu_surface.py new file mode 100644 index 00000000..e0e4af69 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_gpu_surface.py @@ -0,0 +1,37 @@ +from mewnala import * +from math import cos, sin + +NX, NY = 160, 160 +EXTENT = 24.0 + +p = None +gen = None + + +def setup(): + global p, gen + size(900, 700) + window_title("gpu index") + mode_3d() + + p = create_particles(capacity=NX * NY, attributes=[Attribute.position()]) + idx = p.index_buffer((NX - 1) * (NY - 1) * 6) + gen = create_compute(load_shader("shaders/gen_surface.wesl")) + gen.set(indices=idx) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + d = EXTENT * 1.4 + camera_position(cos(t * 0.1) * d, EXTENT * 0.7, sin(t * 0.1) * d) + camera_look_at(0.0, 0.0, 0.0) + + gen.set(nx=NX, ny=NY, time=t, extent=EXTENT) + p.apply(gen) + + particles(p, topology=TRIANGLES) + + +run() diff --git a/crates/processing_pyo3/examples/particles_gpu_surface_lit.py b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py new file mode 100644 index 00000000..d737d1bd --- /dev/null +++ b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py @@ -0,0 +1,40 @@ +from mewnala import * +from math import cos, sin + +NX, NY = 200, 200 +EXTENT = 30.0 + +p = None +gen = None + + +def setup(): + global p, gen + size(900, 700) + window_title("GPU surface — color + normals generated on the GPU") + mode_3d() + + p = create_particles( + capacity=NX * NY, + attributes=[Attribute.position(), Attribute.color(), Attribute.normal()], + ) + idx = p.index_buffer((NX - 1) * (NY - 1) * 6) + gen = create_compute(load_shader("shaders/gen_colored_surface.wesl")) + gen.set(indices=idx) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + d = EXTENT * 1.3 + camera_position(cos(t * 0.08) * d, EXTENT * 0.6, sin(t * 0.08) * d) + camera_look_at(0.0, 0.0, 0.0) + + gen.set(nx=NX, ny=NY, time=t, extent=EXTENT) + p.apply(gen) + + particles(p, topology=TRIANGLES) + + +run() diff --git a/crates/processing_pyo3/examples/particles_lines.py b/crates/processing_pyo3/examples/particles_lines.py new file mode 100644 index 00000000..3137ae11 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lines.py @@ -0,0 +1,47 @@ +from mewnala import * +from math import cos, sin, tau + +N = 3000 +P, Q = 3, 2 +SCALE = 8.0 + +p = None + + +def knot(t, phase): + r = cos(Q * t) + 2.0 + x = r * cos(P * t + phase) + y = r * sin(P * t + phase) + z = -sin(Q * t) + return (x * SCALE, y * SCALE, z * SCALE) + + +def setup(): + global p + size(900, 700) + window_title(f"Particle lines — {N:,}-point torus knot") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[Attribute.position()], + ) + p.buffer("position").write([list(knot(i / N * tau, 0.0)) for i in range(N)]) + + +def draw(): + background(6, 8, 14) + + phase = elapsed_time * 0.4 + positions = [list(knot(i / N * tau, phase)) for i in range(N)] + p.buffer("position").write(positions) + + t = elapsed_time * 0.12 + r = SCALE * 4.5 + camera_position(cos(t) * r, SCALE * 1.5, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p, topology="line_strip") + + +run() diff --git a/crates/processing_pyo3/examples/particles_lissajous.py b/crates/processing_pyo3/examples/particles_lissajous.py new file mode 100644 index 00000000..6bfa291c --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lissajous.py @@ -0,0 +1,85 @@ +from mewnala import * +from math import cos, sin + +ALPHA_OVER = BlendMode( + color_src=BlendMode.SRC_ALPHA, + color_dst=BlendMode.ONE_MINUS_SRC_ALPHA, + color_op=BlendMode.OP_ADD, + alpha_src=BlendMode.ONE, + alpha_dst=BlendMode.ONE_MINUS_SRC_ALPHA, + alpha_op=BlendMode.OP_ADD, +) + +N = 10000 # points on the curve +SCALE = 10.0 +FX, FY, FZ = 3.0, 4.0, 5.0 + +CONNECTION_RADIUS = 3.5 # link points closer than this +CONNECTION_RAMP = 7.0 # alpha = (1/(d/R + 1))^ramp +LINE_ALPHA = 0.2 # overall opacity scale +MAX_LINKS = 4000 # per-point edge cap (bounds the edge buffer) +HUE_MIX = 0.0 # 0 = grayscale, 1 = rainbow + +p = None # curve points (source) +edges = None # emitted line vertices (drawn) +curve = None +link = None +grid = None +idx = None + + +def setup(): + global p, edges, curve, link, grid, idx + size(1000, 800) + window_title(f"Lissajous — all points connected — {N:,} pts") + mode_3d() + bloom(0.0) + + p = create_particles(capacity=N, attributes=[Attribute.position(), Attribute.color()]) + # One line = 2 vertices; up to N*MAX_LINKS lines. + edges = create_particles( + capacity=N * MAX_LINKS * 2, attributes=[Attribute.position(), Attribute.color()] + ) + idx = edges.index_buffer(N * MAX_LINKS * 2) # link fills indices + the dynamic count + + cells = int((2.0 * SCALE + 2.0) / CONNECTION_RADIUS) + 1 + grid = p.create_grid( + min=[-SCALE - 1.0] * 3, cell_size=CONNECTION_RADIUS, dims=[cells, cells, cells] + ) + + curve = create_compute(load_shader("shaders/plexus_curve.wesl")) + link = create_compute(load_shader("shaders/plexus_link.wesl")) + + +def draw(): + bloom(0.0) # re-assert each frame + background(255, 255, 255) + + t = elapsed_time + r = 26.0 + camera_position(cos(t * 0.08) * r, sin(t * 0.05) * r * 0.55, sin(t * 0.08) * r) + camera_look_at(0.0, 0.0, 0.0) + + curve.set(count=N, time=t, fx=FX, fy=FY, fz=FZ, loops=1.0, scale=SCALE, hue_mix=HUE_MIX) + p.apply(curve) + + grid.build(p.buffer("position")) + edges.reset_indices() + link.set( + edge_pos=edges.buffer("position"), + edge_col=edges.buffer("color"), + indices=idx, + draw_args=edges.draw_args(), + connection_radius=CONNECTION_RADIUS, + connection_ramp=CONNECTION_RAMP, + line_alpha=LINE_ALPHA, + max_links=MAX_LINKS, + ) + grid.bind(link) + p.apply(link) + + blend_mode(ALPHA_OVER) + particles(edges, topology="lines") + + +run() diff --git a/crates/processing_pyo3/examples/particles_plexus.py b/crates/processing_pyo3/examples/particles_plexus.py new file mode 100644 index 00000000..00ad6fd9 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_plexus.py @@ -0,0 +1,74 @@ +from mewnala import * +from random import uniform, seed +from math import cos, sin + +N = 2500 # particles +BOX = 12.0 # half-extent of the box +LINK_DIST = 1.8 +MAX_LINKS = 8 # per-particle edge cap +SPEED = 3.0 # units/sec +DT = 1.0 / 60.0 + +BOUNCE = 1 # bounds_box mode: 0=clamp, 1=reflect(bounce), 2=wrap + +p = None +link = None +grid = None +idx = None +args = None + + +def setup(): + global p, link, grid, idx, args + size(1000, 760) + window_title(f"Plexus — {N:,} particles, dynamic GPU links") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[Attribute.position(), Attribute.velocity(), Attribute.color()], + ) + + seed(7) + positions, velocities, colors = [], [], [] + for _ in range(N): + positions.append([uniform(-BOX, BOX) for _ in range(3)]) + velocities.append([uniform(-1.0, 1.0) * SPEED for _ in range(3)]) + c = hsva(uniform(170.0, 320.0), 0.65, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + p.buffer("position").write(positions) + p.buffer("velocity").write(velocities) + p.buffer("color").write(colors) + + # sized for the worst case: every particle at its cap (2 indices per edge) + idx = p.index_buffer(N * MAX_LINKS * 2) + args = p.draw_args() + + # cell_size = LINK_DIST so the 27-cell neighbour walk covers the search radius + cells = int((2.0 * BOX) / LINK_DIST) + 1 + grid = p.create_grid(min=[-BOX, -BOX, -BOX], cell_size=LINK_DIST, dims=[cells, cells, cells]) + + link = create_compute(load_shader("shaders/plexus_link.wesl")) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time * 0.12 + r = BOX * 3.0 + camera_position(cos(t) * r, BOX * 0.9, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(INTEGRATE, dt=DT) + p.apply(BOUNDS_BOX, aabb_min=[-BOX] * 3, aabb_max=[BOX] * 3, mode=BOUNCE, max_speed=SPEED) + + grid.build(p.buffer("position")) + p.reset_indices() + link.set(indices=idx, draw_args=args, link_distance=LINK_DIST, max_links=MAX_LINKS) + grid.bind(link) + p.apply(link) + + particles(p, topology="lines") + + +run() diff --git a/crates/processing_pyo3/examples/particles_points.py b/crates/processing_pyo3/examples/particles_points.py new file mode 100644 index 00000000..2c745d27 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_points.py @@ -0,0 +1,37 @@ +from mewnala import * +from math import cos, sin +from random import uniform + +COUNT = 20000 +BOUND = 20.0 + +p = None + + +def setup(): + global p + size(900, 700) + window_title(f"Particle points — {COUNT:,}") + mode_3d() + + p = create_particles( + capacity=COUNT, + attributes=[Attribute.position()], + ) + + positions = [[uniform(-BOUND, BOUND) for _ in range(3)] for _ in range(COUNT)] + p.buffer("position").write(positions) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time * 0.15 + r = BOUND * 2.6 + camera_position(cos(t) * r, BOUND * 0.6, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p) + + +run() diff --git a/crates/processing_pyo3/examples/particles_scatter_volume.py b/crates/processing_pyo3/examples/particles_scatter_volume.py index 878409cd..af3d251d 100644 --- a/crates/processing_pyo3/examples/particles_scatter_volume.py +++ b/crates/processing_pyo3/examples/particles_scatter_volume.py @@ -7,11 +7,10 @@ particle = None mat = None scatter = None -decay = None def setup(): - global p, particle, mat, scatter, decay + global p, particle, mat, scatter size(900, 700) mode_3d() @@ -37,9 +36,6 @@ def setup(): ) mat = create_material(unlit=True, albedo=[1.0, 1.0, 1.0, 1.0]) - decay = Particles.attr_linear() - decay.set(op=p.buffer("scale"), scale=0.985, offset=0.0) - def draw(): background(8, 8, 13) @@ -49,7 +45,7 @@ def draw(): seed = (int(elapsed_time * 1000.0) ^ 0xC0FFEE) & 0xFFFFFFFF scatter.set(seed=seed) p.emit_gpu(BURST, scatter) - p.apply(decay) + p.apply(MAP, a="scale", op=AFFINE, scale=0.985, offset=0.0) run() diff --git a/crates/processing_pyo3/examples/particles_sphere.py b/crates/processing_pyo3/examples/particles_sphere.py new file mode 100644 index 00000000..e8bd6927 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_sphere.py @@ -0,0 +1,39 @@ +from mewnala import * +from math import cos, sin + +from mewnala.mewnala import TRIANGLES + +p = None +rest = None + + +def setup(): + global p + size(900, 700) + window_title("Sphere") + mode_3d() + + rest = Attribute("rest", AttributeFormat.Float3) + sphere = Geometry.sphere(1.5, 96, 64) + p = create_particles( + geometry=sphere, + attributes=[ + Attribute.position(), + rest, + ], + ) + p.apply(MAP, a=Attribute.position(), out=rest, op=AFFINE, scale=1.0, offset=0.0) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + r = 5.0 + camera_position(cos(t * 0.15) * r, 1.6, sin(t * 0.15) * r) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(NOISE, scale=0.9, strength=0.01, time=t * 0.3, divergence_free=1) + particles(p, topology=TRIANGLES) + +run() diff --git a/crates/processing_pyo3/examples/particles_surface.py b/crates/processing_pyo3/examples/particles_surface.py new file mode 100644 index 00000000..07afe584 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_surface.py @@ -0,0 +1,55 @@ +from mewnala import * +from math import cos, sin, tau + +SEGMENTS = 400 +N = SEGMENTS * 2 +R = 10.0 +WIDTH = 3.5 + +p = None + + +def band(seg, phase): + theta = seg / SEGMENTS * tau + radial = (cos(theta), 0.0, sin(theta)) + center = (R * radial[0], 0.0, R * radial[2]) + twist = theta * 0.5 + phase + w = ( + WIDTH * cos(twist) * radial[0], + WIDTH * sin(twist), + WIDTH * cos(twist) * radial[2], + ) + left = [center[0] - w[0], center[1] - w[1], center[2] - w[2]] + right = [center[0] + w[0], center[1] + w[1], center[2] + w[2]] + return left, right + + +def setup(): + global p + size(900, 700) + window_title(f"Particle surface — {N:,}-vertex Mobius strip") + mode_3d() + + p = create_particles(capacity=N, attributes=[Attribute.position()]) + + +def draw(): + background(6, 8, 14) + + phase = elapsed_time * 0.25 + positions = [] + for seg in range(SEGMENTS): + left, right = band(seg, phase) + positions.append(left) + positions.append(right) + p.buffer("position").write(positions) + + t = elapsed_time * 0.15 + r = R * 3.0 + camera_position(cos(t) * r, R * 1.2, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p, topology="triangle_strip") + + +run() diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs index 283af410..e1f392b3 100644 --- a/crates/processing_pyo3/src/compute.rs +++ b/crates/processing_pyo3/src/compute.rs @@ -29,6 +29,13 @@ impl Buffer { borrowed: true, } } + + pub(crate) fn components(&self) -> Option { + self.element_type + .as_ref() + .and_then(|et| et.byte_size()) + .map(|s| (s / 4) as u32) + } } impl Buffer { @@ -169,6 +176,24 @@ impl Buffer { Ok(PyList::new(py, values)?.into_any()) } + + #[pyo3(signature = (op = "sum"))] + pub fn reduce(&self, op: &str) -> PyResult { + use processing::prelude::constants as c; + use processing_render::particles::reduce::{ + REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce as reduce_buffer, + }; + let mode = if op.eq_ignore_ascii_case(c::SUM) { + REDUCE_OP_SUM + } else if op.eq_ignore_ascii_case(c::MIN) { + REDUCE_OP_MIN + } else if op.eq_ignore_ascii_case(c::MAX) { + REDUCE_OP_MAX + } else { + return Err(PyValueError::new_err(format!("reduce: unknown op {op:?}"))); + }; + reduce_buffer(self.entity, mode).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } } impl Buffer { @@ -254,6 +279,9 @@ fn shader_value_to_py<'py>(py: Python<'py>, sv: &ShaderValue) -> PyResult list(py, v), ShaderValue::Int3(v) => list(py, v), ShaderValue::Int4(v) => list(py, v), + ShaderValue::UInt2(v) => list(py, v), + ShaderValue::UInt3(v) => list(py, v), + ShaderValue::UInt4(v) => list(py, v), ShaderValue::Mat4(v) => list(py, v), ShaderValue::Texture(_) | ShaderValue::Buffer(_) @@ -283,20 +311,26 @@ impl Compute { } } +pub(crate) fn set_compute_kwargs( + entity: Entity, + kwargs: &Bound<'_, pyo3::types::PyDict>, +) -> PyResult<()> { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let value = py_to_shader_value(&value)?; + compute_set(entity, &name, value).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) +} + #[pymethods] impl Compute { #[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}")))?; + match kwargs { + Some(kwargs) => set_compute_kwargs(self.entity, kwargs), + None => Ok(()), } - Ok(()) } pub fn dispatch(&self, x: u32, y: u32, z: u32) -> PyResult<()> { diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs index 60c8be28..10b44594 100644 --- a/crates/processing_pyo3/src/constants.rs +++ b/crates/processing_pyo3/src/constants.rs @@ -48,6 +48,34 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m, PI, TWO_PI, HALF_PI, QUARTER_PI, TAU, DEG_TO_RAD, RAD_TO_DEG ); + add!( + m, MAP, COMBINE, MIX, LOOKUP, REDUCE, EXTRACT, PACK, GENERATE + ); + add!(m, AFFINE, ABS, NEGATE, FLOOR, SQRT); + add!(m, GREATER, LESS, GEQ, LEQ, EQ, NEQ); + add!(m, SUB, MUL, DIV, POW); + add!(m, LENGTH, SUM, SUMSQ, MEAN, MIN, MAX); + add!(m, UNIFORM, SIGNED, GAUSSIAN); + add!(m, NEIGHBOR); + add!(m, COUNT, DENSITY); + add!(m, CONSTANT, SMOOTHSTEP, QUADRATIC, CUBIC, INVERSE); + add!( + m, + NOISE, + TRANSFORM, + ATTRACT, + DRAG, + VORTEX, + FORCE, + INTEGRATE, + AGE, + IMPULSE, + ORIENT, + FIELD, + BOUNDS_SPHERE, + BOUNDS_BOX + ); + add!( m, KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 4901c6e5..e801fad8 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -580,7 +580,8 @@ impl Image { /// Read a single pixel as a `Color` (Processing `get`). fn get(&self, x: u32, y: u32) -> PyResult { - let (w, h) = image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (w, h) = + image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; if x >= w || y >= h { return Err(PyValueError::new_err(format!( "pixel ({x}, {y}) out of bounds for {w}x{h} image" @@ -653,7 +654,8 @@ impl Image { /// Save the image to a PNG file (Processing `save`). fn save(&self, filename: &str) -> PyResult<()> { - let (w, h) = image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (w, h) = + image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let pixels = image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let rgba: Vec = pixels @@ -800,9 +802,12 @@ impl Graphics { /// Create an offscreen graphics buffer in the already-running app (no window, /// no `init`). Backs `create_graphics()` / `new_offscreen()`. The caller must /// ensure the app exists (i.e. `size()` was called first). - pub(crate) fn wrap_offscreen(width: u32, height: u32) -> PyResult { - // sRGB by default: it plays well with PNG export and blits. - let texture_format = TextureFormat::Rgba8UnormSrgb; + pub(crate) fn wrap_offscreen(width: u32, height: u32, hdr: bool) -> PyResult { + let texture_format = if hdr { + TextureFormat::Rgba16Float + } else { + TextureFormat::Rgba8UnormSrgb + }; let surface_entity = surface_create_offscreen(width, height, 1.0, texture_format) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Self::from_surface(surface_entity, width, height, texture_format, None) @@ -898,11 +903,13 @@ impl Graphics { } #[staticmethod] + #[pyo3(signature = (width, height, asset_path, log_level, hdr=false))] pub fn new_offscreen( width: u32, height: u32, asset_path: &str, log_level: Option<&str>, + hdr: bool, ) -> PyResult { let mut config = Config::new(); config.set(ConfigKey::AssetRootPath, asset_path.to_string()); @@ -910,7 +917,7 @@ impl Graphics { config.set(ConfigKey::LogLevel, level.to_string()); } init(config).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Self::wrap_offscreen(width, height) + Self::wrap_offscreen(width, height, hdr) } #[getter] @@ -994,8 +1001,8 @@ impl Graphics { self.width, self.height ))); } - let pixels = graphics_readback(self.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let pixels = + graphics_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let p = pixels .get((y * self.width + x) as usize) .ok_or_else(|| PyValueError::new_err("pixel out of bounds"))?; @@ -1016,8 +1023,8 @@ impl Graphics { /// Read all pixels into the `pixels` list (Processing `loadPixels`). pub fn load_pixels(&self, py: Python<'_>) -> PyResult> { - let pixels = graphics_readback(self.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let pixels = + graphics_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let list = pixels_to_pylist(py, &pixels)?; *self.pixel_cache.lock().unwrap() = Some(list.clone_ref(py)); Ok(list) @@ -1654,8 +1661,9 @@ impl Graphics { #[pyo3(signature = (h, v=None))] pub fn text_align(&self, h: &str, v: Option<&str>) -> PyResult<()> { use processing::prelude::{TextAlignH, TextAlignV}; - let h = TextAlignH::parse(h) - .ok_or_else(|| PyValueError::new_err(format!("unknown horizontal text align: {h:?}")))?; + let h = TextAlignH::parse(h).ok_or_else(|| { + PyValueError::new_err(format!("unknown horizontal text align: {h:?}")) + })?; let v = match v { Some(v) => TextAlignV::parse(v).ok_or_else(|| { PyValueError::new_err(format!("unknown vertical text align: {v:?}")) @@ -2171,16 +2179,25 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + #[pyo3(signature = (particles, geometry = None, topology = None))] pub fn particles( &self, particles: &crate::particles::Particles, - geometry: &Geometry, + geometry: Option<&Geometry>, + topology: Option<&str>, ) -> PyResult<()> { + let topology = match topology { + Some(s) => geometry::Topology::parse(s).ok_or_else(|| { + PyValueError::new_err(format!("particles(): unknown topology {s:?}")) + })?, + None => geometry::Topology::PointList, + }; graphics_record_command( self.entity, DrawCommand::Particles { particles: particles.entity, - geometry: geometry.entity, + geometry: geometry.map(|g| g.entity), + topology, }, ) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -2222,6 +2239,16 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + #[pyo3(signature = (intensity, threshold=0.0))] + pub fn bloom(&self, intensity: f32, threshold: f32) -> PyResult<()> { + if intensity <= 0.0 { + graphics_remove_bloom(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } else { + graphics_set_bloom(self.entity, intensity, threshold) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + } + /// Composites a source onto this graphics with a blend mode (Processing /// `blend`). `src` is any sampleable input — an `Image`, webcam, or another /// `Graphics` (which is flushed first). Optional `src_rect`/`dst_rect` are @@ -2276,13 +2303,7 @@ impl Graphics { /// On a context you don't clear each frame, call this at the start of /// `draw()` and then draw new content on top to get feedback trails. #[pyo3(signature = (*, decay=0.95, zoom=1.0, angle=0.0, offset=(0.0, 0.0)))] - pub fn feedback( - &self, - decay: f32, - zoom: f32, - angle: f32, - offset: (f32, f32), - ) -> PyResult<()> { + pub fn feedback(&self, decay: f32, zoom: f32, angle: f32, offset: (f32, f32)) -> PyResult<()> { use shader_value::ShaderValue; let filter = filter_feedback().map_err(rt_err)?; filter_set(filter, "decay", ShaderValue::Float(decay)).map_err(rt_err)?; diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 30772834..f8378bbe 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -257,7 +257,8 @@ fn create_graphics_context( match env.as_str() { "jupyter" => { let asset_path = get_asset_root()?; - let graphics = Graphics::new_offscreen(width, height, asset_path.as_str(), log_level)?; + let graphics = + Graphics::new_offscreen(width, height, asset_path.as_str(), log_level, false)?; module.setattr("_graphics", graphics)?; if !has_existing { @@ -442,6 +443,8 @@ mod mewnala { #[pymodule_export] use super::particles::AttributeFormat; #[pymodule_export] + use super::particles::Grid; + #[pymodule_export] use super::particles::Particles; #[pymodule_export] use super::surface::Surface; @@ -1162,15 +1165,21 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module, signature = (particles, geometry))] + #[pyo3(pass_module, signature = (particles, geometry = None, topology = None))] fn particles( module: &Bound<'_, PyModule>, particles: &Bound<'_, super::particles::Particles>, - geometry: &Bound<'_, Geometry>, + geometry: Option<&Bound<'_, Geometry>>, + topology: Option<&str>, ) -> PyResult<()> { + let geometry = match geometry { + Some(g) => Some(g.extract::>()?), + None => None, + }; graphics!(module).particles( &*particles.extract::>()?, - &*geometry.extract::>()?, + geometry.as_deref(), + topology, ) } @@ -1270,6 +1279,12 @@ mod mewnala { graphics!(module).blend_mode(&*mode.extract::>()?) } + #[pyfunction] + #[pyo3(pass_module, signature = (intensity, threshold=0.0))] + fn bloom(module: &Bound<'_, PyModule>, intensity: f32, threshold: f32) -> PyResult<()> { + graphics!(module).bloom(intensity, threshold) + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn rect(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { @@ -1353,7 +1368,7 @@ mod mewnala { height: u32, ) -> PyResult { get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - Graphics::wrap_offscreen(width, height) + Graphics::wrap_offscreen(width, height, false) } /// Opens an additional window (libprocessing extension; not in Processing). @@ -1380,7 +1395,10 @@ mod mewnala { // (which the per-frame sync applies) defaults otherwise — set it too. ::processing::prelude::surface_set_title(surface_entity, title.to_string()) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - let window = Py::new(module.py(), Graphics::wrap_window(surface_entity, width, height)?)?; + let window = Py::new( + module.py(), + Graphics::wrap_window(surface_entity, width, height)?, + )?; register_window(module, &window)?; Ok(window) } diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index b812df77..5b706f10 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -8,8 +8,270 @@ use pyo3::{ }; use std::collections::HashMap; +use processing_render::particles::algebra::{ + combine as algebra_combine, extract as algebra_extract, generate as algebra_generate, + lookup as algebra_lookup, map as algebra_map, mix as algebra_mix, pack as algebra_pack, + reduce_components as algebra_reduce, +}; +use processing_render::particles::compact::compact as compact_indices; + use crate::compute::{Buffer, Compute}; -use crate::graphics::Geometry; +use crate::graphics::{Geometry, Image}; + +use processing::prelude::constants as c; +use processing_render::{ + COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, +}; +use processing_render::{GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM}; +use processing_render::{ + MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, + MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, +}; +use processing_render::{ + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, +}; + +fn parse_map_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::AFFINE) => Ok(MAP_AFFINE), + _ if s.eq_ignore_ascii_case(c::ABS) => Ok(MAP_ABS), + _ if s.eq_ignore_ascii_case(c::NEGATE) => Ok(MAP_NEGATE), + _ if s.eq_ignore_ascii_case(c::CLAMP) => Ok(MAP_CLAMP), + _ if s.eq_ignore_ascii_case(c::FLOOR) => Ok(MAP_FLOOR), + _ if s.eq_ignore_ascii_case(c::SQUARE) => Ok(MAP_SQUARE), + _ if s.eq_ignore_ascii_case(c::SQRT) => Ok(MAP_SQRT), + _ if s.eq_ignore_ascii_case(c::GREATER) => Ok(MAP_GREATER), + _ if s.eq_ignore_ascii_case(c::LESS) => Ok(MAP_LESS), + _ if s.eq_ignore_ascii_case(c::GEQ) => Ok(MAP_GEQ), + _ if s.eq_ignore_ascii_case(c::LEQ) => Ok(MAP_LEQ), + _ if s.eq_ignore_ascii_case(c::EQ) => Ok(MAP_EQ), + _ if s.eq_ignore_ascii_case(c::NEQ) => Ok(MAP_NEQ), + _ => Err(PyValueError::new_err(format!("map: unknown op {s:?}"))), + } +} + +fn parse_combine_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::ADD) => Ok(COMBINE_ADD), + _ if s.eq_ignore_ascii_case(c::SUB) => Ok(COMBINE_SUB), + _ if s.eq_ignore_ascii_case(c::MUL) => Ok(COMBINE_MUL), + _ if s.eq_ignore_ascii_case(c::DIV) => Ok(COMBINE_DIV), + _ if s.eq_ignore_ascii_case(c::MIN) => Ok(COMBINE_MIN), + _ if s.eq_ignore_ascii_case(c::MAX) => Ok(COMBINE_MAX), + _ if s.eq_ignore_ascii_case(c::POW) => Ok(COMBINE_POW), + _ => Err(PyValueError::new_err(format!("combine: unknown op {s:?}"))), + } +} + +fn parse_reduce_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::LENGTH) => Ok(REDUCE_LENGTH), + _ if s.eq_ignore_ascii_case(c::SUM) => Ok(REDUCE_SUM), + _ if s.eq_ignore_ascii_case(c::MIN) => Ok(REDUCE_MIN), + _ if s.eq_ignore_ascii_case(c::MAX) => Ok(REDUCE_MAX), + _ if s.eq_ignore_ascii_case(c::SUMSQ) => Ok(REDUCE_SUMSQ), + _ if s.eq_ignore_ascii_case(c::MEAN) => Ok(REDUCE_MEAN), + _ => Err(PyValueError::new_err(format!("reduce: unknown op {s:?}"))), + } +} + +fn parse_generate_mode(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::UNIFORM) => Ok(GEN_UNIFORM), + _ if s.eq_ignore_ascii_case(c::SIGNED) => Ok(GEN_SIGNED), + _ if s.eq_ignore_ascii_case(c::GAUSSIAN) => Ok(GEN_GAUSSIAN), + _ => Err(PyValueError::new_err(format!( + "generate: unknown mode {s:?}" + ))), + } +} + +const NEIGHBOR_SUM: u32 = 0; +const NEIGHBOR_MEAN: u32 = 1; +const NEIGHBOR_COUNT: u32 = 2; + +fn parse_neighbor_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::SUM) => Ok(NEIGHBOR_SUM), + _ if s.eq_ignore_ascii_case(c::MEAN) => Ok(NEIGHBOR_MEAN), + _ if s.eq_ignore_ascii_case(c::COUNT) || s.eq_ignore_ascii_case(c::DENSITY) => { + Ok(NEIGHBOR_COUNT) + } + _ => Err(PyValueError::new_err(format!("neighbor: unknown op {s:?}"))), + } +} + +fn parse_falloff(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::CONSTANT) => Ok(FALLOFF_CONST), + _ if s.eq_ignore_ascii_case(c::LINEAR) => Ok(FALLOFF_LINEAR), + _ if s.eq_ignore_ascii_case(c::SMOOTHSTEP) => Ok(FALLOFF_SMOOTHSTEP), + _ if s.eq_ignore_ascii_case(c::QUADRATIC) => Ok(FALLOFF_QUADRATIC), + _ if s.eq_ignore_ascii_case(c::CUBIC) => Ok(FALLOFF_CUBIC), + _ if s.eq_ignore_ascii_case(c::INVERSE) => Ok(FALLOFF_INVERSE), + _ => Err(PyValueError::new_err(format!( + "neighbor: unknown falloff {s:?}" + ))), + } +} + +#[pyclass(unsendable)] +pub struct Grid { + pub(crate) inner: processing_render::particles::grid::Grid, +} + +#[pymethods] +impl Grid { + pub fn build(&self, position: &Buffer) -> PyResult<()> { + grid_build(&self.inner, position.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn bind(&self, compute: &Compute) -> PyResult<()> { + grid_bind(&self.inner, compute.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[getter] + pub fn cell_size(&self) -> f32 { + self.inner.params.cell_size + } +} + +static FLOCK_COMPUTE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn flock_compute() -> PyResult { + let mut guard = FLOCK_COMPUTE.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let e = particles_kernel_flock().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + *guard = Some(e); + Ok(e) +} + +static PHYSICS_COMPUTES: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +fn physics_compute(name: &str) -> PyResult> { + let lower = name.to_ascii_lowercase(); + if let Some(cache) = PHYSICS_COMPUTES.lock().unwrap().as_ref() { + if let Some(&e) = cache.get(&lower) { + return Ok(Some(e)); + } + } + let created = if lower == c::NOISE { + particles_kernel_noise() + } else if lower == c::TRANSFORM { + particles_kernel_transform() + } else if lower == c::ATTRACT { + particles_kernel_attract() + } else if lower == c::DRAG { + particles_kernel_drag() + } else if lower == c::VORTEX { + particles_kernel_vortex() + } else if lower == c::FORCE { + particles_kernel_force() + } else if lower == c::INTEGRATE { + particles_kernel_integrate() + } else if lower == c::AGE { + particles_kernel_age() + } else if lower == c::IMPULSE { + particles_kernel_impulse() + } else if lower == c::ORIENT { + particles_kernel_orient() + } else if lower == c::FIELD { + particles_kernel_field() + } else if lower == c::BOUNDS_SPHERE { + particles_kernel_bounds_sphere() + } else if lower == c::BOUNDS_BOX { + particles_kernel_bounds_box() + } else { + return Ok(None); + }; + let entity = created.map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + PHYSICS_COMPUTES + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert(lower, entity); + Ok(Some(entity)) +} + +fn kw<'a>(kwargs: Option<&Bound<'a, PyDict>>, key: &str) -> Option> { + kwargs.and_then(|d| d.get_item(key).ok().flatten()) +} + +fn kw_f32(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: f32) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn kw_u32(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: u32) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn kw_bool(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: bool) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn map_params(kwargs: Option<&Bound<'_, PyDict>>, op: u32) -> PyResult<(f32, f32)> { + Ok(match op { + MAP_AFFINE => ( + kw_f32(kwargs, "scale", 1.0)?, + kw_f32(kwargs, "offset", 0.0)?, + ), + MAP_CLAMP => (kw_f32(kwargs, "lo", 0.0)?, kw_f32(kwargs, "hi", 1.0)?), + MAP_GREATER | MAP_LESS | MAP_GEQ | MAP_LEQ | MAP_EQ | MAP_NEQ => ( + kw_f32(kwargs, "threshold", 0.0)?, + kw_f32(kwargs, "epsilon", 1.0e-6)?, + ), + _ => (0.0, 0.0), + }) +} + +fn map_param_keys(op: u32) -> &'static [&'static str] { + match op { + MAP_AFFINE => &["scale", "offset"], + MAP_CLAMP => &["lo", "hi"], + MAP_GREATER | MAP_LESS | MAP_GEQ | MAP_LEQ | MAP_EQ | MAP_NEQ => &["threshold", "epsilon"], + _ => &[], + } +} + +fn reject_unknown_kwargs(kwargs: Option<&Bound<'_, PyDict>>, valid: &[&str]) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return Ok(()); + }; + for key in kwargs.keys() { + let name: String = key.extract()?; + if !valid.iter().any(|v| *v == name) { + return Err(PyValueError::new_err(format!( + "apply(): unknown parameter {name:?} (valid: {})", + valid.join(", ") + ))); + } + } + Ok(()) +} + +fn kw_op( + kwargs: Option<&Bound<'_, PyDict>>, + default: u32, + parse: fn(&str) -> PyResult, +) -> PyResult { + match kw(kwargs, "op") { + Some(v) => parse(&v.extract::()?), + None => Ok(default), + } +} #[pyclass(eq, eq_int, from_py_object)] #[derive(Clone, Copy, PartialEq, Eq)] @@ -192,6 +454,43 @@ impl Particles { )) } + fn resolve_operand(&self, val: &Bound<'_, PyAny>) -> PyResult<(Entity, u32)> { + if let Ok(b) = val.extract::>() { + let comp = b.components().ok_or_else(|| { + PyRuntimeError::new_err( + "operand buffer has no element type; pass a particle attribute \ + or a buffer created with typed data", + ) + })?; + return Ok((b.entity, comp)); + } + let attr_entity = self.resolve_attribute(val)?; + let buf = particles_ensure_attribute(self.entity, attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (_, fmt) = geometry_attribute_info(attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let comp = match AttributeFormat::from_inner(fmt) { + AttributeFormat::Float => 1, + AttributeFormat::Float2 => 2, + AttributeFormat::Float3 => 3, + AttributeFormat::Float4 => 4, + }; + Ok((buf, comp)) + } + + fn operand(&self, kwargs: Option<&Bound<'_, PyDict>>, key: &str) -> PyResult<(Entity, u32)> { + let val = kw(kwargs, key) + .ok_or_else(|| PyRuntimeError::new_err(format!("apply(): missing operand '{key}'")))?; + self.resolve_operand(&val) + } + + fn dest(&self, kwargs: Option<&Bound<'_, PyDict>>, in_place: Entity) -> PyResult { + match kw(kwargs, "out") { + Some(v) => Ok(self.resolve_operand(&v)?.0), + None => Ok(in_place), + } + } + /// Build a particle system (backs `create_particles`). Attributes default to /// `position`; the rest (built-in or declared custom) materialize on demand. pub(crate) fn create( @@ -227,6 +526,194 @@ impl Particles { name_to_attr: Particles::build_name_index(&attrs)?, }) } + + fn apply_named(&self, name: &str, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + fn rt(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) + } + + if let Some(entity) = physics_compute(name)? { + if let Some(kwargs) = kwargs { + crate::compute::set_compute_kwargs(entity, kwargs)?; + } + return particles_apply(self.entity, entity).map_err(rt); + } + + if name.eq_ignore_ascii_case(c::MAP) { + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.dest(kwargs, a)?; + let op = kw_op(kwargs, MAP_AFFINE, parse_map_op)?; + let mut valid = vec!["a", "out", "op"]; + valid.extend_from_slice(map_param_keys(op)); + reject_unknown_kwargs(kwargs, &valid)?; + let (p0, p1) = map_params(kwargs, op)?; + algebra_map(out, a, comp, op, p0, p1).map_err(rt) + } else if name.eq_ignore_ascii_case(c::COMBINE) { + reject_unknown_kwargs(kwargs, &["a", "b", "out", "op", "b_scale", "b_offset"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let (b, b_comp) = self.operand(kwargs, "b")?; + if b_comp != comp { + return Err(PyValueError::new_err(format!( + "apply(combine): `a` has {comp} components but `b` has {b_comp} (must match)" + ))); + } + let out = self.dest(kwargs, a)?; + let op = kw_op(kwargs, COMBINE_ADD, parse_combine_op)?; + let b_scale = kw_f32(kwargs, "b_scale", 1.0)?; + let b_offset = kw_f32(kwargs, "b_offset", 0.0)?; + algebra_combine(out, a, b, comp, op, b_scale, b_offset).map_err(rt) + } else if name.eq_ignore_ascii_case(c::MIX) { + reject_unknown_kwargs( + kwargs, + &["a", "b", "t", "out", "t_scale", "t_offset", "t_clamp"], + )?; + let (a, comp) = self.operand(kwargs, "a")?; + let (b, b_comp) = self.operand(kwargs, "b")?; + let (t, t_comp) = self.operand(kwargs, "t")?; + if b_comp != comp { + return Err(PyValueError::new_err(format!( + "apply(mix): `a` has {comp} components but `b` has {b_comp} (must match)" + ))); + } + if t_comp != 1 { + return Err(PyValueError::new_err(format!( + "apply(mix): `t` must be a per-particle scalar (1 component), got {t_comp}" + ))); + } + let out = self.dest(kwargs, a)?; + let t_scale = kw_f32(kwargs, "t_scale", 1.0)?; + let t_offset = kw_f32(kwargs, "t_offset", 0.0)?; + let t_clamp = kw_bool(kwargs, "t_clamp", true)?; + algebra_mix(out, a, b, t, comp, t_scale, t_offset, t_clamp).map_err(rt) + } else if name.eq_ignore_ascii_case(c::LOOKUP) { + reject_unknown_kwargs( + kwargs, + &[ + "a", + "out", + "tex", + "u_scale", + "u_offset", + "v_scale", + "v_offset", + "color_scale", + ], + )?; + let (a, in_comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let tex = kw(kwargs, "tex") + .ok_or_else(|| PyRuntimeError::new_err("apply(lookup): missing 'tex' Image"))? + .extract::>() + .map_err(|_| PyRuntimeError::new_err("apply(lookup): 'tex' must be an Image"))? + .entity; + let u_scale = kw_f32(kwargs, "u_scale", 1.0)?; + let u_offset = kw_f32(kwargs, "u_offset", 0.0)?; + let v_scale = kw_f32(kwargs, "v_scale", 1.0)?; + let v_offset = kw_f32(kwargs, "v_offset", 0.0)?; + let color_scale = kw_f32(kwargs, "color_scale", 1.0)?; + algebra_lookup( + out, + a, + tex, + in_comp, + u_scale, + u_offset, + v_scale, + v_offset, + color_scale, + ) + .map_err(rt) + } else if name.eq_ignore_ascii_case(c::REDUCE) { + reject_unknown_kwargs(kwargs, &["a", "out", "op"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let op = kw_op(kwargs, REDUCE_LENGTH, parse_reduce_op)?; + algebra_reduce(out, a, comp, op).map_err(rt) + } else if name.eq_ignore_ascii_case(c::EXTRACT) { + reject_unknown_kwargs(kwargs, &["a", "out", "index"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let index = kw_u32(kwargs, "index", 0)?; + algebra_extract(out, a, comp, index).map_err(rt) + } else if name.eq_ignore_ascii_case(c::PACK) { + reject_unknown_kwargs(kwargs, &["out", "sources"])?; + let out = self.operand(kwargs, "out")?.0; + let sources = kw(kwargs, "sources") + .ok_or_else(|| PyRuntimeError::new_err("apply(pack): missing 'sources' list"))?; + let items: Vec> = sources.extract()?; + let mut entities = Vec::with_capacity(items.len()); + for item in &items { + entities.push(self.resolve_operand(item)?.0); + } + algebra_pack(out, &entities).map_err(rt) + } else if name.eq_ignore_ascii_case(c::GENERATE) { + reject_unknown_kwargs(kwargs, &["out", "mode", "seed", "scale", "offset"])?; + let (out, comp) = self.operand(kwargs, "out")?; + let mode = match kw(kwargs, "mode") { + Some(v) => parse_generate_mode(&v.extract::()?)?, + None => GEN_UNIFORM, + }; + let seed = kw_u32(kwargs, "seed", 0)?; + let scale = kw_f32(kwargs, "scale", 1.0)?; + let offset = kw_f32(kwargs, "offset", 0.0)?; + algebra_generate(out, comp, mode, seed, scale, offset).map_err(rt) + } else if name.eq_ignore_ascii_case(c::NEIGHBOR) { + reject_unknown_kwargs(kwargs, &["a", "out", "grid", "op", "radius", "falloff"])?; + let grid = kw(kwargs, "grid") + .ok_or_else(|| PyRuntimeError::new_err("apply(neighbor): missing 'grid'"))? + .extract::>() + .map_err(|_| PyRuntimeError::new_err("apply(neighbor): 'grid' must be a Grid"))?; + let op = kw_op(kwargs, NEIGHBOR_MEAN, parse_neighbor_op)?; + let falloff = match kw(kwargs, "falloff") { + Some(v) => parse_falloff(&v.extract::()?)?, + None => FALLOFF_SMOOTHSTEP, + }; + let cell = grid.inner.params.cell_size; + let radius = kw_f32(kwargs, "radius", cell)?.min(cell); + + let (out, out_comp) = self.operand(kwargs, "out")?; + let (a, components) = if op == NEIGHBOR_COUNT { + if out_comp != 1 { + return Err(PyValueError::new_err( + "apply(neighbor, op=count/density): `out` must be a scalar (1 component)", + )); + } + let a = match kw(kwargs, "a") { + Some(_) => self.operand(kwargs, "a")?.0, + None => { + let pos = Self::builtin_attribute("position") + .expect("position is a built-in") + .entity; + particles_ensure_attribute(self.entity, pos).map_err(rt)? + } + }; + (a, 1u32) + } else { + let (a, in_comp) = self.operand(kwargs, "a")?; + if in_comp != out_comp { + return Err(PyValueError::new_err(format!( + "apply(neighbor): source has {in_comp} components but out has {out_comp}" + ))); + } + (a, in_comp) + }; + particles_gather( + self.entity, + &grid.inner, + a, + out, + op, + radius, + falloff, + components, + ) + .map_err(rt) + } else { + Err(PyValueError::new_err(format!( + "apply(): unknown operation {name:?}" + ))) + } + } } #[pymethods] @@ -273,13 +760,47 @@ impl Particles { Ok(Buffer::from_entity(buf, Some(element_type))) } - #[pyo3(signature = (compute, **kwargs))] - pub fn apply(&self, compute: &Compute, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { - if let Some(kwargs) = kwargs { - compute.set(Some(kwargs))?; + pub fn index_buffer(&self, index_count: u32) -> PyResult { + let entity = particles_set_connectivity(self.entity, index_count) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Buffer::from_entity( + entity, + Some(shader_value::ShaderValue::UInt(0)), + )) + } + + pub fn draw_args(&self) -> PyResult { + let entity = particles_connectivity_indirect(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Buffer::from_entity( + entity, + Some(shader_value::ShaderValue::UInt(0)), + )) + } + + pub fn reset_indices(&self) -> PyResult<()> { + particles_reset_indices(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (kind, **kwargs))] + pub fn apply( + &self, + kind: &Bound<'_, PyAny>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + if let Ok(compute) = kind.extract::>() { + if let Some(kwargs) = kwargs { + compute.set(Some(kwargs))?; + } + return particles_apply(self.entity, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); } - particles_apply(self.entity, compute.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + let name: String = kind.extract().map_err(|_| { + PyTypeError::new_err( + "apply(): first argument must be an operation constant or a Compute", + ) + })?; + self.apply_named(&name, kwargs) } #[pyo3(signature = (n, **kwargs))] @@ -317,6 +838,11 @@ impl Particles { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn compact(&self, flags: &Bound<'_, PyAny>, out: &Buffer) -> PyResult { + let (flag_buf, _) = self.resolve_operand(flags)?; + compact_indices(flag_buf, out.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[staticmethod] pub fn noise() -> PyResult { let entity = @@ -400,11 +926,35 @@ impl Particles { Ok(Compute::from_entity(entity)) } - #[staticmethod] - pub fn flock() -> PyResult { - let entity = - particles_kernel_flock().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) + pub fn create_grid(&self, min: [f32; 3], cell_size: f32, dims: [u32; 3]) -> PyResult { + let capacity = + particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let params = GridParams { + min, + cell_size, + dims, + }; + let inner = + grid_create(params, capacity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Grid { inner }) + } + + #[pyo3(signature = (grid, **kwargs))] + pub fn flock(&self, grid: &Grid, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let flock = flock_compute()?; + if let Some(kwargs) = kwargs { + crate::compute::set_compute_kwargs(flock, kwargs)?; + } + let cell = grid.inner.params.cell_size; + let neighbor_distance = kw_f32(kwargs, "neighbor_distance", cell)?.min(cell); + compute_set( + flock, + "neighbor_distance", + shader_value::ShaderValue::Float(neighbor_distance), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + particles_flock(self.entity, flock, &grid.inner) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[staticmethod] @@ -421,41 +971,6 @@ impl Particles { Ok(Compute::from_entity(entity)) } - #[staticmethod] - pub fn attr_linear() -> PyResult { - let entity = - particles_kernel_attr_linear().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) - } - - #[staticmethod] - pub fn attr_combine() -> PyResult { - let entity = - particles_kernel_attr_combine().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) - } - - #[staticmethod] - pub fn attr_mix() -> PyResult { - let entity = - particles_kernel_attr_mix().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) - } - - #[staticmethod] - pub fn attr_lookup1d() -> PyResult { - let entity = particles_kernel_attr_lookup1d() - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) - } - - #[staticmethod] - pub fn attr_lookup2d() -> PyResult { - let entity = particles_kernel_attr_lookup2d() - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Compute::from_entity(entity)) - } - #[staticmethod] pub fn scatter_surface(geometry: &Geometry) -> PyResult { let entity = particles_scatter_create(geometry.entity) diff --git a/crates/processing_render/shaders/processing/particles.wesl b/crates/processing_render/shaders/processing/particles.wesl new file mode 100644 index 00000000..9545b2b3 --- /dev/null +++ b/crates/processing_render/shaders/processing/particles.wesl @@ -0,0 +1,28 @@ +fn falloff(d: f32, radius: f32, mode: u32) -> f32 { + let n = 1.0 - d / radius; + switch mode { + case 1u: { return n; } + case 2u: { return n * n * (3.0 - 2.0 * n); } + case 3u: { return n * n; } + case 4u: { return n * n * n; } + case 5u: { return radius / (d + radius); } + default: { return 1.0; } + } +} + +fn cell_coords(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> vec3 { + let rel = (p - grid_min) / cell_size; + return vec3( + clamp(i32(floor(rel.x)), 0, i32(dims.x) - 1), + clamp(i32(floor(rel.y)), 0, i32(dims.y) - 1), + clamp(i32(floor(rel.z)), 0, i32(dims.z) - 1), + ); +} + +fn cell_index(c: vec3, dims: vec3) -> u32 { + return c.x + c.y * dims.x + c.z * dims.x * dims.y; +} + +fn cell_of(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> u32 { + return cell_index(vec3(cell_coords(p, grid_min, cell_size, dims)), dims); +} diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 4a3e53b4..4b56fe6a 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -73,6 +73,26 @@ pub fn create_buffer( .id() } +pub fn create_buffer_with_usage( + In((size, extra_usage)): In<(u64, BufferUsages)>, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let mut shader_buffer = ShaderBuffer::new(&vec![0u8; size as usize], RenderAssetUsages::all()); + shader_buffer.buffer_description.usage |= extra_usage; + let handle = buffers.add(shader_buffer); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + pub fn create_buffer_with_data( In(data): In>, mut commands: Commands, diff --git a/crates/processing_render/src/geometry/attribute.rs b/crates/processing_render/src/geometry/attribute.rs index d7f153b6..ca5f7764 100644 --- a/crates/processing_render/src/geometry/attribute.rs +++ b/crates/processing_render/src/geometry/attribute.rs @@ -317,12 +317,33 @@ pub fn default_attribute_init(name: &str, format: AttributeFormat) -> Vec { } } +#[derive(Resource, Default)] +pub struct AttributeRegistry { + by_name: std::collections::HashMap, +} + pub fn create( In((name, format)): In<(String, AttributeFormat)>, mut commands: Commands, + builtins: Res, + mut registry: ResMut, ) -> Result { - // TODO: validation? - Ok(commands.spawn(Attribute::new(name, format)).id()) + if let Some(entity) = builtins.by_name(&name) { + return Ok(entity); + } + let id = hash_attr_name(&name); + if let Some(&(entity, existing)) = registry.by_name.get(&id) { + if existing != format { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{name}' was already declared as {existing:?}, cannot \ + redeclare it as {format:?}" + ))); + } + return Ok(entity); + } + let entity = commands.spawn(Attribute::new(name, format)).id(); + registry.by_name.insert(id, (entity, format)); + Ok(entity) } pub fn destroy(In(entity): In, mut commands: Commands) -> Result<()> { diff --git a/crates/processing_render/src/geometry/mod.rs b/crates/processing_render/src/geometry/mod.rs index 041193cb..0ed13339 100644 --- a/crates/processing_render/src/geometry/mod.rs +++ b/crates/processing_render/src/geometry/mod.rs @@ -24,6 +24,7 @@ pub struct GeometryPlugin; impl Plugin for GeometryPlugin { fn build(&self, app: &mut App) { app.init_resource::(); + app.init_resource::(); } } diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index ce03db59..e7a4fcd7 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -60,7 +60,10 @@ pub fn blit( render_device: Res, render_queue: Res, gpu_images: Res>, - view_targets: Query<(&bevy::render::sync_world::MainEntity, &bevy::render::view::ViewTarget)>, + view_targets: Query<( + &bevy::render::sync_world::MainEntity, + &bevy::render::view::ViewTarget, + )>, mut cache: ResMut, ) -> Result<()> { let dst = gpu_images diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 286b7935..6359c86f 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -20,21 +20,32 @@ pub mod text; pub mod time; pub mod transform; +pub use particles::algebra::{ + GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM, MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, + MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, combine, extract, + generate, lookup, map, mix, pack, reduce_components, +}; +pub use particles::compact::compact; +pub use particles::grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; +pub use particles::reduce::{REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce}; +pub use particles::sort::bitonic_sort_by_key; pub use particles::{ BOUNDS_CLAMP, BOUNDS_REFLECT, BOUNDS_SOFT, BOUNDS_WRAP, COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, FALLOFF_CONST, FALLOFF_CUBIC, FALLOFF_INVERSE, FALLOFF_LINEAR, FALLOFF_QUADRATIC, FALLOFF_SMOOTHSTEP, particles_apply, - particles_attribute_add, particles_buffer, particles_capacity, particles_create, - particles_create_from_geometry, particles_destroy, particles_emit, particles_emit_gpu, - particles_ensure_attribute, + particles_attribute_add, particles_buffer, particles_capacity, particles_connectivity_indirect, + particles_create, particles_create_from_geometry, particles_destroy, particles_emit, + particles_emit_gpu, particles_ensure_attribute, particles_flock, particles_gather, particles_kernel_age, particles_kernel_attr_combine, particles_kernel_attr_linear, particles_kernel_attr_lookup1d, particles_kernel_attr_lookup2d, particles_kernel_attr_mix, particles_kernel_attract, particles_kernel_bounds_box, particles_kernel_bounds_geometry, particles_kernel_bounds_sphere, particles_kernel_drag, particles_kernel_field, particles_kernel_flock, particles_kernel_force, particles_kernel_impulse, particles_kernel_integrate, particles_kernel_noise, particles_kernel_orient, - particles_kernel_transform, particles_kernel_vortex, particles_scatter_create, - particles_scatter_volume_create, + particles_kernel_transform, particles_kernel_vortex, particles_reset_indices, + particles_scatter_create, particles_scatter_volume_create, particles_set_connectivity, + prefix_sum_u32, }; use std::path::PathBuf; @@ -159,7 +170,14 @@ pub fn surface_create_wayland( app.world_mut() .run_system_cached_with( surface::create_surface_wayland, - (window_handle, display_handle, width, height, scale_factor, transparent), + ( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ), ) .unwrap() }) @@ -179,7 +197,14 @@ pub fn surface_create_x11( app.world_mut() .run_system_cached_with( surface::create_surface_x11, - (window_handle, display_handle, width, height, scale_factor, transparent), + ( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ), ) .unwrap() }) @@ -1762,6 +1787,21 @@ pub fn shader_create(source: &str) -> error::Result { }) } +pub fn shader_create_with_features( + source: &str, + features: &[(&str, bool)], +) -> error::Result { + let features: Vec<(String, bool)> = features.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + material::custom::create_shader_with_features, + (source.to_string(), features), + ) + .unwrap() + }) +} + /// 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 { @@ -2205,6 +2245,20 @@ pub fn buffer_create(size: u64) -> error::Result { }) } +pub fn buffer_create_with_usage( + size: u64, + extra_usage: bevy::render::render_resource::BufferUsages, +) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer_with_usage, (size, extra_usage)) + .unwrap(); + app.update(); + Ok(entity) + }) +} + pub fn buffer_create_with_data(data: Vec) -> error::Result { app_mut(|app| { let entity = app diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index e7fe6052..f39de9d7 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -140,6 +140,13 @@ impl wesl::Resolver for ProcessingResolver<'_> { } pub(crate) fn compile_shader(source: &str) -> Result<(String, naga::Module)> { + compile_shader_with_features(source, &[]) +} + +pub(crate) fn compile_shader_with_features( + source: &str, + features: &[(&str, bool)], +) -> Result<(String, naga::Module)> { let mut pkg_resolver = PkgResolver::new(); pkg_resolver.add_package(&processing::PACKAGE); pkg_resolver.add_package(&lygia::PACKAGE); @@ -149,11 +156,17 @@ pub(crate) fn compile_shader(source: &str) -> Result<(String, naga::Module)> { pkg_resolver, }; let module_path: ModulePath = "entry".parse().unwrap(); - let options = wesl::CompileOptions { + let mut options = wesl::CompileOptions { imports: true, strip: false, ..Default::default() }; + for (name, enabled) in features { + options + .features + .flags + .insert((*name).to_string(), (*enabled).into()); + } let compiled = wesl::compile(&module_path, &resolver, &wesl::EscapeMangler, &options) .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; let wgsl = compiled.to_string(); @@ -177,6 +190,22 @@ pub fn create_shader( .id()) } +pub fn create_shader_with_features( + In((source, features)): In<(String, Vec<(String, bool)>)>, + mut commands: Commands, + mut shaders: ResMut>, +) -> Result { + let feats: Vec<(&str, bool)> = features.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + let (compiled_wgsl, module) = compile_shader_with_features(&source, &feats)?; + let shader_handle = shaders.add(ShaderAsset::from_wgsl(compiled_wgsl, "custom_material")); + Ok(commands + .spawn(Shader { + module, + shader_handle, + }) + .id()) +} + pub fn load_shader(In(path): In, world: &mut World) -> Result { use bevy::asset::{ AssetPath, LoadState, handle_internal_asset_events, @@ -306,32 +335,146 @@ pub(crate) fn apply_reflect_field( Err(ProcessingError::UnknownShaderProperty(name.to_string())) } -fn reflect_scalar_as_f64(value: &dyn PartialReflect) -> Option { - if let Some(v) = value.try_downcast_ref::() { - Some(*v as f64) - } else if let Some(v) = value.try_downcast_ref::() { - Some(*v as f64) - } else if let Some(v) = value.try_downcast_ref::() { - Some(*v as f64) +fn apply_field_coerced(field: &mut dyn PartialReflect, value: &dyn PartialReflect) { + if let Some(coerced) = coerce_numeric(field, value) { + field.apply(coerced.as_ref()); } else { - None + field.apply(value); } } -fn apply_field_coerced(field: &mut dyn PartialReflect, value: &dyn PartialReflect) { - if let Some(n) = reflect_scalar_as_f64(value) { - if field.try_downcast_ref::().is_some() { - field.apply((n as f32).as_partial_reflect()); - return; - } else if field.try_downcast_ref::().is_some() { - field.apply((n as u32).as_partial_reflect()); - return; - } else if field.try_downcast_ref::().is_some() { - field.apply((n as i32).as_partial_reflect()); - return; +fn coerce_numeric( + target: &dyn PartialReflect, + value: &dyn PartialReflect, +) -> Option> { + fn scalar_f32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as f32) + } else { + v.try_downcast_ref::().map(|x| *x as f32) + } + } + fn scalar_i32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as i32) + } else { + v.try_downcast_ref::().map(|x| *x as i32) + } + } + fn scalar_u32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as u32) + } else { + v.try_downcast_ref::().map(|x| *x as u32) } } - field.apply(value); + fn comps(v: &dyn PartialReflect) -> Option<[f64; N]> { + macro_rules! from { + ($ty:ty) => { + if let Some(x) = v.try_downcast_ref::<$ty>() { + let a = x.to_array(); + let mut out = [0f64; N]; + for i in 0..N { + out[i] = a[i] as f64; + } + return Some(out); + } + }; + } + match N { + 2 => { + from!(Vec2); + from!(IVec2); + from!(UVec2); + } + 3 => { + from!(Vec3); + from!(IVec3); + from!(UVec3); + } + 4 => { + from!(Vec4); + from!(IVec4); + from!(UVec4); + } + _ => {} + } + None + } + + if target.try_downcast_ref::().is_some() { + return scalar_f32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return scalar_i32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return scalar_u32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(Vec2::new(c[0] as f32, c[1] as f32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(IVec2::new(c[0] as i32, c[1] as i32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(UVec2::new(c[0] as u32, c[1] as u32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(Vec3::new(c[0] as f32, c[1] as f32, c[2] as f32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(IVec3::new(c[0] as i32, c[1] as i32, c[2] as i32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(UVec3::new(c[0] as u32, c[1] as u32, c[2] as u32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(Vec4::new( + c[0] as f32, + c[1] as f32, + c[2] as f32, + c[3] as f32, + )) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(IVec4::new( + c[0] as i32, + c[1] as i32, + c[2] as i32, + c[3] as i32, + )) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(UVec4::new( + c[0] as u32, + c[1] as u32, + c[2] as u32, + c[3] as u32, + )) as Box + }); + } + None } pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result> { @@ -345,6 +488,9 @@ pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result Box::new(IVec3::from_array(*v)), ShaderValue::Int4(v) => Box::new(IVec4::from_array(*v)), ShaderValue::UInt(v) => Box::new(*v), + ShaderValue::UInt2(v) => Box::new(UVec2::from_array(*v)), + ShaderValue::UInt3(v) => Box::new(UVec3::from_array(*v)), + ShaderValue::UInt4(v) => Box::new(UVec4::from_array(*v)), ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), ShaderValue::Texture(_) | ShaderValue::Buffer(_) diff --git a/crates/processing_render/src/particles/algebra.rs b/crates/processing_render/src/particles/algebra.rs new file mode 100644 index 00000000..7500040b --- /dev/null +++ b/crates/processing_render/src/particles/algebra.rs @@ -0,0 +1,613 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::{ProcessingError, Result}; + +use crate::shader_value::ShaderValue; +use crate::{buffer_size, compute_dispatch, compute_set, shader_create_with_features}; + +const WG: u32 = 64; + +pub const MAP_AFFINE: u32 = 0; +pub const MAP_ABS: u32 = 1; +pub const MAP_NEGATE: u32 = 2; +pub const MAP_CLAMP: u32 = 3; +pub const MAP_FLOOR: u32 = 4; +pub const MAP_SQUARE: u32 = 5; +pub const MAP_SQRT: u32 = 6; +pub const MAP_GREATER: u32 = 7; +pub const MAP_LESS: u32 = 8; +pub const MAP_GEQ: u32 = 9; +pub const MAP_LEQ: u32 = 10; +pub const MAP_EQ: u32 = 11; +pub const MAP_NEQ: u32 = 12; + +const MAP_SRC: &str = r#" +struct Params { + components: u32, + op: u32, + p0: f32, + p1: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +fn apply_op(x: f32) -> f32 { + switch params.op { + case 0u: { return x * params.p0 + params.p1; } + case 1u: { return abs(x); } + case 2u: { return -x; } + case 3u: { return clamp(x, params.p0, params.p1); } + case 4u: { return floor(x); } + case 5u: { return x * x; } + case 6u: { return sqrt(max(x, 0.0)); } + case 7u: { return select(0.0, 1.0, x > params.p0); } + case 8u: { return select(0.0, 1.0, x < params.p0); } + case 9u: { return select(0.0, 1.0, x >= params.p0); } + case 10u: { return select(0.0, 1.0, x <= params.p0); } + case 11u: { return select(0.0, 1.0, abs(x - params.p0) <= params.p1); } + case 12u: { return select(0.0, 1.0, abs(x - params.p0) > params.p1); } + default: { return x; } + } +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + @if(in_place) { a[idx] = apply_op(a[idx]); } + @if(!in_place) { dst[idx] = apply_op(a[idx]); } + } +} +"#; + +struct Variants { + out_of_place: Entity, + in_place: Entity, +} + +fn variants(cache: &Mutex>, src: &str) -> Result { + let mut guard = cache.lock().unwrap(); + if let Some((o, i)) = *guard { + return Ok(Variants { + out_of_place: o, + in_place: i, + }); + } + let out_of_place = shader_create_with_features(src, &[("in_place", false)])?; + let in_place = shader_create_with_features(src, &[("in_place", true)])?; + let out_of_place = crate::compute_create(out_of_place)?; + let in_place = crate::compute_create(in_place)?; + *guard = Some((out_of_place, in_place)); + Ok(Variants { + out_of_place, + in_place, + }) +} + +static MAP: Mutex> = Mutex::new(None); + +fn dispatch_particles(compute: Entity, floats: u64, components: u32) -> Result<()> { + let n = (floats / components as u64) as u32; + compute_dispatch(compute, n.div_ceil(WG), 1, 1) +} + +fn check_components(verb: &str, components: u32) -> Result<()> { + if components == 0 || components > 4 { + return Err(ProcessingError::InvalidArgument(format!( + "{verb}: components must be 1..=4, got {components}" + ))); + } + Ok(()) +} + +fn ensure_no_alias(verb: &str, rw: Entity, reads: &[Entity]) -> Result<()> { + if reads.contains(&rw) { + return Err(ProcessingError::InvalidArgument(format!( + "{verb}: dst aliases a read-only operand; make dst the first operand \ + (in-place) or pass a distinct dst buffer" + ))); + } + Ok(()) +} + +pub fn map(dst: Entity, a: Entity, components: u32, op: u32, p0: f32, p1: f32) -> Result<()> { + check_components("map", components)?; + let v = variants(&MAP, MAP_SRC)?; + let floats = buffer_size(a)? / 4; + + if dst == a { + let c = v.in_place; + compute_set(c, "a", ShaderValue::Buffer(a))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "p0", ShaderValue::Float(p0))?; + compute_set(c, "p1", ShaderValue::Float(p1))?; + dispatch_particles(c, floats, components) + } else { + let c = v.out_of_place; + compute_set(c, "a", ShaderValue::Buffer(a))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "p0", ShaderValue::Float(p0))?; + compute_set(c, "p1", ShaderValue::Float(p1))?; + dispatch_particles(c, floats, components) + } +} + +const COMBINE_SRC: &str = r#" +struct Params { + components: u32, + op: u32, + b_scale: f32, + b_offset: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@group(0) @binding(1) var b: array; +@if(!in_place) @group(0) @binding(2) var dst: array; +@group(0) @binding(3) var params: Params; + +fn combine_op(x: f32, y: f32) -> f32 { + switch params.op { + case 0u: { return x + y; } + case 1u: { return x - y; } + case 2u: { return x * y; } + case 3u: { if y == 0.0 { return x; } return x / y; } + case 4u: { return min(x, y); } + case 5u: { return max(x, y); } + case 6u: { return pow(max(x, 0.0), y); } + default: { return x; } + } +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + let r = combine_op(a[idx], b[idx] * params.b_scale + params.b_offset); + @if(in_place) { a[idx] = r; } + @if(!in_place) { dst[idx] = r; } + } +} +"#; + +static COMBINE: Mutex> = Mutex::new(None); + +pub fn combine( + dst: Entity, + a: Entity, + b: Entity, + components: u32, + op: u32, + b_scale: f32, + b_offset: f32, +) -> Result<()> { + check_components("combine", components)?; + let v = variants(&COMBINE, COMBINE_SRC)?; + let floats = buffer_size(a)? / 4; + + let c = if dst == a { + ensure_no_alias("combine", a, &[b])?; + v.in_place + } else { + ensure_no_alias("combine", dst, &[a, b])?; + compute_set(v.out_of_place, "a", ShaderValue::Buffer(a))?; + compute_set(v.out_of_place, "dst", ShaderValue::Buffer(dst))?; + v.out_of_place + }; + if dst == a { + compute_set(c, "a", ShaderValue::Buffer(a))?; + } + compute_set(c, "b", ShaderValue::Buffer(b))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "b_scale", ShaderValue::Float(b_scale))?; + compute_set(c, "b_offset", ShaderValue::Float(b_offset))?; + dispatch_particles(c, floats, components) +} + +const MIX_SRC: &str = r#" +struct Params { + components: u32, + t_clamp: u32, + t_scale: f32, + t_offset: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@group(0) @binding(1) var b: array; +@group(0) @binding(2) var t: array; +@if(!in_place) @group(0) @binding(3) var dst: array; +@group(0) @binding(4) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + // t is one value per particle, broadcast across the components. + var tv = t[i] * params.t_scale + params.t_offset; + if params.t_clamp != 0u { tv = clamp(tv, 0.0, 1.0); } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + let r = mix(a[idx], b[idx], tv); + @if(in_place) { a[idx] = r; } + @if(!in_place) { dst[idx] = r; } + } +} +"#; + +static MIX: Mutex> = Mutex::new(None); + +pub fn mix( + dst: Entity, + a: Entity, + b: Entity, + t: Entity, + components: u32, + t_scale: f32, + t_offset: f32, + t_clamp: bool, +) -> Result<()> { + check_components("mix", components)?; + let v = variants(&MIX, MIX_SRC)?; + let floats = buffer_size(a)? / 4; + + let c = if dst == a { + ensure_no_alias("mix", a, &[b, t])?; + v.in_place + } else { + ensure_no_alias("mix", dst, &[a, b, t])?; + compute_set(v.out_of_place, "a", ShaderValue::Buffer(a))?; + compute_set(v.out_of_place, "dst", ShaderValue::Buffer(dst))?; + v.out_of_place + }; + if dst == a { + compute_set(c, "a", ShaderValue::Buffer(a))?; + } + compute_set(c, "b", ShaderValue::Buffer(b))?; + compute_set(c, "t", ShaderValue::Buffer(t))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "t_scale", ShaderValue::Float(t_scale))?; + compute_set(c, "t_offset", ShaderValue::Float(t_offset))?; + compute_set(c, "t_clamp", ShaderValue::UInt(t_clamp as u32))?; + dispatch_particles(c, floats, components) +} + +const LOOKUP_SRC: &str = r#" +struct Params { + in_components: u32, + u_scale: f32, + u_offset: f32, + v_scale: f32, + v_offset: f32, + color_scale: f32, + _p0: u32, + _p1: u32, +} + +@group(0) @binding(0) var op_in: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; +@group(0) @binding(3) var tex: texture_2d; +@group(0) @binding(4) var samp: sampler; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst) / 4u; + if i >= n { return; } + + var uv: vec2; + if params.in_components == 1u { + uv = vec2(op_in[i] * params.u_scale + params.u_offset, 0.5); + } else { + uv = vec2( + op_in[i * 2u] * params.u_scale + params.u_offset, + op_in[i * 2u + 1u] * params.v_scale + params.v_offset, + ); + } + uv = clamp(uv, vec2(0.0), vec2(1.0)); + + let c = textureSampleLevel(tex, samp, uv, 0.0); + dst[i * 4u + 0u] = c.r * params.color_scale; + dst[i * 4u + 1u] = c.g * params.color_scale; + dst[i * 4u + 2u] = c.b * params.color_scale; + dst[i * 4u + 3u] = c.a; +} +"#; + +static LOOKUP: Mutex> = Mutex::new(None); + +fn single_pipeline(cache: &Mutex>, src: &str) -> Result { + let mut guard = cache.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = crate::compute_create(crate::shader_create(src)?)?; + *guard = Some(compute); + Ok(compute) +} + +#[allow(clippy::too_many_arguments)] +pub fn lookup( + dst: Entity, + op_in: Entity, + tex: Entity, + in_components: u32, + u_scale: f32, + u_offset: f32, + v_scale: f32, + v_offset: f32, + color_scale: f32, +) -> Result<()> { + if in_components != 1 && in_components != 2 { + return Err(ProcessingError::InvalidArgument(format!( + "lookup: in_components must be 1 or 2, got {in_components}" + ))); + } + ensure_no_alias("lookup", dst, &[op_in])?; + let c = single_pipeline(&LOOKUP, LOOKUP_SRC)?; + compute_set(c, "op_in", ShaderValue::Buffer(op_in))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "in_components", ShaderValue::UInt(in_components))?; + compute_set(c, "u_scale", ShaderValue::Float(u_scale))?; + compute_set(c, "u_offset", ShaderValue::Float(u_offset))?; + compute_set(c, "v_scale", ShaderValue::Float(v_scale))?; + compute_set(c, "v_offset", ShaderValue::Float(v_offset))?; + compute_set(c, "color_scale", ShaderValue::Float(color_scale))?; + compute_set(c, "tex", ShaderValue::Texture(tex))?; + compute_set(c, "samp", ShaderValue::Texture(tex))?; + let n = (buffer_size(dst)? / 16) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +pub const REDUCE_LENGTH: u32 = 0; +pub const REDUCE_SUM: u32 = 1; +pub const REDUCE_MIN: u32 = 2; +pub const REDUCE_MAX: u32 = 3; +pub const REDUCE_SUMSQ: u32 = 4; +pub const REDUCE_MEAN: u32 = 5; + +const REDUCE_SRC: &str = r#" +struct Params { components: u32, op: u32 } + +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst); + if i >= n { return; } + let base = i * params.components; + var s = 0.0; + var ss = 0.0; + var mn = src[base]; + var mx = src[base]; + for (var c = 0u; c < params.components; c = c + 1u) { + let v = src[base + c]; + s = s + v; + ss = ss + v * v; + mn = min(mn, v); + mx = max(mx, v); + } + switch params.op { + case 0u: { dst[i] = sqrt(ss); } + case 1u: { dst[i] = s; } + case 2u: { dst[i] = mn; } + case 3u: { dst[i] = mx; } + case 4u: { dst[i] = ss; } + case 5u: { dst[i] = s / f32(params.components); } + default: { dst[i] = s; } + } +} +"#; + +static REDUCE: Mutex> = Mutex::new(None); + +pub fn reduce_components(dst: Entity, src: Entity, components: u32, op: u32) -> Result<()> { + check_components("reduce_components", components)?; + ensure_no_alias("reduce_components", dst, &[src])?; + let c = single_pipeline(&REDUCE, REDUCE_SRC)?; + compute_set(c, "src", ShaderValue::Buffer(src))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + let n = (buffer_size(dst)? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +const EXTRACT_SRC: &str = r#" +struct Params { components: u32, index: u32 } + +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst); + if i >= n { return; } + dst[i] = src[i * params.components + params.index]; +} +"#; + +static EXTRACT: Mutex> = Mutex::new(None); + +pub fn extract(dst: Entity, src: Entity, components: u32, index: u32) -> Result<()> { + check_components("extract", components)?; + if index >= components { + return Err(ProcessingError::InvalidArgument(format!( + "extract: index {index} out of range for {components} components" + ))); + } + ensure_no_alias("extract", dst, &[src])?; + let c = single_pipeline(&EXTRACT, EXTRACT_SRC)?; + compute_set(c, "src", ShaderValue::Buffer(src))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "index", ShaderValue::UInt(index))?; + let n = (buffer_size(dst)? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +const PACK_SRC: &str = r#" +struct Params { components: u32 } + +@group(0) @binding(0) var s0: array; +@group(0) @binding(1) var s1: array; +@if(ge3) @group(0) @binding(2) var s2: array; +@if(ge4) @group(0) @binding(3) var s3: array; +@group(0) @binding(4) var dst: 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 n = arrayLength(&s0); + if i >= n { return; } + let c = params.components; + dst[i * c + 0u] = s0[i]; + dst[i * c + 1u] = s1[i]; + @if(ge3) { dst[i * c + 2u] = s2[i]; } + @if(ge4) { dst[i * c + 3u] = s3[i]; } +} +"#; + +static PACK: Mutex> = Mutex::new(None); + +fn pack_pipeline(components: u32) -> Result { + let mut guard = PACK.lock().unwrap(); + if guard.is_none() { + let p2 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", false), ("ge4", false)], + )?)?; + let p3 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", true), ("ge4", false)], + )?)?; + let p4 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", true), ("ge4", true)], + )?)?; + *guard = Some([p2, p3, p4]); + } + Ok(guard.unwrap()[(components - 2) as usize]) +} + +pub fn pack(dst: Entity, sources: &[Entity]) -> Result<()> { + let components = sources.len() as u32; + if !(2..=4).contains(&components) { + return Err(ProcessingError::InvalidArgument(format!( + "pack: expects 2..=4 source buffers, got {}", + sources.len() + ))); + } + ensure_no_alias("pack", dst, sources)?; + let c = pack_pipeline(components)?; + compute_set(c, "s0", ShaderValue::Buffer(sources[0]))?; + compute_set(c, "s1", ShaderValue::Buffer(sources[1]))?; + if components >= 3 { + compute_set(c, "s2", ShaderValue::Buffer(sources[2]))?; + } + if components >= 4 { + compute_set(c, "s3", ShaderValue::Buffer(sources[3]))?; + } + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + let n = (buffer_size(sources[0])? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +pub const GEN_UNIFORM: u32 = 0; +pub const GEN_SIGNED: u32 = 1; +pub const GEN_GAUSSIAN: u32 = 2; + +const GENERATE_SRC: &str = r#" +struct Params { + components: u32, + mode: u32, + seed: u32, + scale: f32, + offset: f32, +} + +@group(0) @binding(0) var dst: array; +@group(0) @binding(1) var params: Params; + +fn hashu(x0: u32) -> u32 { + var x = x0; + x = x ^ (x >> 16u); + x = x * 0x7feb352du; + x = x ^ (x >> 15u); + x = x * 0x846ca68bu; + x = x ^ (x >> 16u); + return x; +} + +fn rnd(x: u32) -> f32 { + return f32(hashu(x)) / 4294967295.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let id = i * params.components + c; + let base = id ^ (params.seed * 0x9e3779b9u); + var v: f32; + switch params.mode { + case 1u: { v = rnd(base) * 2.0 - 1.0; } + case 2u: { + let u1 = max(rnd(base), 1e-7); + let u2 = rnd(base ^ 0x85ebca6bu); + v = sqrt(-2.0 * log(u1)) * cos(6.28318530718 * u2); + } + default: { v = rnd(base); } + } + dst[id] = v * params.scale + params.offset; + } +} +"#; + +static GENERATE: Mutex> = Mutex::new(None); + +pub fn generate( + dst: Entity, + components: u32, + mode: u32, + seed: u32, + scale: f32, + offset: f32, +) -> Result<()> { + check_components("generate", components)?; + let c = single_pipeline(&GENERATE, GENERATE_SRC)?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "mode", ShaderValue::UInt(mode))?; + compute_set(c, "seed", ShaderValue::UInt(seed))?; + compute_set(c, "scale", ShaderValue::Float(scale))?; + compute_set(c, "offset", ShaderValue::Float(offset))?; + let floats = buffer_size(dst)? / 4; + dispatch_particles(c, floats, components) +} diff --git a/crates/processing_render/src/particles/compact.rs b/crates/processing_render/src/particles/compact.rs new file mode 100644 index 00000000..ba016798 --- /dev/null +++ b/crates/processing_render/src/particles/compact.rs @@ -0,0 +1,66 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::particles::scan::prefix_sum_u32; +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_read_element, buffer_size, compute_create, + compute_dispatch, compute_set, shader_load, +}; + +const WG: u32 = 64; +const FLAG_SHADER: &str = "embedded://processing_render/particles/kernels/compact_flag.wgsl"; +const SCATTER_SHADER: &str = "embedded://processing_render/particles/kernels/compact_scatter.wgsl"; + +static COMPUTES: Mutex> = Mutex::new(None); +static SCANNED: Mutex> = Mutex::new(None); + +fn computes() -> Result<(Entity, Entity)> { + let mut guard = COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let flag = compute_create(shader_load(FLAG_SHADER)?)?; + let scatter = compute_create(shader_load(SCATTER_SHADER)?)?; + *guard = Some((flag, scatter)); + Ok((flag, scatter)) +} + +fn scanned_buffer(bytes: u64) -> Result { + let mut guard = SCANNED.lock().unwrap(); + if let Some((entity, size)) = *guard { + if size == bytes { + return Ok(entity); + } + let _ = buffer_destroy(entity); + } + let entity = buffer_create(bytes)?; + *guard = Some((entity, bytes)); + Ok(entity) +} + +pub fn compact(flags: Entity, indices_out: Entity) -> Result { + let n = (buffer_size(flags)? / 4) as u32; + if n == 0 { + return Ok(0); + } + let (flag_c, scatter_c) = computes()?; + let scanned = scanned_buffer(((n + 1) as u64) * 4)?; + + compute_set(flag_c, "flags", ShaderValue::Buffer(flags))?; + compute_set(flag_c, "scanned", ShaderValue::Buffer(scanned))?; + compute_dispatch(flag_c, (n + 1).div_ceil(WG), 1, 1)?; + + prefix_sum_u32(scanned)?; + + compute_set(scatter_c, "flags", ShaderValue::Buffer(flags))?; + compute_set(scatter_c, "scanned", ShaderValue::Buffer(scanned))?; + compute_set(scatter_c, "indices", ShaderValue::Buffer(indices_out))?; + compute_dispatch(scatter_c, n.div_ceil(WG), 1, 1)?; + + let bytes = buffer_read_element(scanned, (n as u64) * 4, 4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} diff --git a/crates/processing_render/src/particles/emit.rs b/crates/processing_render/src/particles/emit.rs index 12df47d8..f31fb5aa 100644 --- a/crates/processing_render/src/particles/emit.rs +++ b/crates/processing_render/src/particles/emit.rs @@ -1,13 +1,16 @@ +use std::sync::Mutex; + use bevy::prelude::*; use processing_core::app_mut; use processing_core::error; use crate::geometry; +use crate::particles::grid::{Grid, grid_bind, grid_build}; use crate::particles::kernels::KernelRequires; use crate::particles::{Particles, particles_ensure_attribute}; use crate::shader_value::ShaderValue; -use crate::{buffer_write_element, compute_dispatch, compute_set}; +use crate::{buffer_write_element, compute_create, compute_dispatch, compute_set, shader_load}; const WORKGROUP_SIZE: u32 = 64; @@ -140,6 +143,98 @@ pub fn particles_emit( }) } +pub fn particles_flock( + particles_entity: Entity, + flock_entity: Entity, + grid: &Grid, +) -> error::Result<()> { + let position = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + if attr.name == "position" { + return Ok(buf_entity); + } + } + Err(error::ProcessingError::InvalidArgument( + "particles_flock requires a `position` attribute".to_string(), + )) + })?; + + grid_build(grid, position)?; + grid_bind(grid, flock_entity)?; + particles_apply(particles_entity, flock_entity) +} + +static NEIGHBOR_COMPUTE: Mutex> = Mutex::new(None); + +fn neighbor_compute() -> error::Result { + let mut guard = NEIGHBOR_COMPUTE.lock().unwrap(); + if let Some(entity) = *guard { + return Ok(entity); + } + let shader = shader_load("embedded://processing_render/particles/kernels/neighbor.wgsl")?; + let entity = compute_create(shader)?; + *guard = Some(entity); + Ok(entity) +} + +pub fn particles_gather( + particles_entity: Entity, + grid: &Grid, + source: Entity, + out: Entity, + op: u32, + radius: f32, + falloff_mode: u32, + components: u32, +) -> error::Result<()> { + let (position, _capacity) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + if attr.name == "position" { + return Ok((buf_entity, field.capacity)); + } + } + Err(error::ProcessingError::InvalidArgument( + "apply(neighbor) requires a `position` attribute".to_string(), + )) + })?; + + if out == source || out == position { + return Err(error::ProcessingError::InvalidArgument( + "apply(neighbor): `out` must be a distinct buffer from the source and \ + position (in-place gather is a read/write hazard)" + .to_string(), + )); + } + + grid_build(grid, position)?; + let neighbor = neighbor_compute()?; + grid_bind(grid, neighbor)?; + compute_set(neighbor, "position", ShaderValue::Buffer(position))?; + compute_set(neighbor, "source", ShaderValue::Buffer(source))?; + compute_set(neighbor, "out", ShaderValue::Buffer(out))?; + compute_set(neighbor, "radius", ShaderValue::Float(radius))?; + compute_set(neighbor, "op", ShaderValue::UInt(op))?; + compute_set(neighbor, "falloff_mode", ShaderValue::UInt(falloff_mode))?; + compute_set(neighbor, "components", ShaderValue::UInt(components))?; + + let capacity = _capacity; + compute_dispatch(neighbor, capacity.div_ceil(WORKGROUP_SIZE), 1, 1) +} + pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { let required: Vec = app_mut(|app| { Ok(app diff --git a/crates/processing_render/src/particles/grid.rs b/crates/processing_render/src/particles/grid.rs new file mode 100644 index 00000000..0a31f069 --- /dev/null +++ b/crates/processing_render/src/particles/grid.rs @@ -0,0 +1,112 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::particles::scan::prefix_sum_u32; +use crate::shader_value::ShaderValue; +use crate::{buffer_create, compute_create, compute_dispatch, compute_set, shader_load}; + +const CLEAR_SHADER: &str = "embedded://processing_render/particles/kernels/grid_clear.wgsl"; +const COUNT_SHADER: &str = "embedded://processing_render/particles/kernels/grid_count.wgsl"; +const COPY_SHADER: &str = "embedded://processing_render/particles/kernels/grid_copy.wgsl"; +const SCATTER_SHADER: &str = "embedded://processing_render/particles/kernels/grid_scatter.wgsl"; + +static GRID_COMPUTES: Mutex> = Mutex::new(None); + +fn grid_computes() -> Result<(Entity, Entity, Entity, Entity)> { + let mut guard = GRID_COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let clear = compute_create(shader_load(CLEAR_SHADER)?)?; + let count = compute_create(shader_load(COUNT_SHADER)?)?; + let copy = compute_create(shader_load(COPY_SHADER)?)?; + let scatter = compute_create(shader_load(SCATTER_SHADER)?)?; + *guard = Some((clear, count, copy, scatter)); + Ok((clear, count, copy, scatter)) +} + +#[derive(Clone, Copy, Debug)] +pub struct GridParams { + pub min: [f32; 3], + pub cell_size: f32, + pub dims: [u32; 3], +} + +impl GridParams { + pub fn num_cells(&self) -> u32 { + self.dims[0] * self.dims[1] * self.dims[2] + } +} + +#[derive(Clone, Copy)] +pub struct Grid { + pub offsets: Entity, + pub cursor: Entity, + pub sorted: Entity, + pub params: GridParams, + pub capacity: u32, +} + +const CLEAR_WG: u32 = 256; +const PARTICLE_WG: u32 = 64; +const COPY_WG: u32 = 256; + +pub fn grid_create(params: GridParams, capacity: u32) -> Result { + let num_cells = params.num_cells(); + let offsets = buffer_create(((num_cells + 1) as u64) * 4)?; + let cursor = buffer_create((num_cells as u64) * 4)?; + let sorted = buffer_create((capacity.max(1) as u64) * 4)?; + Ok(Grid { + offsets, + cursor, + sorted, + params, + capacity, + }) +} + +pub fn grid_bind(grid: &Grid, compute: Entity) -> Result<()> { + compute_set(compute, "offsets", ShaderValue::Buffer(grid.offsets))?; + compute_set(compute, "sorted", ShaderValue::Buffer(grid.sorted))?; + set_domain(compute, &grid.params)?; + Ok(()) +} + +fn set_domain(compute: Entity, params: &GridParams) -> Result<()> { + compute_set(compute, "grid_min", ShaderValue::Float3(params.min))?; + compute_set(compute, "cell_size", ShaderValue::Float(params.cell_size))?; + compute_set(compute, "dims_x", ShaderValue::UInt(params.dims[0]))?; + compute_set(compute, "dims_y", ShaderValue::UInt(params.dims[1]))?; + compute_set(compute, "dims_z", ShaderValue::UInt(params.dims[2]))?; + Ok(()) +} + +pub fn grid_build(grid: &Grid, position: Entity) -> Result<()> { + let (clear, count, copy, scatter) = grid_computes()?; + let num_cells = grid.params.num_cells(); + + compute_set(clear, "counts", ShaderValue::Buffer(grid.offsets))?; + compute_dispatch(clear, (num_cells + 1).div_ceil(CLEAR_WG), 1, 1)?; + + compute_set(count, "position", ShaderValue::Buffer(position))?; + compute_set(count, "counts", ShaderValue::Buffer(grid.offsets))?; + set_domain(count, &grid.params)?; + compute_dispatch(count, grid.capacity.div_ceil(PARTICLE_WG), 1, 1)?; + + prefix_sum_u32(grid.offsets)?; + + compute_set(copy, "starts", ShaderValue::Buffer(grid.offsets))?; + compute_set(copy, "cursor", ShaderValue::Buffer(grid.cursor))?; + compute_dispatch(copy, num_cells.div_ceil(COPY_WG), 1, 1)?; + + compute_set(scatter, "position", ShaderValue::Buffer(position))?; + compute_set(scatter, "cursor", ShaderValue::Buffer(grid.cursor))?; + compute_set(scatter, "sorted", ShaderValue::Buffer(grid.sorted))?; + set_domain(scatter, &grid.params)?; + compute_dispatch(scatter, grid.capacity.div_ceil(PARTICLE_WG), 1, 1)?; + + Ok(()) +} diff --git a/crates/processing_render/src/particles/kernels/attract.wgsl b/crates/processing_render/src/particles/kernels/attract.wgsl index bcf45693..e6ef373d 100644 --- a/crates/processing_render/src/particles/kernels/attract.wgsl +++ b/crates/processing_render/src/particles/kernels/attract.wgsl @@ -1,3 +1,5 @@ +import processing::particles::falloff; + struct Params { center: vec3, _pad0: f32, @@ -27,19 +29,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d = sqrt(d2); let dir = diff / d; - var fall: f32 = 1.0; - let n = 1.0 - d / params.radius; - if params.falloff_mode == 1u { - fall = n; - } else if params.falloff_mode == 2u { - fall = n * n * (3.0 - 2.0 * n); - } else if params.falloff_mode == 3u { - fall = n * n; - } else if params.falloff_mode == 4u { - fall = n * n * n; - } else if params.falloff_mode == 5u { - fall = params.radius / (d + params.radius); - } + let fall = falloff(d, params.radius, params.falloff_mode); let kick = dir * (params.strength * fall); velocity[pi] = velocity[pi] + kick.x; diff --git a/crates/processing_render/src/particles/kernels/bitonic.wgsl b/crates/processing_render/src/particles/kernels/bitonic.wgsl new file mode 100644 index 00000000..f190a240 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/bitonic.wgsl @@ -0,0 +1,29 @@ +struct Params { + k: u32, + j: u32, +} + +@group(0) @binding(0) var keys: array; +@group(0) @binding(1) var payload: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&keys); + if i >= n { return; } + + let partner = i ^ params.j; + if partner <= i || partner >= n { return; } + + let up = (i & params.k) == 0u; + let ki = keys[i]; + let kp = keys[partner]; + if (ki > kp) == up { + keys[i] = kp; + keys[partner] = ki; + let pi = payload[i]; + payload[i] = payload[partner]; + payload[partner] = pi; + } +} diff --git a/crates/processing_render/src/particles/kernels/compact_flag.wgsl b/crates/processing_render/src/particles/kernels/compact_flag.wgsl new file mode 100644 index 00000000..ae71270a --- /dev/null +++ b/crates/processing_render/src/particles/kernels/compact_flag.wgsl @@ -0,0 +1,15 @@ +@group(0) @binding(0) var flags: array; +@group(0) @binding(1) var scanned: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let m = arrayLength(&scanned); + if i >= m { return; } + let n = arrayLength(&flags); + if i < n { + scanned[i] = select(0u, 1u, flags[i] != 0.0); + } else { + scanned[i] = 0u; + } +} diff --git a/crates/processing_render/src/particles/kernels/compact_scatter.wgsl b/crates/processing_render/src/particles/kernels/compact_scatter.wgsl new file mode 100644 index 00000000..f0bcac52 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/compact_scatter.wgsl @@ -0,0 +1,12 @@ +@group(0) @binding(0) var flags: array; +@group(0) @binding(1) var scanned: array; +@group(0) @binding(2) var indices: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&flags) { return; } + if flags[i] != 0.0 { + indices[scanned[i]] = i; + } +} diff --git a/crates/processing_render/src/particles/kernels/field.wgsl b/crates/processing_render/src/particles/kernels/field.wgsl index ef079d4d..009d2958 100644 --- a/crates/processing_render/src/particles/kernels/field.wgsl +++ b/crates/processing_render/src/particles/kernels/field.wgsl @@ -1,3 +1,5 @@ +import processing::particles::falloff; + struct Params { center: vec3, radius: f32, @@ -25,21 +27,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { var w: f32 = 0.0; if d2 < r2 { - let d = sqrt(d2); - let n = 1.0 - d / params.radius; - w = 1.0; - if params.falloff_mode == 1u { - w = n; - } else if params.falloff_mode == 2u { - w = n * n * (3.0 - 2.0 * n); - } else if params.falloff_mode == 3u { - w = n * n; - } else if params.falloff_mode == 4u { - w = n * n * n; - } else if params.falloff_mode == 5u { - w = params.radius / (d + params.radius); - } + w = falloff(sqrt(d2), params.radius, params.falloff_mode); } - weight[i] = w; } diff --git a/crates/processing_render/src/particles/kernels/flock.wgsl b/crates/processing_render/src/particles/kernels/flock.wgsl index c2d6311a..56040c17 100644 --- a/crates/processing_render/src/particles/kernels/flock.wgsl +++ b/crates/processing_render/src/particles/kernels/flock.wgsl @@ -1,4 +1,6 @@ -struct Params { +import processing::particles::{cell_coords, cell_index}; + +struct FlockParams { sep_distance: f32, neighbor_distance: f32, weight_separation: f32, @@ -9,13 +11,21 @@ struct Params { min_speed: f32, } -@group(0) @binding(0) var position: array; -@group(0) @binding(1) var velocity: array; -@group(0) @binding(2) var params: Params; +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} -const TILE: u32 = 64u; -var s_pos: array, 64>; -var s_vel: array, 64>; +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var offsets: array; +@group(0) @binding(3) var sorted: array; +@group(0) @binding(4) var fp: FlockParams; +@group(0) @binding(5) var gp: GridParams; fn limit_mag(v: vec3, m: f32) -> vec3 { let len2 = dot(v, v); @@ -29,25 +39,31 @@ fn steer_toward(desired: vec3, vel: vec3, max_speed: f32, max_force: f return limit_mag(desired * (max_speed * inverseSqrt(m2)) - vel, max_force); } +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +fn load_vel(i: u32) -> vec3 { + return vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); +} + @compute @workgroup_size(64) -fn main( - @builtin(global_invocation_id) gid: vec3, - @builtin(local_invocation_id) lid: vec3, -) { +fn main(@builtin(global_invocation_id) gid: vec3) { let i = gid.x; let count = arrayLength(&position) / 3u; - let alive = i < count; + if i >= count { return; } - let sep_d2 = params.sep_distance * params.sep_distance; - let neighbor_d2 = params.neighbor_distance * params.neighbor_distance; + let pos = load_pos(i); + let vel = load_vel(i); - var pos = vec3(0.0); - var vel = vec3(0.0); - if alive { - let pi = i * 3u; - pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); - vel = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); - } + let sep_d2 = fp.sep_distance * fp.sep_distance; + let neighbor_d2 = fp.neighbor_distance * fp.neighbor_distance; + + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + let bx = base.x; + let by = base.y; + let bz = base.z; var sep_steer = vec3(0.0); var sep_count = 0u; @@ -55,73 +71,64 @@ fn main( var coh_sum = vec3(0.0); var flock_count = 0u; - let num_tiles = (count + TILE - 1u) / TILE; - - for (var t = 0u; t < num_tiles; t++) { - let j = t * TILE + lid.x; - if j < count { - let pj = j * 3u; - s_pos[lid.x] = vec3(position[pj], position[pj + 1u], position[pj + 2u]); - s_vel[lid.x] = vec3(velocity[pj], velocity[pj + 1u], velocity[pj + 2u]); - } - workgroupBarrier(); - - if alive { - let tile_end = min(TILE, count - t * TILE); - for (var k = 0u; k < tile_end; k++) { - let global_j = t * TILE + k; - if global_j == i { continue; } - - let diff = pos - s_pos[k]; - let d2 = dot(diff, diff); - - if d2 > 0.000001 && d2 < neighbor_d2 { - if d2 < sep_d2 { - sep_steer += diff / d2; - sep_count += 1u; + for (var dz = -1; dz <= 1; dz++) { + let cz = bz + dz; + if cz < 0 || cz >= i32(gp.dims_z) { continue; } + for (var dy = -1; dy <= 1; dy++) { + let cy = by + dy; + if cy < 0 || cy >= i32(gp.dims_y) { continue; } + for (var dx = -1; dx <= 1; dx++) { + let cx = bx + dx; + if cx < 0 || cx >= i32(gp.dims_x) { continue; } + + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + if j == i { continue; } + + let diff = pos - load_pos(j); + let d2 = dot(diff, diff); + if d2 > 0.000001 && d2 < neighbor_d2 { + if d2 < sep_d2 { + sep_steer += diff / d2; + sep_count += 1u; + } + ali_sum += load_vel(j); + coh_sum += diff; + flock_count += 1u; } - ali_sum += s_vel[k]; - coh_sum += diff; - flock_count += 1u; } } } - workgroupBarrier(); } - if !alive { return; } - var force = vec3(0.0); if sep_count > 0u { force += steer_toward(sep_steer / f32(sep_count), vel, - params.max_speed, params.max_force) * params.weight_separation; + fp.max_speed, fp.max_force) * fp.weight_separation; } if flock_count > 0u { force += steer_toward(ali_sum / f32(flock_count), vel, - params.max_speed, params.max_force) * params.weight_alignment; + fp.max_speed, fp.max_force) * fp.weight_alignment; force += steer_toward(-coh_sum / f32(flock_count), vel, - params.max_speed, params.max_force) * params.weight_cohesion; + fp.max_speed, fp.max_force) * fp.weight_cohesion; } var new_vel = vel + force; let speed2 = dot(new_vel, new_vel); - let max_speed_sq = params.max_speed * params.max_speed; + let max_speed_sq = fp.max_speed * fp.max_speed; if speed2 > max_speed_sq { - new_vel = new_vel * (params.max_speed * inverseSqrt(speed2)); - } else if params.min_speed > 0.0 { - let min_speed_sq = params.min_speed * params.min_speed; + new_vel = new_vel * (fp.max_speed * inverseSqrt(speed2)); + } else if fp.min_speed > 0.0 { + let min_speed_sq = fp.min_speed * fp.min_speed; if speed2 < min_speed_sq && speed2 > 0.0 { new_vel = new_vel * sqrt(min_speed_sq / speed2); } } - let new_pos = pos + new_vel; - - let pi = i * 3u; - position[pi] = new_pos.x; - position[pi + 1u] = new_pos.y; - position[pi + 2u] = new_pos.z; - velocity[pi] = new_vel.x; - velocity[pi + 1u] = new_vel.y; - velocity[pi + 2u] = new_vel.z; + velocity[i * 3u] = new_vel.x; + velocity[i * 3u + 1u] = new_vel.y; + velocity[i * 3u + 2u] = new_vel.z; } diff --git a/crates/processing_render/src/particles/kernels/grid_clear.wgsl b/crates/processing_render/src/particles/kernels/grid_clear.wgsl new file mode 100644 index 00000000..b59f5061 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_clear.wgsl @@ -0,0 +1,8 @@ +@group(0) @binding(0) var counts: array; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&counts) { return; } + counts[i] = 0u; +} diff --git a/crates/processing_render/src/particles/kernels/grid_copy.wgsl b/crates/processing_render/src/particles/kernels/grid_copy.wgsl new file mode 100644 index 00000000..8e5f9991 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_copy.wgsl @@ -0,0 +1,9 @@ +@group(0) @binding(0) var starts: array; +@group(0) @binding(1) var cursor: array; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&cursor) { return; } + cursor[i] = starts[i]; +} diff --git a/crates/processing_render/src/particles/kernels/grid_count.wgsl b/crates/processing_render/src/particles/kernels/grid_count.wgsl new file mode 100644 index 00000000..949ed6e8 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_count.wgsl @@ -0,0 +1,24 @@ +import processing::particles::cell_of; + +struct Params { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var counts: array>; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&position) / 3u; + if i >= n { return; } + let p = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let dims = vec3(params.dims_x, params.dims_y, params.dims_z); + atomicAdd(&counts[cell_of(p, params.grid_min, params.cell_size, dims)], 1u); +} diff --git a/crates/processing_render/src/particles/kernels/grid_scatter.wgsl b/crates/processing_render/src/particles/kernels/grid_scatter.wgsl new file mode 100644 index 00000000..98be9ace --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_scatter.wgsl @@ -0,0 +1,26 @@ +import processing::particles::cell_of; + +struct Params { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var cursor: array>; +@group(0) @binding(2) var sorted: array; +@group(0) @binding(3) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&position) / 3u; + if i >= n { return; } + let p = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let dims = vec3(params.dims_x, params.dims_y, params.dims_z); + let slot = atomicAdd(&cursor[cell_of(p, params.grid_min, params.cell_size, dims)], 1u); + sorted[slot] = i; +} diff --git a/crates/processing_render/src/particles/kernels/impulse.wgsl b/crates/processing_render/src/particles/kernels/impulse.wgsl index 3368d748..0d6d1431 100644 --- a/crates/processing_render/src/particles/kernels/impulse.wgsl +++ b/crates/processing_render/src/particles/kernels/impulse.wgsl @@ -1,3 +1,5 @@ +import processing::particles::falloff; + struct Params { center: vec3, radius: f32, @@ -27,19 +29,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d = sqrt(d2); let dir = diff / d; - var fall: f32 = 1.0; - let n = 1.0 - d / params.radius; - if params.falloff_mode == 1u { - fall = n; - } else if params.falloff_mode == 2u { - fall = n * n * (3.0 - 2.0 * n); - } else if params.falloff_mode == 3u { - fall = n * n; - } else if params.falloff_mode == 4u { - fall = n * n * n; - } else if params.falloff_mode == 5u { - fall = params.radius / (d + params.radius); - } + let fall = falloff(d, params.radius, params.falloff_mode); let pos_push = dir * (params.position_kick * fall); let vel_push = dir * (params.velocity_kick * fall); diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs index 357461e6..a552af51 100644 --- a/crates/processing_render/src/particles/kernels/mod.rs +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -52,6 +52,17 @@ impl Plugin for ParticlesKernelsPlugin { embedded_asset!(app, "attr_lookup2d.wgsl"); embedded_asset!(app, "scatter_surface.wgsl"); embedded_asset!(app, "scatter_volume.wgsl"); + embedded_asset!(app, "scan_block.wgsl"); + embedded_asset!(app, "scan_add.wgsl"); + embedded_asset!(app, "grid_clear.wgsl"); + embedded_asset!(app, "grid_count.wgsl"); + embedded_asset!(app, "grid_copy.wgsl"); + embedded_asset!(app, "grid_scatter.wgsl"); + embedded_asset!(app, "bitonic.wgsl"); + embedded_asset!(app, "compact_flag.wgsl"); + embedded_asset!(app, "compact_scatter.wgsl"); + embedded_asset!(app, "reduce.wgsl"); + embedded_asset!(app, "neighbor.wgsl"); } } @@ -279,6 +290,7 @@ pub fn particles_kernel_field() -> error::Result { Ok(entity) } +// Bind `op*` buffers directly, not named attributes, so they declare no requires. pub fn particles_kernel_attr_linear() -> error::Result { let shader = shader_load("embedded://processing_render/particles/kernels/attr_linear.wgsl")?; let entity = compute_create(shader)?; diff --git a/crates/processing_render/src/particles/kernels/neighbor.wgsl b/crates/processing_render/src/particles/kernels/neighbor.wgsl new file mode 100644 index 00000000..966b412f --- /dev/null +++ b/crates/processing_render/src/particles/kernels/neighbor.wgsl @@ -0,0 +1,94 @@ +import processing::particles::{cell_coords, cell_index, falloff}; + +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +struct Params { + radius: f32, + op: u32, + falloff_mode: u32, + components: u32, +} + +const OP_SUM: u32 = 0u; +const OP_MEAN: u32 = 1u; +const OP_COUNT: u32 = 2u; + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var source: array; +@group(0) @binding(2) var out: array; +@group(0) @binding(3) var offsets: array; +@group(0) @binding(4) var sorted: array; +@group(0) @binding(5) var params: Params; +@group(0) @binding(6) var gp: GridParams; + +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +@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 pos = load_pos(i); + let r2 = params.radius * params.radius; + let comps = params.components; + + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + + var value = array(0.0, 0.0, 0.0, 0.0); + var weight_sum = 0.0; + + for (var dz = -1; dz <= 1; dz++) { + let cz = base.z + dz; + if cz < 0 || cz >= i32(gp.dims_z) { continue; } + for (var dy = -1; dy <= 1; dy++) { + let cy = base.y + dy; + if cy < 0 || cy >= i32(gp.dims_y) { continue; } + for (var dx = -1; dx <= 1; dx++) { + let cx = base.x + dx; + if cx < 0 || cx >= i32(gp.dims_x) { continue; } + + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + let diff = pos - load_pos(j); + let d2 = dot(diff, diff); + if d2 <= r2 { + let w = falloff(sqrt(d2), params.radius, params.falloff_mode); + weight_sum += w; + if params.op != OP_COUNT { + for (var c = 0u; c < comps; c++) { + value[c] += w * source[j * comps + c]; + } + } + } + } + } + } + } + + if params.op == OP_COUNT { + out[i] = weight_sum; + } else if params.op == OP_MEAN { + let inv = select(0.0, 1.0 / weight_sum, weight_sum > 0.0); + for (var c = 0u; c < comps; c++) { + out[i * comps + c] = value[c] * inv; + } + } else { + for (var c = 0u; c < comps; c++) { + out[i * comps + c] = value[c]; + } + } +} diff --git a/crates/processing_render/src/particles/kernels/reduce.wgsl b/crates/processing_render/src/particles/kernels/reduce.wgsl new file mode 100644 index 00000000..9d0f97e4 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/reduce.wgsl @@ -0,0 +1,52 @@ +struct Params { + mode: u32, + count: u32, +} + +const BLOCK: u32 = 256u; + +@group(0) @binding(0) var input: array; +@group(0) @binding(1) var output: array; +@group(0) @binding(2) var params: Params; + +var sdata: array; + +fn ident(mode: u32) -> f32 { + if mode == 1u { return 3.4e38; } + if mode == 2u { return -3.4e38; } + return 0.0; +} + +fn combine(a: f32, b: f32, mode: u32) -> f32 { + if mode == 1u { return min(a, b); } + if mode == 2u { return max(a, b); } + return a + b; +} + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + var v = ident(params.mode); + if gid.x < params.count { + v = input[gid.x]; + } + sdata[lid.x] = v; + workgroupBarrier(); + + var stride = BLOCK / 2u; + loop { + if stride == 0u { break; } + if lid.x < stride { + sdata[lid.x] = combine(sdata[lid.x], sdata[lid.x + stride], params.mode); + } + workgroupBarrier(); + stride = stride >> 1u; + } + + if lid.x == 0u { + output[wid.x] = sdata[0]; + } +} diff --git a/crates/processing_render/src/particles/kernels/scan_add.wgsl b/crates/processing_render/src/particles/kernels/scan_add.wgsl new file mode 100644 index 00000000..918d72d4 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scan_add.wgsl @@ -0,0 +1,13 @@ +@group(0) @binding(0) var data: array; +@group(0) @binding(1) var block_sums: array; + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + let n = arrayLength(&data); + let g = gid.x; + if g >= n { return; } + data[g] = data[g] + block_sums[wid.x]; +} diff --git a/crates/processing_render/src/particles/kernels/scan_block.wgsl b/crates/processing_render/src/particles/kernels/scan_block.wgsl new file mode 100644 index 00000000..aea9824a --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scan_block.wgsl @@ -0,0 +1,34 @@ +const BLOCK: u32 = 256u; + +@group(0) @binding(0) var data: array; +@group(0) @binding(1) var block_sums: array; + +var tmp: array; + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + let n = arrayLength(&data); + let g = gid.x; + let l = lid.x; + + var v: u32 = 0u; + if g < n { v = data[g]; } + tmp[l] = v; + workgroupBarrier(); + + for (var offset: u32 = 1u; offset < BLOCK; offset = offset << 1u) { + var add: u32 = 0u; + if l >= offset { add = tmp[l - offset]; } + workgroupBarrier(); + tmp[l] = tmp[l] + add; + workgroupBarrier(); + } + + if g < n { data[g] = tmp[l] - v; } + + if l == 0u { block_sums[wid.x] = tmp[BLOCK - 1u]; } +} diff --git a/crates/processing_render/src/particles/kernels/vortex.wgsl b/crates/processing_render/src/particles/kernels/vortex.wgsl index 59ca61e4..48a899fa 100644 --- a/crates/processing_render/src/particles/kernels/vortex.wgsl +++ b/crates/processing_render/src/particles/kernels/vortex.wgsl @@ -1,3 +1,5 @@ +import processing::particles::falloff; + struct Params { center: vec3, _pad0: f32, @@ -36,19 +38,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let r = sqrt(r2); let tangent = cross(axis, radial / r); - var fall: f32 = 1.0; - let n = 1.0 - r / params.radius; - if params.falloff_mode == 1u { - fall = n; - } else if params.falloff_mode == 2u { - fall = n * n * (3.0 - 2.0 * n); - } else if params.falloff_mode == 3u { - fall = n * n; - } else if params.falloff_mode == 4u { - fall = n * n * n; - } else if params.falloff_mode == 5u { - fall = params.radius / (r + params.radius); - } + let fall = falloff(r, params.radius, params.falloff_mode); let kick = tangent * (params.strength * fall); velocity[pi] = velocity[pi] + kick.x; diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs index 577f2261..38034d73 100644 --- a/crates/processing_render/src/particles/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -1,12 +1,29 @@ //! See `docs/particles.md`. +pub mod algebra; +pub mod compact; mod emit; +pub mod grid; pub mod kernels; pub mod material; pub mod pack; +pub mod point_render; +pub mod reduce; +pub mod scan; mod scatter; +pub mod sort; -pub use emit::{particles_apply, particles_emit, particles_emit_gpu}; +pub use algebra::{ + GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM, MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, + MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, combine, extract, + generate, lookup, map, mix, pack, reduce_components, +}; +pub use compact::compact; +pub use emit::{ + particles_apply, particles_emit, particles_emit_gpu, particles_flock, particles_gather, +}; +pub use grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; pub use kernels::{ BOUNDS_CLAMP, BOUNDS_REFLECT, BOUNDS_SOFT, BOUNDS_WRAP, COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, FALLOFF_CONST, FALLOFF_CUBIC, @@ -18,13 +35,16 @@ pub use kernels::{ particles_kernel_impulse, particles_kernel_integrate, particles_kernel_noise, particles_kernel_orient, particles_kernel_transform, particles_kernel_vortex, }; +pub use reduce::{REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce}; +pub use scan::prefix_sum_u32; pub use scatter::{ particles_scatter_create, particles_scatter_volume_create, prepare_scatter_source, prepare_scatter_volume_source, }; +pub use sort::bitonic_sort_by_key; use bevy::asset::RenderAssetUsages; -use bevy::mesh::VertexAttributeValues; +use bevy::mesh::{Indices, VertexAttributeValues}; use bevy::pbr::gpu_instance_batch::GpuInstanceBatchPlugin; use bevy::platform::collections::HashMap; use bevy::prelude::*; @@ -48,6 +68,7 @@ impl Plugin for ParticlesPlugin { app.add_plugins(pack::ParticlesPackPlugin); app.add_plugins(material::ParticlesMaterialPlugin); app.add_plugins(kernels::ParticlesKernelsPlugin); + app.add_plugins(point_render::ParticlesPointRenderPlugin); } fn finish(&self, app: &mut App) { @@ -71,10 +92,18 @@ pub struct Particles { /// Must outlive the per-frame draw: `GpuInstanceBatchReservations` queues /// mesh batches one frame behind, so respawning per-frame loses the reservation. pub draw_entity: Option, + pub raster_draw_entity: Option, + pub connectivity: Option, /// Ring-buffer write cursor; wraps at `capacity`. pub emit_head: u32, } +#[derive(Clone, Copy)] +pub struct Connectivity { + pub index_buffer: Entity, + pub indirect_buffer: Entity, +} + impl Particles { pub fn buffer(&self, attribute: Entity) -> Option { self.buffers.get(&attribute).copied() @@ -98,13 +127,18 @@ pub fn create( 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_buffer( - &mut commands, - &mut shader_buffers, - &render_device, - &vec![0u8; byte_size as usize], - ); + let per_element = convention_seed_bytes(attr.name, attr.format); + let elem_size = attr.format.byte_size(); + if per_element.len() != elem_size { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{}' reuses a builtin name with an incompatible format; \ + declare it with an explicit default", + attr.name + ))); + } + let initial = tile_seed(&per_element, capacity as usize); + let buffer_entity = + make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); buffers.insert(attr_entity, buffer_entity); } @@ -113,6 +147,8 @@ pub fn create( capacity, buffers, draw_entity: None, + raster_draw_entity: None, + connectivity: None, emit_head: 0, }) .id(); @@ -147,32 +183,137 @@ pub fn create_from_geometry( .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]); + .unwrap_or_else(|| { + let per_element = convention_seed_bytes(attr.name, attr.format); + if per_element.len() == attr.format.byte_size() { + tile_seed(&per_element, capacity as usize) + } else { + vec![0u8; byte_size as usize] + } + }); let buffer_entity = make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); buffers.insert(attr_entity, buffer_entity); } + let connectivity = mesh.indices().map(|indices| { + let index_data: Vec = match indices { + Indices::U16(v) => v.iter().map(|&i| i as u32).collect(), + Indices::U32(v) => v.clone(), + }; + let index_buffer = make_buffer_with_usage( + &mut commands, + &mut shader_buffers, + &render_device, + &u32s_to_bytes(&index_data), + BufferUsages::INDEX, + ); + let args = [index_data.len() as u32, 1, 0, 0, 0]; + let indirect_buffer = make_buffer_with_usage( + &mut commands, + &mut shader_buffers, + &render_device, + &u32s_to_bytes(&args), + BufferUsages::INDIRECT, + ); + Connectivity { + index_buffer, + indirect_buffer, + } + }); + let entity = commands .spawn(Particles { capacity, buffers, draw_entity: None, + raster_draw_entity: None, + connectivity, emit_head: 0, }) .id(); Ok(entity) } +pub fn particles_set_connectivity( + particles_entity: Entity, + index_count: u32, +) -> error::Result { + use bevy::render::render_resource::BufferUsages; + + let index_buffer = + crate::buffer_create_with_usage(index_count as u64 * 4, BufferUsages::INDEX)?; + let indirect_buffer = + crate::buffer_create_with_usage(20, BufferUsages::INDIRECT | BufferUsages::STORAGE)?; + crate::buffer_write(indirect_buffer, u32s_to_bytes(&[index_count, 1, 0, 0, 0]))?; + + let previous = app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + Ok(field.connectivity.replace(Connectivity { + index_buffer, + indirect_buffer, + })) + })?; + if let Some(previous) = previous { + crate::buffer_destroy(previous.index_buffer)?; + crate::buffer_destroy(previous.indirect_buffer)?; + } + Ok(index_buffer) +} + +pub fn particles_connectivity_indirect(particles_entity: Entity) -> error::Result { + app_mut(|app| { + let field = app + .world() + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field + .connectivity + .as_ref() + .map(|c| c.indirect_buffer) + .ok_or_else(|| { + error::ProcessingError::InvalidArgument( + "particles have no index buffer; call index_buffer() first".to_string(), + ) + }) + }) +} + +pub fn particles_reset_indices(particles_entity: Entity) -> error::Result<()> { + let indirect = particles_connectivity_indirect(particles_entity)?; + crate::buffer_write(indirect, u32s_to_bytes(&[0, 1, 0, 0, 0])) +} + fn make_buffer( commands: &mut Commands, shader_buffers: &mut Assets, render_device: &RenderDevice, initial: &[u8], +) -> Entity { + make_buffer_with_usage( + commands, + shader_buffers, + render_device, + initial, + BufferUsages::empty(), + ) +} + +fn make_buffer_with_usage( + commands: &mut Commands, + shader_buffers: &mut Assets, + render_device: &RenderDevice, + initial: &[u8], + extra_usage: BufferUsages, ) -> Entity { let byte_size = initial.len() as u64; - let handle = shader_buffers.add(ShaderBuffer::new(initial, RenderAssetUsages::all())); + let mut shader_buffer = ShaderBuffer::new(initial, RenderAssetUsages::all()); + shader_buffer.buffer_description.usage |= extra_usage; + let handle = shader_buffers.add(shader_buffer); let readback = render_device.create_buffer(&BufferDescriptor { label: Some("Particles Buffer Readback"), size: byte_size, @@ -190,6 +331,14 @@ fn make_buffer( .id() } +fn u32s_to_bytes(values: &[u32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for &value in values { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + fn attribute_values_to_bytes( values: &VertexAttributeValues, format: AttributeFormat, @@ -231,6 +380,13 @@ pub fn destroy( if let Some(draw_entity) = p.draw_entity { commands.entity(draw_entity).despawn(); } + if let Some(raster_draw_entity) = p.raster_draw_entity { + commands.entity(raster_draw_entity).despawn(); + } + if let Some(connectivity) = p.connectivity { + commands.entity(connectivity.index_buffer).despawn(); + commands.entity(connectivity.indirect_buffer).despawn(); + } commands.entity(entity).despawn(); Ok(()) } diff --git a/crates/processing_render/src/particles/point.wgsl b/crates/processing_render/src/particles/point.wgsl new file mode 100644 index 00000000..14d418ed --- /dev/null +++ b/crates/processing_render/src/particles/point.wgsl @@ -0,0 +1,55 @@ +#import bevy_render::view::View + +@group(0) @binding(0) var view: View; +@group(1) @binding(0) var positions: array; +#ifdef HAS_COLORS +@group(1) @binding(1) var colors: array; +#endif +#ifdef HAS_NORMALS +@group(1) @binding(2) var normals: array; +#endif + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) world: vec3, + @location(1) color: vec4, + @location(2) normal: vec3, +} + +@vertex +fn vertex(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let i = vertex_index; + let world = vec3(positions[i * 3u], positions[i * 3u + 1u], positions[i * 3u + 2u]); + + var out: VertexOutput; + out.world = world; + out.clip_position = view.clip_from_world * vec4(world, 1.0); +#ifdef HAS_COLORS + out.color = vec4( + colors[i * 4u], colors[i * 4u + 1u], colors[i * 4u + 2u], colors[i * 4u + 3u]); +#else + out.color = vec4(1.0, 1.0, 1.0, 1.0); +#endif +#ifdef HAS_NORMALS + out.normal = vec3(normals[i * 3u], normals[i * 3u + 1u], normals[i * 3u + 2u]); +#else + out.normal = vec3(0.0, 0.0, 0.0); +#endif + return out; +} + +@fragment +fn fragment(frag: VertexOutput) -> @location(0) vec4 { + var rgb = frag.color.rgb; +#ifdef SHADED + #ifdef HAS_NORMALS + let normal = normalize(frag.normal); + #else + let normal = normalize(cross(dpdx(frag.world), dpdy(frag.world))); + #endif + let light = normalize(vec3(0.4, 0.85, 0.35)); + let diffuse = abs(dot(normal, light)); + rgb = rgb * (0.2 + 0.8 * diffuse); +#endif + return vec4(rgb, frag.color.a); +} diff --git a/crates/processing_render/src/particles/point_render.rs b/crates/processing_render/src/particles/point_render.rs new file mode 100644 index 00000000..2e8db841 --- /dev/null +++ b/crates/processing_render/src/particles/point_render.rs @@ -0,0 +1,482 @@ +use bevy::asset::embedded_asset; +use bevy::camera::visibility::{self, VisibilityClass}; +use bevy::core_pipeline::core_3d::{ + CORE_3D_DEPTH_FORMAT, Opaque3d, Opaque3dBatchSetKey, Opaque3dBinKey, Transparent3d, + TransparentSortingInfo3d, +}; +use bevy::ecs::query::ROQueryItem; +use bevy::ecs::system::SystemParamItem; +use bevy::ecs::system::lifetimeless::{Read, SRes}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin}; +use bevy::render::mesh::allocator::MeshSlabs; +use bevy::render::render_asset::RenderAssets; +use bevy::render::render_phase::{ + AddRenderCommand, BinnedRenderPhaseType, DrawFunctions, InputUniformIndex, PhaseItem, + PhaseItemExtraIndex, RenderCommand, RenderCommandResult, SetItemPipeline, TrackedRenderPass, + ViewBinnedRenderPhases, ViewSortedRenderPhases, +}; +use bevy::render::render_resource::binding_types::{ + storage_buffer_read_only_sized, uniform_buffer, +}; +use bevy::render::render_resource::{ + BindGroup, BindGroupEntries, BindGroupLayout, BindGroupLayoutDescriptor, + BindGroupLayoutEntries, BlendState, Buffer, Canonical, ColorTargetState, ColorWrites, + CompareFunction, DepthStencilState, FragmentState, IndexFormat, PipelineCache, PrimitiveState, + PrimitiveTopology, RenderPipeline, RenderPipelineDescriptor, ShaderStages, Specializer, + SpecializerKey, TextureFormat, Variants, VertexState, +}; +use bevy::render::renderer::RenderDevice; +use bevy::render::storage::GpuShaderBuffer; +use bevy::render::sync_world::MainEntity; +use bevy::render::view::{ + ExtractedView, RenderVisibleEntities, ViewUniform, ViewUniformOffset, ViewUniforms, +}; +use bevy::render::{Render, RenderApp, RenderSystems}; +use bevy::shader::Shader; + +use bevy::render::storage::ShaderBuffer; + +use crate::geometry::Topology; + +const SURFACE_FORMAT: TextureFormat = TextureFormat::Rgba16Float; + +pub struct ParticlesPointRenderPlugin; + +impl Plugin for ParticlesPointRenderPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "point.wgsl"); + app.add_plugins(ExtractComponentPlugin::::default()); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .init_resource::() + .add_render_command::() + .add_render_command::() + .add_systems( + Render, + prepare_raster_bind_groups.in_set(RenderSystems::PrepareBindGroups), + ) + .add_systems(Render, queue_particle_raster.in_set(RenderSystems::Queue)); + } + + fn finish(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +#[derive(Component, Clone, ExtractComponent)] +#[require(VisibilityClass)] +#[component(on_add = visibility::add_visibility_class::)] +pub struct ParticleRasterDraw { + pub position: Handle, + pub count: u32, + pub topology: Topology, + pub index: Option>, + pub indirect: Option>, + pub color: Option>, + pub normal: Option>, + /// The sketch's blend mode at draw time (`None` = opaque). + pub blend: Option, +} + +#[derive(Resource)] +struct ParticleRasterPipeline { + view_layout: BindGroupLayout, + storage_layout: BindGroupLayout, + variants: Variants, +} + +struct RasterSpecializer; + +#[derive(Copy, Clone, PartialEq, Eq, Hash, SpecializerKey)] +struct RasterKey { + samples: u32, + topology: u8, + has_color: bool, + has_normal: bool, + blend: Option, + format: TextureFormat, +} + +impl Specializer for RasterSpecializer { + type Key = RasterKey; + fn specialize( + &self, + key: Self::Key, + descriptor: &mut RenderPipelineDescriptor, + ) -> Result, BevyError> { + descriptor.multisample.count = key.samples; + let topology = Topology::from_u8(key.topology).unwrap_or(Topology::PointList); + descriptor.primitive.topology = topology.to_primitive_topology(); + + let mut defs: Vec<&str> = Vec::new(); + if key.has_color { + defs.push("HAS_COLORS"); + } + if key.has_normal { + defs.push("HAS_NORMALS"); + } + if matches!(topology, Topology::TriangleList | Topology::TriangleStrip) { + defs.push("SHADED"); + } + for def in defs { + descriptor.vertex.shader_defs.push(def.into()); + if let Some(fragment) = descriptor.fragment.as_mut() { + fragment.shader_defs.push(def.into()); + } + } + + if let Some(target) = descriptor + .fragment + .as_mut() + .and_then(|f| f.targets.get_mut(0)) + .and_then(|t| t.as_mut()) + { + target.format = key.format; + target.blend = key.blend; + } + if key.blend.is_some() { + if let Some(depth) = descriptor.depth_stencil.as_mut() { + depth.depth_write_enabled = Some(false); + } + } + Ok(key) + } +} + +impl FromWorld for ParticleRasterPipeline { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::().clone(); + let asset_server = world.resource::(); + let shader: Handle = + asset_server.load("embedded://processing_render/particles/point.wgsl"); + + let view_entries: Vec<_> = BindGroupLayoutEntries::single( + ShaderStages::VERTEX, + uniform_buffer::(true), + ) + .to_vec(); + let view_layout = + render_device.create_bind_group_layout("point_view_layout", &view_entries); + let storage_entries: Vec<_> = BindGroupLayoutEntries::sequential( + ShaderStages::VERTEX, + ( + storage_buffer_read_only_sized(false, None), + storage_buffer_read_only_sized(false, None), + storage_buffer_read_only_sized(false, None), + ), + ) + .to_vec(); + let storage_layout = + render_device.create_bind_group_layout("point_storage_layout", &storage_entries); + + let base_descriptor = RenderPipelineDescriptor { + label: Some("particle_raster_pipeline".into()), + layout: vec![ + BindGroupLayoutDescriptor { + label: "point_view_layout".into(), + entries: view_entries, + }, + BindGroupLayoutDescriptor { + label: "point_storage_layout".into(), + entries: storage_entries, + }, + ], + vertex: VertexState { + shader: shader.clone(), + entry_point: Some("vertex".into()), + buffers: vec![], + ..default() + }, + fragment: Some(FragmentState { + shader, + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format: SURFACE_FORMAT, + blend: None, + write_mask: ColorWrites::ALL, + })], + ..default() + }), + primitive: PrimitiveState { + topology: PrimitiveTopology::PointList, + ..default() + }, + depth_stencil: Some(DepthStencilState { + format: CORE_3D_DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(CompareFunction::GreaterEqual), + stencil: default(), + bias: default(), + }), + ..default() + }; + + Self { + view_layout, + storage_layout, + variants: Variants::new(RasterSpecializer, base_descriptor), + } + } +} + +struct RasterEntry { + storage_bg: BindGroup, + count: u32, + index: Option, + indirect: Option, +} + +#[derive(Resource, Default)] +struct RasterBindGroups { + view: Option, + entries: HashMap, +} + +fn prepare_raster_bind_groups( + render_device: Res, + pipeline: Res, + view_uniforms: Res, + gpu_buffers: Res>, + draws: Query<(&MainEntity, &ParticleRasterDraw)>, + mut bind_groups: ResMut, +) { + bind_groups.view = view_uniforms.uniforms.binding().map(|binding| { + render_device.create_bind_group( + "point_view_bind_group", + &pipeline.view_layout, + &BindGroupEntries::single(binding), + ) + }); + + bind_groups.entries.clear(); + for (main_entity, draw) in draws.iter() { + let Some(position) = gpu_buffers.get(&draw.position) else { + continue; + }; + let (index, indirect) = match (&draw.index, &draw.indirect) { + (Some(index_handle), Some(indirect_handle)) => { + let (Some(index_gpu), Some(indirect_gpu)) = ( + gpu_buffers.get(index_handle), + gpu_buffers.get(indirect_handle), + ) else { + continue; + }; + ( + Some(index_gpu.buffer.clone()), + Some(indirect_gpu.buffer.clone()), + ) + } + _ => (None, None), + }; + let color_buffer = match &draw.color { + Some(handle) => match gpu_buffers.get(handle) { + Some(gpu) => &gpu.buffer, + None => continue, + }, + None => &position.buffer, + }; + let normal_buffer = match &draw.normal { + Some(handle) => match gpu_buffers.get(handle) { + Some(gpu) => &gpu.buffer, + None => continue, + }, + None => &position.buffer, + }; + let storage_bg = render_device.create_bind_group( + "point_storage_bind_group", + &pipeline.storage_layout, + &BindGroupEntries::sequential(( + position.buffer.as_entire_binding(), + color_buffer.as_entire_binding(), + normal_buffer.as_entire_binding(), + )), + ); + bind_groups.entries.insert( + *main_entity, + RasterEntry { + storage_bg, + count: draw.count, + index, + indirect, + }, + ); + } +} + +fn queue_particle_raster( + pipeline_cache: Res, + mut pipeline: ResMut, + mut opaque_phases: ResMut>, + mut transparent_phases: ResMut>, + opaque_draw_functions: Res>, + transparent_draw_functions: Res>, + views: Query<(&ExtractedView, &RenderVisibleEntities, &Msaa)>, + raster_draws: Query<&ParticleRasterDraw>, +) { + let opaque_draw = opaque_draw_functions + .read() + .id::(); + let transparent_draw = transparent_draw_functions + .read() + .id::(); + + for (view, visible, msaa) in views.iter() { + let Some(visible_raster) = visible.get::() else { + continue; + }; + let mut opaque = opaque_phases.get_mut(&view.retained_view_entity); + let mut transparent = transparent_phases.get_mut(&view.retained_view_entity); + + if let Some(op) = opaque.as_deref_mut() { + for (_, main_entity) in &visible_raster.removed_entities { + op.remove(*main_entity); + } + } + for (render_entity, main_entity) in visible_raster.iter_visible() { + if let Some(op) = opaque.as_deref_mut() { + op.remove(*main_entity); + } + + let draw = raster_draws.get(*render_entity).ok(); + let topology = draw.map(|d| d.topology).unwrap_or(Topology::PointList); + let blended = draw.is_some_and(|d| d.blend.is_some()); + let indexed = draw.is_some_and(|d| d.index.is_some()); + + let Ok(pipeline_id) = pipeline.variants.specialize( + &pipeline_cache, + RasterKey { + samples: msaa.samples(), + topology: topology as u8, + has_color: draw.is_some_and(|d| d.color.is_some()), + has_normal: draw.is_some_and(|d| d.normal.is_some()), + blend: draw.and_then(|d| d.blend), + format: view.target_format, + }, + ) else { + continue; + }; + + if blended { + if let Some(tp) = transparent.as_deref_mut() { + tp.add_transient(Transparent3d { + // Sort by the view depth of the particle field's origin. + // NOT AlwaysOnTop: its -inf key sorts BEFORE the + // background quad (whose z-layer distance is a large + // negative batch offset), which painted the background + // over the particles. + sorting_info: TransparentSortingInfo3d::Sorted { + mesh_center: Vec3::ZERO, + depth_bias: 0.0, + }, + distance: 0.0, + pipeline: pipeline_id, + entity: (*render_entity, *main_entity), + draw_function: transparent_draw, + batch_range: 0..1, + extra_index: PhaseItemExtraIndex::None, + indexed, + }); + } + } else if let Some(op) = opaque.as_deref_mut() { + op.add( + Opaque3dBatchSetKey { + draw_function: opaque_draw, + pipeline: pipeline_id, + material_bind_group_index: None, + lightmap_slab: None, + slabs: MeshSlabs::default(), + }, + Opaque3dBinKey { + asset_id: AssetId::::invalid().untyped(), + }, + (*render_entity, *main_entity), + InputUniformIndex::default(), + BinnedRenderPhaseType::NonMesh, + ); + } + } + } +} + +type DrawParticleRasterCommands = ( + SetItemPipeline, + SetRasterViewBindGroup<0>, + SetRasterStorageBindGroup<1>, + DrawParticleRaster, +); + +struct SetRasterViewBindGroup; +impl RenderCommand

for SetRasterViewBindGroup { + type Param = SRes; + type ViewQuery = Read; + type ItemQuery = (); + + fn render<'w>( + _item: &P, + view_offset: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(view_bg) = bind_groups.into_inner().view.as_ref() else { + return RenderCommandResult::Skip; + }; + pass.set_bind_group(I, view_bg, &[view_offset.offset]); + RenderCommandResult::Success + } +} + +struct SetRasterStorageBindGroup; +impl RenderCommand

for SetRasterStorageBindGroup { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = (); + + fn render<'w>( + item: &P, + _view: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(entry) = bind_groups.into_inner().entries.get(&item.main_entity()) else { + return RenderCommandResult::Skip; + }; + pass.set_bind_group(I, &entry.storage_bg, &[]); + RenderCommandResult::Success + } +} + +struct DrawParticleRaster; +impl RenderCommand

for DrawParticleRaster { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = (); + + fn render<'w>( + item: &P, + _view: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(entry) = bind_groups.into_inner().entries.get(&item.main_entity()) else { + return RenderCommandResult::Skip; + }; + match (&entry.index, &entry.indirect) { + (Some(index), Some(indirect)) => { + pass.set_index_buffer(index.slice(..), IndexFormat::Uint32); + pass.draw_indexed_indirect(indirect, 0); + } + _ => pass.draw(0..entry.count, 0..1), + } + RenderCommandResult::Success + } +} diff --git a/crates/processing_render/src/particles/reduce.rs b/crates/processing_render/src/particles/reduce.rs new file mode 100644 index 00000000..f86730c6 --- /dev/null +++ b/crates/processing_render/src/particles/reduce.rs @@ -0,0 +1,83 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_read_element, buffer_size, compute_create, + compute_dispatch, compute_set, shader_load, +}; + +const BLOCK: u32 = 256; +const SHADER: &str = "embedded://processing_render/particles/kernels/reduce.wgsl"; + +pub const REDUCE_OP_SUM: u32 = 0; +pub const REDUCE_OP_MIN: u32 = 1; +pub const REDUCE_OP_MAX: u32 = 2; + +static COMPUTE: Mutex> = Mutex::new(None); +static SCRATCH: Mutex> = Mutex::new(Vec::new()); + +fn reduce_compute() -> Result { + let mut guard = COMPUTE.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = compute_create(shader_load(SHADER)?)?; + *guard = Some(compute); + Ok(compute) +} + +fn scratch(level: usize, bytes: u64) -> Result { + let mut scratch = SCRATCH.lock().unwrap(); + while scratch.len() <= level { + scratch.push((Entity::PLACEHOLDER, 0)); + } + let (entity, size) = scratch[level]; + if entity != Entity::PLACEHOLDER && size >= bytes { + return Ok(entity); + } + if entity != Entity::PLACEHOLDER { + let _ = buffer_destroy(entity); + } + let new_entity = buffer_create(bytes)?; + scratch[level] = (new_entity, bytes); + Ok(new_entity) +} + +pub fn reduce(values: Entity, op: u32) -> Result { + let n = (buffer_size(values)? / 4) as u32; + if n == 0 { + return Ok(match op { + REDUCE_OP_MIN => f32::INFINITY, + REDUCE_OP_MAX => f32::NEG_INFINITY, + _ => 0.0, + }); + } + + let compute = reduce_compute()?; + let mut cur = values; + let mut cur_n = n; + let mut level = 0usize; + + loop { + let num_wg = cur_n.div_ceil(BLOCK); + let out = scratch(level, (num_wg as u64) * 4)?; + + compute_set(compute, "input", ShaderValue::Buffer(cur))?; + compute_set(compute, "output", ShaderValue::Buffer(out))?; + compute_set(compute, "mode", ShaderValue::UInt(op))?; + compute_set(compute, "count", ShaderValue::UInt(cur_n))?; + compute_dispatch(compute, num_wg, 1, 1)?; + + if num_wg == 1 { + let bytes = buffer_read_element(out, 0, 4)?; + return Ok(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])); + } + cur = out; + cur_n = num_wg; + level += 1; + } +} diff --git a/crates/processing_render/src/particles/scan.rs b/crates/processing_render/src/particles/scan.rs new file mode 100644 index 00000000..bab519e7 --- /dev/null +++ b/crates/processing_render/src/particles/scan.rs @@ -0,0 +1,89 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_size, compute_create, compute_dispatch, compute_set, + shader_load, +}; + +const BLOCK: u64 = 256; + +const BLOCK_SHADER: &str = "embedded://processing_render/particles/kernels/scan_block.wgsl"; +const ADD_SHADER: &str = "embedded://processing_render/particles/kernels/scan_add.wgsl"; + +static SCAN_COMPUTES: Mutex> = Mutex::new(None); + +static SCRATCH: Mutex> = Mutex::new(Vec::new()); + +fn scan_computes() -> Result<(Entity, Entity)> { + let mut guard = SCAN_COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let block = compute_create(shader_load(BLOCK_SHADER)?)?; + let add = compute_create(shader_load(ADD_SHADER)?)?; + *guard = Some((block, add)); + Ok((block, add)) +} + +fn scratch_buffer(level: usize, bytes: u64) -> Result { + let mut scratch = SCRATCH.lock().unwrap(); + while scratch.len() <= level { + scratch.push((Entity::PLACEHOLDER, 0)); + } + let (entity, size) = scratch[level]; + if entity != Entity::PLACEHOLDER && size >= bytes { + return Ok(entity); + } + if entity != Entity::PLACEHOLDER { + let _ = buffer_destroy(entity); + } + let new_entity = buffer_create(bytes)?; + scratch[level] = (new_entity, bytes); + Ok(new_entity) +} + +pub fn prefix_sum_u32(buffer: Entity) -> Result<()> { + let total_bytes = buffer_size(buffer)?; + if total_bytes == 0 { + return Ok(()); + } + + let (block, add) = scan_computes()?; + + let n = (total_bytes / 4).max(1); + + let mut level_bufs: Vec = vec![buffer]; + let mut level_ns: Vec = vec![n]; + + let mut lvl = 0usize; + loop { + let num_blocks = level_ns[lvl].div_ceil(BLOCK).max(1); + let sums = scratch_buffer(lvl, num_blocks * 4)?; + + compute_set(block, "data", ShaderValue::Buffer(level_bufs[lvl]))?; + compute_set(block, "block_sums", ShaderValue::Buffer(sums))?; + compute_dispatch(block, num_blocks as u32, 1, 1)?; + + level_bufs.push(sums); + level_ns.push(num_blocks); + + if num_blocks == 1 { + break; + } + lvl += 1; + } + + for k in (0..level_bufs.len() - 2).rev() { + let num_blocks = level_ns[k].div_ceil(BLOCK).max(1); + compute_set(add, "data", ShaderValue::Buffer(level_bufs[k]))?; + compute_set(add, "block_sums", ShaderValue::Buffer(level_bufs[k + 1]))?; + compute_dispatch(add, num_blocks as u32, 1, 1)?; + } + + Ok(()) +} diff --git a/crates/processing_render/src/particles/sort.rs b/crates/processing_render/src/particles/sort.rs new file mode 100644 index 00000000..052558a6 --- /dev/null +++ b/crates/processing_render/src/particles/sort.rs @@ -0,0 +1,58 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::{ProcessingError, Result}; + +use crate::shader_value::ShaderValue; +use crate::{buffer_size, compute_create, compute_dispatch, compute_set, shader_load}; + +const WG: u32 = 64; +const SHADER: &str = "embedded://processing_render/particles/kernels/bitonic.wgsl"; + +static BITONIC: Mutex> = Mutex::new(None); + +fn bitonic_compute() -> Result { + let mut guard = BITONIC.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = compute_create(shader_load(SHADER)?)?; + *guard = Some(compute); + Ok(compute) +} + +pub fn bitonic_sort_by_key(keys: Entity, payload: Entity) -> Result<()> { + let n = (buffer_size(keys)? / 4) as u32; + if n <= 1 { + return Ok(()); + } + if !n.is_power_of_two() { + return Err(ProcessingError::InvalidArgument(format!( + "bitonic_sort_by_key: length {n} must be a power of two" + ))); + } + if buffer_size(payload)? / 4 != n as u64 { + return Err(ProcessingError::InvalidArgument( + "bitonic_sort_by_key: keys and payload must have the same length".to_string(), + )); + } + + let compute = bitonic_compute()?; + let workgroups = n.div_ceil(WG); + + let mut k = 2u32; + while k <= n { + let mut j = k / 2; + while j >= 1 { + compute_set(compute, "keys", ShaderValue::Buffer(keys))?; + compute_set(compute, "payload", ShaderValue::Buffer(payload))?; + compute_set(compute, "k", ShaderValue::UInt(k))?; + compute_set(compute, "j", ShaderValue::UInt(j))?; + compute_dispatch(compute, workgroups, 1, 1)?; + j /= 2; + } + k *= 2; + } + Ok(()) +} diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 0c8773ff..d514982b 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -653,7 +653,8 @@ pub enum DrawCommand { Geometry(Entity), Particles { particles: Entity, - geometry: Entity, + geometry: Option, + topology: crate::geometry::Topology, }, BlendMode(Option), Material(Entity), diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 876c861e..685f6f80 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -7,7 +7,10 @@ pub mod style; pub mod transform; use bevy::{ - camera::{primitives::Aabb, visibility::RenderLayers}, + camera::{ + primitives::Aabb, + visibility::{NoFrustumCulling, RenderLayers}, + }, ecs::system::SystemParam, math::{Affine2, Affine3A, Mat4, Vec3A, Vec4}, pbr::gpu_instance_batch::GpuBatchedMesh3d, @@ -146,12 +149,29 @@ pub fn flush_draw_commands( p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, mut p_particles: Query<&mut Particles>, + p_particle_buffers: Query<&crate::compute::Buffer>, + builtin_attributes: Res, p_fonts: Query<&crate::text::font::Font>, text_cx: Res, + p_raster_draws: Query< + (Entity, &RenderLayers), + Or<( + With, + With, + )>, + >, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() { + for (raster_entity, raster_layers) in p_raster_draws.iter() { + if raster_layers.intersects(render_layers) { + res.commands + .entity(raster_entity) + .insert(Visibility::Hidden); + } + } + let clip_from_view = projection.get_clip_from_view(); let view_from_world = camera_transform.to_matrix().inverse(); let world_from_clip = (clip_from_view * view_from_world).inverse(); @@ -924,7 +944,98 @@ pub fn flush_draw_commands( } DrawCommand::Particles { particles, - geometry, + geometry: None, + topology, + } => { + let Ok(mut particles_data) = p_particles.get_mut(particles) else { + warn!("Could not find Particles for entity {:?}", particles); + continue; + }; + + let position_attr = builtin_attributes.position; + let Some(&buffer_entity) = particles_data.buffers.get(&position_attr) else { + warn!( + "particles(p) with no geometry needs a materialized `position` buffer" + ); + continue; + }; + let Ok(buffer) = p_particle_buffers.get(buffer_entity) else { + warn!("position buffer {:?} has no compute::Buffer", buffer_entity); + continue; + }; + let position = buffer.handle.clone(); + let count = particles_data.capacity; + + let (index, indirect) = match particles_data.connectivity { + Some(connectivity) => { + let (Ok(index_buf), Ok(indirect_buf)) = ( + p_particle_buffers.get(connectivity.index_buffer), + p_particle_buffers.get(connectivity.indirect_buffer), + ) else { + warn!("connectivity buffers for {:?} not found", particles); + continue; + }; + ( + Some(index_buf.handle.clone()), + Some(indirect_buf.handle.clone()), + ) + } + None => (None, None), + }; + + let color = particles_data + .buffers + .get(&builtin_attributes.color) + .and_then(|&e| p_particle_buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let normal = particles_data + .buffers + .get(&builtin_attributes.normal) + .and_then(|&e| p_particle_buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let render_layers = batch.render_layers.clone(); + + flush_batch(&mut res, &mut batch, &p_material_handles); + + let raster_draw = crate::particles::point_render::ParticleRasterDraw { + position, + count, + topology, + index, + indirect, + color, + normal, + blend: state.style.blend_state, + }; + match particles_data.raster_draw_entity { + Some(e) => { + res.commands.entity(e).insert(( + raster_draw, + render_layers, + Visibility::Visible, + )); + } + None => { + let e = res + .commands + .spawn(( + raster_draw, + Visibility::Visible, + Transform::default(), + NoFrustumCulling, + render_layers, + )) + .id(); + particles_data.raster_draw_entity = Some(e); + } + } + + batch.draw_index += 1; + } + DrawCommand::Particles { + particles, + geometry: Some(geometry), + topology: _, } => { let Some((geometry_data, _)) = p_geometries.get(geometry).ok() else { warn!("Could not find Geometry for entity {:?}", geometry); @@ -978,6 +1089,7 @@ pub fn flush_draw_commands( }, UntypedMaterial(material_handle), render_layers, + Visibility::Visible, )); } None => { @@ -995,6 +1107,7 @@ pub fn flush_draw_commands( }, ParticlesDraw { particles }, render_layers, + Visibility::Visible, )) .id(); particles_data.draw_entity = Some(e); diff --git a/crates/processing_render/src/shader_property.rs b/crates/processing_render/src/shader_property.rs index f0c50f5a..7d76b73c 100644 --- a/crates/processing_render/src/shader_property.rs +++ b/crates/processing_render/src/shader_property.rs @@ -83,7 +83,9 @@ pub(crate) fn apply_shader_value( .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.to_string()))?; if !matches!( category, - ParameterCategory::Texture | ParameterCategory::StorageTexture + ParameterCategory::Texture + | ParameterCategory::StorageTexture + | ParameterCategory::Sampler ) { return Err(ProcessingError::InvalidArgument(format!( "property `{name}` expects {category:?}, got Texture", diff --git a/crates/processing_render/src/shader_value.rs b/crates/processing_render/src/shader_value.rs index 2ca8dce6..2f1d4049 100644 --- a/crates/processing_render/src/shader_value.rs +++ b/crates/processing_render/src/shader_value.rs @@ -11,6 +11,9 @@ pub enum ShaderValue { Int3([i32; 3]), Int4([i32; 4]), UInt(u32), + UInt2([u32; 2]), + UInt3([u32; 3]), + UInt4([u32; 4]), Mat4([f32; 16]), Texture(Entity), Buffer(Entity), @@ -30,6 +33,9 @@ impl ShaderValue { 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::UInt2(v) => Some(v.iter().flat_map(|u| u.to_le_bytes()).collect()), + ShaderValue::UInt3(v) => Some(v.iter().flat_map(|u| u.to_le_bytes()).collect()), + ShaderValue::UInt4(v) => Some(v.iter().flat_map(|u| u.to_le_bytes()).collect()), ShaderValue::Mat4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), ShaderValue::Texture(_) | ShaderValue::Buffer(_) @@ -41,9 +47,9 @@ impl ShaderValue { 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::Float2(_) | ShaderValue::Int2(_) | ShaderValue::UInt2(_) => Some(8), + ShaderValue::Float3(_) | ShaderValue::Int3(_) | ShaderValue::UInt3(_) => Some(12), + ShaderValue::Float4(_) | ShaderValue::Int4(_) | ShaderValue::UInt4(_) => Some(16), ShaderValue::Mat4(_) => Some(64), ShaderValue::Texture(_) | ShaderValue::Buffer(_) @@ -67,6 +73,13 @@ impl ShaderValue { } Some(arr) } + fn u32s(bytes: &[u8]) -> Option<[u32; N]> { + let mut arr = [0u32; N]; + for i in 0..N { + arr[i] = u32::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()?, @@ -83,6 +96,9 @@ impl ShaderValue { ShaderValue::UInt(_) => Some(ShaderValue::UInt(u32::from_le_bytes( bytes[..4].try_into().ok()?, ))), + ShaderValue::UInt2(_) => Some(ShaderValue::UInt2(u32s::<2>(bytes)?)), + ShaderValue::UInt3(_) => Some(ShaderValue::UInt3(u32s::<3>(bytes)?)), + ShaderValue::UInt4(_) => Some(ShaderValue::UInt4(u32s::<4>(bytes)?)), ShaderValue::Mat4(_) => Some(ShaderValue::Mat4(f32s::<16>(bytes)?)), ShaderValue::Texture(_) | ShaderValue::Buffer(_) diff --git a/crates/processing_render/src/surface.rs b/crates/processing_render/src/surface.rs index 20f29e2f..4feb630f 100644 --- a/crates/processing_render/src/surface.rs +++ b/crates/processing_render/src/surface.rs @@ -255,7 +255,14 @@ pub fn create_surface_windows( /// * `display_handle` - The wl_display pointer (from GLFW's `get_wayland_display()`) #[cfg(all(target_os = "linux", feature = "wayland"))] pub fn create_surface_wayland( - In((window_handle, display_handle, width, height, scale_factor, transparent)): In<(u64, u64, u32, u32, f32, bool)>, + In((window_handle, display_handle, width, height, scale_factor, transparent)): In<( + u64, + u64, + u32, + u32, + f32, + bool, + )>, mut commands: Commands, ) -> Result { use raw_window_handle::{WaylandDisplayHandle, WaylandWindowHandle}; @@ -294,7 +301,14 @@ pub fn create_surface_wayland( /// * `display_handle` - The X11 Display pointer (from GLFW's `get_x11_display()`) #[cfg(all(target_os = "linux", feature = "x11"))] pub fn create_surface_x11( - In((window_handle, display_handle, width, height, scale_factor, transparent)): In<(u64, u64, u32, u32, f32, bool)>, + In((window_handle, display_handle, width, height, scale_factor, transparent)): In<( + u64, + u64, + u32, + u32, + f32, + bool, + )>, mut commands: Commands, ) -> Result { use raw_window_handle::{XlibDisplayHandle, XlibWindowHandle}; @@ -370,11 +384,9 @@ pub fn prepare_offscreen( let pixel_size = match texture_format { TextureFormat::R8Unorm => 1, TextureFormat::Rg8Unorm => 2, - TextureFormat::Rgba8Unorm - | TextureFormat::Rgba8UnormSrgb - | TextureFormat::Bgra8Unorm - | TextureFormat::Rgba16Float - | TextureFormat::Rgba32Float => 4, + TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb | TextureFormat::Bgra8Unorm => 4, + TextureFormat::Rgba16Float => 8, + TextureFormat::Rgba32Float => 16, _ => return Err(ProcessingError::UnsupportedTextureFormat), }; diff --git a/crates/processing_wasm/src/lib.rs b/crates/processing_wasm/src/lib.rs index fc3d9581..4131a7a8 100644 --- a/crates/processing_wasm/src/lib.rs +++ b/crates/processing_wasm/src/lib.rs @@ -2363,7 +2363,8 @@ pub fn js_particles(graphics_id: u64, particles: u64, geometry: u64) -> Result<( Entity::from_bits(graphics_id), DrawCommand::Particles { particles: Entity::from_bits(particles), - geometry: Entity::from_bits(geometry), + geometry: Some(Entity::from_bits(geometry)), + topology: geometry::Topology::PointList, }, )) } diff --git a/examples/alias_spike.rs b/examples/alias_spike.rs new file mode 100644 index 00000000..c76c61b5 --- /dev/null +++ b/examples/alias_spike.rs @@ -0,0 +1,77 @@ +use processing::prelude::*; + +const TWO_BINDING_SRC: &str = r#" +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&dst) { return; } + dst[i] = src[i] * 2.0; +} +"#; + +const ONE_BINDING_SRC: &str = r#" +@group(0) @binding(0) var data: array; +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&data) { return; } + data[i] = data[i] * 2.0; +} +"#; + +fn f32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} +fn to_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|f| f.to_le_bytes()).collect() +} + +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 base = [1.0f32, 2.0, 3.0, 4.0]; + + let two = compute_create(shader_create(TWO_BINDING_SRC)?)?; + let a_src = buffer_create_with_data(to_bytes(&base))?; + let a_dst = buffer_create_with_data(to_bytes(&[0.0; 4]))?; + compute_set(two, "src", shader_value::ShaderValue::Buffer(a_src))?; + compute_set(two, "dst", shader_value::ShaderValue::Buffer(a_dst))?; + compute_dispatch(two, 1, 1, 1)?; + println!( + "A distinct read+read_write -> {:?}", + f32s(&buffer_read(a_dst)?) + ); + + let one = compute_create(shader_create(ONE_BINDING_SRC)?)?; + let c = buffer_create_with_data(to_bytes(&base))?; + compute_set(one, "data", shader_value::ShaderValue::Buffer(c))?; + compute_dispatch(one, 1, 1, 1)?; + println!( + "C single read_write in-place-> {:?} (want [2,4,6,8])", + f32s(&buffer_read(c)?) + ); + + let b = buffer_create_with_data(to_bytes(&base))?; + compute_set(two, "src", shader_value::ShaderValue::Buffer(b))?; + compute_set(two, "dst", shader_value::ShaderValue::Buffer(b))?; + println!("B aliased read+read_write -> dispatching (expect fatal Validation Error)..."); + compute_dispatch(two, 1, 1, 1)?; + println!( + "B aliased read+read_write -> {:?} (want [2,4,6,8])", + f32s(&buffer_read(b)?) + ); + + Ok(()) +} + +fn main() { + run().unwrap(); + exit(0).unwrap(); +}