From a77b9b7df3bf5c508d439cd5713b8a5d49ff08fb Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Jul 2026 12:03:32 +0000 Subject: [PATCH 1/4] Draw raster images with pad extension instead of repeat --- node-graph/libraries/rendering/src/renderer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b284959b8d..910f3cdd94 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1861,7 +1861,7 @@ impl Render for List> { height: image.height, alpha_type: peniko::ImageAlphaType::Alpha, }) - .with_extend(peniko::Extend::Repeat); + .with_extend(peniko::Extend::Pad); scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); @@ -1960,7 +1960,7 @@ impl Render for List> { height, alpha_type: peniko::ImageAlphaType::Alpha, }) - .with_extend(peniko::Extend::Repeat); + .with_extend(peniko::Extend::Pad); let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64)); scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array())); context.resource_overrides.push((image, raster.texture.clone())); From fed55511ceb9032aa176a68c728a7b8f9e6333ef Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 20 Aug 2026 13:21:41 +0000 Subject: [PATCH 2/4] Add the GPU airbrush renderer --- Cargo.lock | 4 + node-graph/nodes/brush/Cargo.toml | 6 +- node-graph/nodes/brush/src/airbrush/consts.rs | 8 + .../nodes/brush/src/airbrush/convert.rs | 80 +++ .../nodes/brush/src/airbrush/convert.wgsl | 37 ++ node-graph/nodes/brush/src/airbrush/kernel.rs | 163 ++++++ node-graph/nodes/brush/src/airbrush/mod.rs | 68 +++ .../nodes/brush/src/airbrush/pipeline.rs | 543 ++++++++++++++++++ node-graph/nodes/brush/src/airbrush/region.rs | 67 +++ node-graph/nodes/brush/src/airbrush/render.rs | 450 +++++++++++++++ .../nodes/brush/src/airbrush/resolve.wgsl | 43 ++ .../nodes/brush/src/airbrush/scatter.wgsl | 117 ++++ node-graph/nodes/brush/src/airbrush/stroke.rs | 222 +++++++ node-graph/nodes/brush/src/lib.rs | 12 +- 14 files changed, 1816 insertions(+), 4 deletions(-) create mode 100644 node-graph/nodes/brush/src/airbrush/consts.rs create mode 100644 node-graph/nodes/brush/src/airbrush/convert.rs create mode 100644 node-graph/nodes/brush/src/airbrush/convert.wgsl create mode 100644 node-graph/nodes/brush/src/airbrush/kernel.rs create mode 100644 node-graph/nodes/brush/src/airbrush/mod.rs create mode 100644 node-graph/nodes/brush/src/airbrush/pipeline.rs create mode 100644 node-graph/nodes/brush/src/airbrush/region.rs create mode 100644 node-graph/nodes/brush/src/airbrush/render.rs create mode 100644 node-graph/nodes/brush/src/airbrush/resolve.wgsl create mode 100644 node-graph/nodes/brush/src/airbrush/scatter.wgsl create mode 100644 node-graph/nodes/brush/src/airbrush/stroke.rs diff --git a/Cargo.lock b/Cargo.lock index 00a9fcea30..0e2d844b6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,16 +371,20 @@ name = "brush-nodes" version = "0.1.0" dependencies = [ "brush-types", + "bytemuck", "core-types", "dyn-any", "glam", "graphene-hash", "graphic-types", + "half", "node-macro", "raster-nodes", "raster-types", "serde", "tokio", + "wgpu", + "wgpu-executor", ] [[package]] diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index f9b467b57f..3e1681b277 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -17,12 +17,16 @@ brush-types = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } graphic-types = { workspace = true } -raster-types = { workspace = true } +raster-types = { workspace = true, features = ["wgpu"] } raster-nodes = { workspace = true } +wgpu-executor = { workspace = true } node-macro = { workspace = true } # Workspace dependencies glam = { workspace = true } +half = { workspace = true } +bytemuck = { workspace = true } +wgpu = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true, features = ["derive"] } diff --git a/node-graph/nodes/brush/src/airbrush/consts.rs b/node-graph/nodes/brush/src/airbrush/consts.rs new file mode 100644 index 0000000000..5805eb38ed --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/consts.rs @@ -0,0 +1,8 @@ +pub(super) const SIGMA_CUTOFF: f32 = 3.; +pub(super) const SIGMA_PER_DIAMETER: f64 = 1. / 4.; +pub(super) const RIDGE_GAIN: f32 = 5.075688; + +pub(super) const LUT_SIZE: u32 = 256; +pub(super) const LUT_V_MAX: f64 = 7.5; +pub(super) const LUT_T_MAX: f64 = 7.5; +pub(super) const LUT_CACHE_SIZE: usize = 64; diff --git a/node-graph/nodes/brush/src/airbrush/convert.rs b/node-graph/nodes/brush/src/airbrush/convert.rs new file mode 100644 index 0000000000..b05a7b9569 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/convert.rs @@ -0,0 +1,80 @@ +pub(super) struct Convert { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, +} + +impl Convert { + pub(super) fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("convert.wgsl")); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("airbrush_convert_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("airbrush_convert_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("airbrush_convert_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { pipeline, layout } + } + + pub(super) fn encode(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, target: &wgpu::TextureView) { + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("airbrush_convert_bind_group"), + layout: &self.layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source), + }], + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("airbrush_convert_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + } +} diff --git a/node-graph/nodes/brush/src/airbrush/convert.wgsl b/node-graph/nodes/brush/src/airbrush/convert.wgsl new file mode 100644 index 0000000000..a92aa5d383 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/convert.wgsl @@ -0,0 +1,37 @@ +// ============= +// VERTEX SHADER +// ============= + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4 { + let pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + return vec4f(pos[vertex_index], 0.0, 1.0); +} + +// =============== +// FRAGMENT SHADER +// =============== + +@group(0) @binding(0) +var t_composite: texture_2d; + +fn linear_to_srgb(channel: f32) -> f32 { + if (channel <= 0.0031308) { + return channel * 12.92; + } + return 1.055 * pow(channel, 1.0 / 2.4) - 0.055; +} + +@fragment +fn fs_main(@builtin(position) frag: vec4) -> @location(0) vec4 { + let premultiplied = textureLoad(t_composite, vec2(frag.xy), 0); + var straight = vec3(0.0); + if (premultiplied.a > 0.0) { + straight = premultiplied.rgb / premultiplied.a; + } + return vec4(linear_to_srgb(straight.r), linear_to_srgb(straight.g), linear_to_srgb(straight.b), premultiplied.a); +} diff --git a/node-graph/nodes/brush/src/airbrush/kernel.rs b/node-graph/nodes/brush/src/airbrush/kernel.rs new file mode 100644 index 0000000000..b00a1dcb76 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/kernel.rs @@ -0,0 +1,163 @@ +//! Brush kernel baking + caching. +//! +//! Kernel is a super-Gaussian `exp(-((v^2 + s^2) / 2)^p)`: p = 1 plain Gaussian, higher p +//! flattens center + steepens edge. Hardness controls p. Sweep along a segment has no +//! closed form, so baked numerically into a texture: row per perpendicular distance, +//! columns accumulate the along-axis integral. Segment = two LUT samples, +//! `F(v, t) - F(v, t - len)`. Normalized so a long stroke's interior settles at 1. +//! +//! Calibration defines diameter: find where resolved alpha crosses EDGE_ALPHA, scale so +//! that contour lands on `diameter / 2`. Painted width matches the setting, hard or soft. +//! p clamped so the edge stays >= MIN_EDGE_TEXELS on screen. +//! +//! Baked kernels: small LRU keyed by quantized p. Textures from the global pool, held +//! weakly; evicted under pressure -> bake again. + +use super::consts::{LUT_CACHE_SIZE, LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_PER_DIAMETER}; +use super::stroke::StyledStroke; +use glam::UVec2; +use raster_types::{Texture, TextureWeakRef}; +use std::sync::Mutex; +use wgpu_executor::WgpuExecutor; + +const INTEGRATE_END: f64 = 12.; +const FINE_STEPS: usize = 4096; +const MIN_EDGE_TEXELS: f64 = 1.5; + +const EDGE_WIDTH_FACTOR: f64 = 3.09; + +const KEY_STEPS_PER_LN: f64 = 24.; + +const SOFTEST: f64 = 0.7; +const HARDEST: f64 = 48.; + +const EDGE_ALPHA: f64 = 0.05; + +pub(super) struct Kernel { + pub(super) texture: Texture, + pub(super) scale: f32, + pub(super) exponent: f32, + pub(super) section_scale: f32, +} + +struct Baked { + scale: f32, + exponent: f32, + section_scale: f32, + texture: TextureWeakRef, +} + +#[derive(Default)] +pub(super) struct KernelCache { + entries: Mutex>, +} + +impl KernelCache { + pub(super) fn get(&self, executor: &WgpuExecutor, stroke: &StyledStroke, scale: f64) -> Kernel { + let sigma_texels = stroke.diameter.max(0.) * SIGMA_PER_DIAMETER * scale; + let sharpest = (EDGE_WIDTH_FACTOR * sigma_texels / (2. * MIN_EDGE_TEXELS)).max(1.); + let exponent = (SOFTEST * (HARDEST / SOFTEST).powf(stroke.hardness.clamp(0., 1.))).min(sharpest); + let key = (exponent.ln() * KEY_STEPS_PER_LN).round() as i32; + let mut entries = self.entries.lock().unwrap(); + if let Some(index) = entries.iter().position(|(cached, _)| *cached == key) { + if let Some(texture) = entries[index].1.texture.upgrade() { + let entry = entries.remove(index); + let kernel = Kernel { + texture, + scale: entry.1.scale, + exponent: entry.1.exponent, + section_scale: entry.1.section_scale, + }; + entries.insert(0, entry); + return kernel; + } + entries.remove(index); + } + let kernel = bake(executor, (key as f64 / KEY_STEPS_PER_LN).exp()); + let baked = Baked { + scale: kernel.scale, + exponent: kernel.exponent, + section_scale: kernel.section_scale, + texture: kernel.texture.downgrade(), + }; + entries.insert(0, (key, baked)); + entries.truncate(LUT_CACHE_SIZE); + kernel + } +} + +fn kernel(v: f64, s: f64, exponent: f64) -> f64 { + (-((v * v + s * s) / 2.).powf(exponent)).exp() +} + +fn sweep_row(v: f64, exponent: f64) -> (Vec, f64) { + let ds = 2. * INTEGRATE_END / FINE_STEPS as f64; + let mut cumulative = Vec::with_capacity(FINE_STEPS + 1); + let mut total = 0.; + let mut previous = kernel(v, -INTEGRATE_END, exponent); + cumulative.push(0.); + for i in 1..=FINE_STEPS { + let value = kernel(v, -INTEGRATE_END + i as f64 * ds, exponent); + total += (previous + value) / 2. * ds; + previous = value; + cumulative.push(total); + } + let samples = (0..LUT_SIZE) + .map(|j| { + let t = -LUT_T_MAX + j as f64 * 2. * LUT_T_MAX / (LUT_SIZE - 1) as f64; + let x = (t + INTEGRATE_END) / ds; + let i = (x.floor() as usize).min(FINE_STEPS - 1); + cumulative[i] + (cumulative[i + 1] - cumulative[i]) * (x - i as f64) + }) + .collect(); + (samples, total) +} + +fn calibrate(ridge: &[f64], target: f64) -> f64 { + let step = LUT_V_MAX / (LUT_SIZE - 1) as f64; + let Some(i) = ridge.iter().position(|&r| r < target).filter(|&i| i > 0) else { + return LUT_V_MAX; + }; + let (above, below) = (ridge[i - 1], ridge[i]); + step * ((i - 1) as f64 + (above - target) / (above - below)) +} + +fn bake(executor: &WgpuExecutor, exponent: f64) -> Kernel { + let mut rows = Vec::with_capacity((LUT_SIZE * LUT_SIZE) as usize); + let mut ridge = Vec::with_capacity(LUT_SIZE as usize); + let mut norm = 1.; + for row in 0..LUT_SIZE { + let v = row as f64 * LUT_V_MAX / (LUT_SIZE - 1) as f64; + if kernel(v, 0., exponent) < 1e-9 { + rows.resize(rows.len() + LUT_SIZE as usize, half::f16::ZERO); + ridge.push(0.); + continue; + } + let (samples, total) = sweep_row(v, exponent); + if row == 0 { + norm = 1. / total; + } + rows.extend(samples.into_iter().map(|value| half::f16::from_f64(value * norm))); + ridge.push(total * norm); + } + let texture = executor.request_texture_with_format(UVec2::splat(LUT_SIZE), wgpu::TextureFormat::R16Float); + executor.context().queue.write_texture( + texture.as_image_copy(), + bytemuck::cast_slice(&rows), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(LUT_SIZE * 2), + rows_per_image: Some(LUT_SIZE), + }, + texture.size(), + ); + let gain = RIDGE_GAIN as f64; + let target = -(1. - EDGE_ALPHA * (1. - (-gain).exp())).ln() / gain; + let a = calibrate(&ridge, target); + Kernel { + texture, + scale: (a / 2.) as f32, + exponent: exponent as f32, + section_scale: ((2. * (1. / target).ln().powf(1. / exponent)).sqrt() / 2.) as f32, + } +} diff --git a/node-graph/nodes/brush/src/airbrush/mod.rs b/node-graph/nodes/brush/src/airbrush/mod.rs new file mode 100644 index 0000000000..3b7a752121 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/mod.rs @@ -0,0 +1,68 @@ +mod consts; +mod convert; +mod kernel; +mod pipeline; +mod region; +mod render; +mod stroke; + +use brush_types::BrushCache; +use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List}; +use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint}; +use graphic_types::Graphic; +use pipeline::{AirbrushPipeline, AirbrushPipelineArgs}; +use raster_types::{GPU, Raster}; +use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; + +#[node_macro::node(category("Raster: Brush"))] +pub async fn airbrush<'a: 'n>(ctx: impl Ctx + ExtractFootprint, strokes: List, cache: BrushCache, #[scope(airbrush_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache) -> List> { + let mut stack = vec![strokes.into_iter()]; + let mut strokes = Vec::new(); + while let Some(top) = stack.last_mut() { + let Some(item) = top.next() else { + stack.pop(); + continue; + }; + let color = item.attribute_cloned_or(ATTR_COLOR, crate::DEFAULT_COLOR); + let diameter = item.attribute_cloned_or(ATTR_DIAMETER, crate::DEFAULT_DIAMETER); + let hardness = item.attribute_cloned_or(ATTR_HARDNESS, crate::DEFAULT_HARDNESS / 100.); + let flow = item.attribute_cloned_or(ATTR_FLOW, crate::DEFAULT_FLOW / 100.); + match item.into_element() { + Graphic::Stroke(list) => strokes.extend( + list.into_iter() + .map(Item::into_element) + .filter(|stroke| !stroke.is_empty() && stroke.is_valid()) + .map(|stroke| stroke::StyledStroke { + color, + diameter, + hardness, + flow, + stroke, + }), + ), + Graphic::Graphic(nested) => stack.push(nested.into_iter()), + _ => {} + } + } + + let args = AirbrushPipelineArgs { + footprint: *ctx.footprint(), + strokes: &strokes, + cache: &cache, + }; + let Some((texture, transform)) = pipeline.run::(&args).await else { + return List::new(); + }; + let raster = Raster::::new_gpu(texture); + List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform)) +} + +#[node_macro::node(category(""), inject_scope)] +async fn airbrush_pipeline<'a: 'n>( + _ctx: impl Ctx, + #[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: &'a WgpuExecutor, + #[data] pipeline: WgpuPipelineCache, +) -> WgpuPipelineCache { + executor.pipeline_init::(pipeline); + pipeline.clone() +} diff --git a/node-graph/nodes/brush/src/airbrush/pipeline.rs b/node-graph/nodes/brush/src/airbrush/pipeline.rs new file mode 100644 index 0000000000..8ca2135730 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/pipeline.rs @@ -0,0 +1,543 @@ +use super::consts::{SIGMA_CUTOFF, LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN}; +use super::convert::Convert; +use super::kernel::{Kernel, KernelCache}; +use super::region::{Crop, Region}; +use super::stroke::{Edge, StyledStroke}; +use brush_types::BrushCache; +use bytemuck::{Pod, Zeroable}; +use core_types::Color; +use core_types::transform::Footprint; +use glam::{DAffine2, UVec2}; +use raster_types::Texture; +use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor}; + +pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; +pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +struct ScatterUniforms { + frame_size: [f32; 2], + kernel_scale: f32, + kernel_exponent: f32, + kernel_section_scale: f32, + _pad: f32, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +struct ResolveUniforms { + color: [f32; 4], + density_offset: [f32; 2], + _pad: [f32; 2], +} + +pub struct AirbrushPipeline { + scatter: Scatter, + resolve: Resolve, + convert: Convert, + kernels: KernelCache, +} + +pub(super) struct Field { + pub(super) density: Texture, + pub(super) stamp: Texture, +} + +impl Field { + pub(super) fn request(executor: &WgpuExecutor, size: UVec2) -> Self { + Self { + density: executor.request_texture_with_format(size, DENSITY_FORMAT), + stamp: executor.request_texture_with_format(size, DENSITY_FORMAT), + } + } + + pub(super) fn views(&self) -> FieldViews { + FieldViews { + density: self.density.create_view(&wgpu::TextureViewDescriptor::default()), + stamp: self.stamp.create_view(&wgpu::TextureViewDescriptor::default()), + } + } +} + +pub(super) struct FieldViews { + pub(super) density: wgpu::TextureView, + pub(super) stamp: wgpu::TextureView, +} + +pub struct AirbrushPipelineArgs<'a> { + pub(super) footprint: Footprint, + pub(super) strokes: &'a [StyledStroke], + pub(super) cache: &'a BrushCache, +} + +impl AsyncWgpuPipeline for AirbrushPipeline { + type Args<'a> = AirbrushPipelineArgs<'a>; + type Out = Option<(Texture, DAffine2)>; + + fn create(executor: &WgpuExecutor) -> Self { + let device = &executor.context().device; + Self { + scatter: Scatter::new(device), + resolve: Resolve::new(device), + convert: Convert::new(device), + kernels: KernelCache::default(), + } + } + + async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out { + let frame = super::render::Frame::new(args.strokes)?; + let region = Region::new(&args.footprint)?; + let state = args.cache.take(&args.footprint).unwrap_or_default(); + let rendered = super::render::render(self, executor, frame, region, state)?; + args.cache.store(&args.footprint, rendered.state); + Some((rendered.texture, rendered.transform)) + } +} + +struct Scatter { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl Scatter { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("scatter.wgsl")); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("airbrush_density_bind_group_layout"), + entries: &[ + uniform_entry(0, wgpu::ShaderStages::VERTEX_FRAGMENT), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("airbrush_kernel_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("airbrush_density_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let instance_layout = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 16, + shader_location: 2, + format: wgpu::VertexFormat::Float32, + }, + wgpu::VertexAttribute { + offset: 20, + shader_location: 3, + format: wgpu::VertexFormat::Float32, + }, + ], + }; + let additive = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }; + let union = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Max, + }; + let options = wgpu::PipelineCompilationOptions { + constants: &[("CUTOFF_SIGMA", SIGMA_CUTOFF as f64), ("LUT_SIZE", LUT_SIZE as f64), ("LUT_V_MAX", LUT_V_MAX), ("LUT_T_MAX", LUT_T_MAX)], + ..Default::default() + }; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("airbrush_density_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: options.clone(), + buffers: &[instance_layout], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: options, + targets: &[ + Some(wgpu::ColorTargetState { + format: DENSITY_FORMAT, + blend: Some(wgpu::BlendState { color: additive, alpha: additive }), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: DENSITY_FORMAT, + blend: Some(wgpu::BlendState { color: union, alpha: union }), + write_mask: wgpu::ColorWrites::ALL, + }), + ], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { pipeline, layout, sampler } + } + + fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, kernel: &wgpu::TextureView) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("airbrush_density_bind_group"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: globals.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(kernel), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }) + } + + fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &FieldViews, bind: &wgpu::BindGroup, buffer: &wgpu::Buffer, instances: u32) { + let attachment = |view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }) + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("airbrush_density_pass"), + color_attachments: &[attachment(&target.density), attachment(&target.stamp)], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, bind, &[]); + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..4, 0..instances); + } +} + +struct Resolve { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, +} + +impl Resolve { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("resolve.wgsl")); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("airbrush_resolve_bind_group_layout"), + entries: &[uniform_entry(0, wgpu::ShaderStages::FRAGMENT), texture_entry(1), texture_entry(2)], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("airbrush_resolve_pipeline_layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let options = wgpu::PipelineCompilationOptions { + constants: &[("RIDGE_GAIN", RIDGE_GAIN as f64), ("RIDGE_NORM", 1. / (1. - (-RIDGE_GAIN as f64).exp()))], + ..Default::default() + }; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("airbrush_resolve_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: options.clone(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: options, + targets: &[Some(wgpu::ColorTargetState { + format: COMPOSITE_FORMAT, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { pipeline, layout } + } + + fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, source: &FieldViews) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("airbrush_resolve_bind_group"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: globals.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&source.density), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&source.stamp), + }, + ], + }) + } + + fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, bind: &wgpu::BindGroup, scissor: (UVec2, UVec2)) { + let (origin, size) = scissor; + if !size.cmpgt(UVec2::ZERO).all() { + return; + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("airbrush_resolve_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, bind, &[]); + pass.set_scissor_rect(origin.x, origin.y, size.x, size.y); + pass.draw(0..3, 0..1); + } +} + +pub(super) struct Recorder<'a> { + pipeline: &'a AirbrushPipeline, + executor: &'a WgpuExecutor, + encoder: wgpu::CommandEncoder, + region: Region, + buffers: Vec, + textures: Vec, +} + +impl<'a> Recorder<'a> { + pub(super) fn new(pipeline: &'a AirbrushPipeline, executor: &'a WgpuExecutor, region: &Region) -> Self { + let device = &executor.context().device; + let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("airbrush_encoder") }); + Self { + pipeline, + executor, + encoder, + region: *region, + buffers: Vec::new(), + textures: Vec::new(), + } + } + + pub(super) fn kernel(&self, stroke: &StyledStroke) -> Kernel { + self.pipeline.kernels.get(self.executor, stroke, self.region.scale) + } + + pub(super) fn clear(&mut self, target: &wgpu::TextureView) { + self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("airbrush_clear_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + } + + pub(super) fn clear_field(&mut self, target: &FieldViews) { + let attachment = |view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }) + }; + self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("airbrush_clear_pass"), + color_attachments: &[attachment(&target.density), attachment(&target.stamp)], + ..Default::default() + }); + } + + pub(super) fn scatter(&mut self, target: &FieldViews, edges: &[Edge], kernel: &Kernel) { + if edges.is_empty() { + return; + } + let globals = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("airbrush_scatter_uniform"), + contents: bytemuck::bytes_of(&ScatterUniforms { + frame_size: [self.region.size.x as f32, self.region.size.y as f32], + kernel_scale: kernel.scale, + kernel_exponent: kernel.exponent, + kernel_section_scale: kernel.section_scale, + _pad: 0., + }), + usage: wgpu::BufferUsages::UNIFORM, + }); + let view = kernel.texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind = self.pipeline.scatter.bind(&self.executor.context().device, &globals, &view); + let buffer = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("airbrush_segment_buffer"), + contents: bytemuck::cast_slice(edges), + usage: wgpu::BufferUsages::VERTEX, + }); + self.pipeline.scatter.encode(&mut self.encoder, target, &bind, &buffer, edges.len() as u32); + self.buffers.push(globals); + self.buffers.push(buffer); + self.textures.push(kernel.texture.clone()); + } + + pub(super) fn resolve(&mut self, color: Color, crop: &Crop, source: &FieldViews, target: &wgpu::TextureView, scissor: (UVec2, UVec2)) { + let globals = resolve_uniform(self.executor, color, crop); + let bind = self.pipeline.resolve.bind(&self.executor.context().device, &globals, source); + self.pipeline.resolve.encode(&mut self.encoder, target, &bind, scissor); + self.buffers.push(globals); + } + + pub(super) fn copy(&mut self, from: &Texture, from_origin: UVec2, to: &Texture, to_origin: UVec2) { + copy_placed(&mut self.encoder, from, from_origin, to, to_origin); + } + + pub(super) fn copy_texture(&mut self, from: &Texture, to: &Texture) { + self.encoder.copy_texture_to_texture(from.as_image_copy(), to.as_image_copy(), from.size()); + } + + pub(super) fn convert(&mut self, source: &wgpu::TextureView, target: &wgpu::TextureView) { + self.pipeline.convert.encode(&self.executor.context().device, &mut self.encoder, source, target); + } + + pub(super) fn keep(&mut self, texture: Texture) { + self.textures.push(texture); + } + + pub(super) fn submit(self) { + let command = self.encoder.finish(); + self.executor.context().queue.submit([command]); + } +} + +fn uniform_entry(binding: u32, visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +fn resolve_uniform(executor: &WgpuExecutor, color: Color, crop: &Crop) -> Buffer { + let uniforms = ResolveUniforms { + color: [color.r(), color.g(), color.b(), color.a()], + density_offset: [crop.origin.x as f32, crop.origin.y as f32], + _pad: [0.; 2], + }; + executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("airbrush_resolve_uniform"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }) +} + +fn copy_placed(encoder: &mut wgpu::CommandEncoder, from: &wgpu::Texture, from_origin: UVec2, to: &wgpu::Texture, to_origin: UVec2) { + let start = from_origin.max(to_origin); + let end = (from_origin + UVec2::new(from.width(), from.height())).min(to_origin + UVec2::new(to.width(), to.height())); + if !end.cmpgt(start).all() { + return; + } + let info = |texture, origin: UVec2| wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d { x: origin.x, y: origin.y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }; + let extent = end - start; + encoder.copy_texture_to_texture( + info(from, start - from_origin), + info(to, start - to_origin), + wgpu::Extent3d { + width: extent.x, + height: extent.y, + depth_or_array_layers: 1, + }, + ); +} diff --git a/node-graph/nodes/brush/src/airbrush/region.rs b/node-graph/nodes/brush/src/airbrush/region.rs new file mode 100644 index 0000000000..54c78be392 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/region.rs @@ -0,0 +1,67 @@ +use core_types::math::bbox::AxisAlignedBbox; +use core_types::transform::Footprint; +use glam::{DAffine2, DVec2, UVec2}; + +const MAX_RESOLUTION: u32 = 8192; + +const CROP_STEP: u32 = 256; + +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct Region { + pub(crate) min: DVec2, + pub(crate) scale: f64, + pub(crate) size: UVec2, +} + +impl Region { + pub(crate) fn new(footprint: &Footprint) -> Option { + let margin = DVec2::splat(2. / footprint.scale().max_element()); + let viewport = footprint.viewport_bounds_in_local_space(); + let bounds = AxisAlignedBbox { + start: viewport.start - margin, + end: viewport.end + margin, + }; + if !bounds.size().cmpgt(DVec2::ZERO).all() { + return None; + } + // -2 leaves room for the floor/ceil below to add a texel per side at the cap. + let scale = footprint.scale().max_element().min((MAX_RESOLUTION as f64 - 2.) / bounds.size().max_element()); + if !scale.is_finite() || scale <= 0. { + return None; + } + let start = (bounds.start * scale).floor(); + let end = (bounds.end * scale).ceil(); + let size = (end - start).as_uvec2().max(UVec2::ONE); + Some(Self { min: start / scale, scale, size }) + } +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct Crop { + pub(crate) origin: UVec2, + pub(crate) size: UVec2, +} + +impl Crop { + pub(crate) fn new(content: AxisAlignedBbox, region: &Region) -> Option { + let start = ((content.start - region.min) * region.scale).floor().max(DVec2::ZERO); + let end = ((content.end - region.min) * region.scale).ceil().min(region.size.as_dvec2()); + if !(end - start).cmpgt(DVec2::ZERO).all() { + return None; + } + let origin = start.as_uvec2() / CROP_STEP * CROP_STEP; + let end = ((end.as_uvec2() + UVec2::splat(CROP_STEP - 1)) / CROP_STEP * CROP_STEP).min(region.size); + Some(Self { origin, size: end - origin }) + } + + pub(crate) fn transform(&self, region: &Region) -> DAffine2 { + DAffine2::from_translation(region.min + self.origin.as_dvec2() / region.scale) * DAffine2::from_scale(self.size.as_dvec2() / region.scale) + } + + pub(crate) fn scissor(&self, region: &Region, bounds: AxisAlignedBbox) -> (UVec2, UVec2) { + let clamp = |texels: UVec2| texels.max(self.origin).min(self.origin + self.size) - self.origin; + let min = ((bounds.start - region.min) * region.scale).floor().max(DVec2::ZERO).as_uvec2().min(region.size); + let max = ((bounds.end - region.min) * region.scale).ceil().max(DVec2::ZERO).as_uvec2().min(region.size); + (clamp(min), clamp(max) - clamp(min)) + } +} diff --git a/node-graph/nodes/brush/src/airbrush/render.rs b/node-graph/nodes/brush/src/airbrush/render.rs new file mode 100644 index 0000000000..77761403c1 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/render.rs @@ -0,0 +1,450 @@ +use super::pipeline::{AirbrushPipeline, COMPOSITE_FORMAT, Field, Recorder}; +use super::region::{Crop, Region}; +use super::stroke::{self, StyledStroke, Walk}; + +use core_types::CacheHash; +use core_types::math::bbox::AxisAlignedBbox; +use glam::{DAffine2, UVec2}; +use raster_types::{Texture, TextureWeakRef}; +use std::hash::{Hash, Hasher}; +use wgpu_executor::WgpuExecutor; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct StrokeKey(u64); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct DensityKey(u64); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct PrefixKey(u64); + +#[derive(PartialEq, Eq)] +struct FrameKey { + finished: Vec, + active: StrokeKey, +} + +pub(super) struct Frame<'a> { + finished: &'a [StyledStroke], + active: &'a StyledStroke, +} +impl<'a> Frame<'a> { + pub(super) fn new(strokes: &'a [StyledStroke]) -> Option { + let (active, finished) = strokes.split_last()?; + Some(Self { finished, active }) + } +} + +#[derive(Default)] +pub(super) struct State { + finished: Finished, + pending: Option, + output: Option, +} + +#[derive(Default)] +struct Finished { + strokes: Vec, + image: Option>, +} + +struct Record { + key: StrokeKey, + bounds: AxisAlignedBbox, +} + +struct Placed { + texture: T, + origin: UVec2, +} + +struct CachedOutput { + key: FrameKey, + texture: TextureWeakRef, +} + +struct Pending { + key: PendingKey, + walk: Walk, + density: TextureWeakRef, + stamp: TextureWeakRef, +} + +struct LivePending { + key: PendingKey, + walk: Walk, + field: Field, +} + +#[derive(Clone, Copy)] +struct PendingKey { + seed: u64, + density: DensityKey, + prefix: PrefixKey, +} + +impl PendingKey { + fn new(stroke: &StyledStroke, consumed: usize) -> Self { + Self { + seed: stroke.stroke.seed, + density: density_key(stroke), + prefix: prefix_key(stroke, consumed), + } + } + + fn matches(&self, stroke: &StyledStroke, consumed: usize) -> bool { + self.seed == stroke.stroke.seed && self.density == density_key(stroke) && self.prefix == prefix_key(stroke, consumed) + } +} + +impl Pending { + fn upgrade(self, region: &Region) -> Option { + let density = self.density.upgrade()?; + let stamp = self.stamp.upgrade()?; + if density.width() != region.size.x || density.height() != region.size.y { + return None; + } + Some(LivePending { + key: self.key, + walk: self.walk, + field: Field { density, stamp }, + }) + } +} + +impl LivePending { + fn matches(&self, stroke: &StyledStroke) -> bool { + self.walk.consumed > 0 && self.walk.consumed <= stroke.stroke.len() && self.key.matches(stroke, self.walk.consumed) + } + + fn park(self) -> Pending { + Pending { + key: self.key, + walk: self.walk, + density: self.field.density.downgrade(), + stamp: self.field.stamp.downgrade(), + } + } +} + +pub(super) struct Rendered { + pub(super) texture: Texture, + pub(super) transform: DAffine2, + pub(super) state: State, +} + +pub(super) fn render(pipeline: &AirbrushPipeline, executor: &WgpuExecutor, frame: Frame<'_>, region: Region, mut state: State) -> Option { + let keys: Vec<_> = frame.finished.iter().map(stroke_key).collect(); + let active_key = stroke_key(frame.active); + let frame_key = frame_key(&keys, active_key); + let prefix = state.finished.strokes.len() <= keys.len() && state.finished.strokes.iter().zip(&keys).all(|(cached, current)| cached.key == *current); + let known = if prefix { state.finished.strokes.len() } else { 0 }; + let mut bounds: Vec<_> = if prefix { + state.finished.strokes.iter().map(|record| record.bounds.clone()).collect() + } else { + Vec::new() + }; + bounds.extend(frame.finished[known..].iter().map(|stroke| stroke::bounds(stroke, region.scale))); + let active_bounds = stroke::bounds(frame.active, region.scale); + let mut content = None; + for bounds in &bounds { + stroke::union(&mut content, bounds.clone()); + } + stroke::union(&mut content, active_bounds.clone()); + let crop = Crop::new(content?, ®ion)?; + + if let Some(texture) = state.output.as_ref().filter(|output| output.key == frame_key).and_then(|output| output.texture.upgrade()) { + return Some(Rendered { + texture, + transform: crop.transform(®ion), + state, + }); + } + + let base = state + .finished + .image + .take() + .and_then(|placed| { + Some(Placed { + texture: placed.texture.upgrade()?, + origin: placed.origin, + }) + }) + .filter(|placed| prefix && (placed.origin + UVec2::new(placed.texture.width(), placed.texture.height())).cmple(region.size).all()); + let covered = if base.is_some() { state.finished.strokes.len() } else { 0 }; + let missing = &frame.finished[covered..]; + let pending = state.pending.take().and_then(|pending| pending.upgrade(®ion)); + let (active_pending, mut finished_pending) = match pending { + Some(pending) if pending.matches(frame.active) => (Some(pending), None), + pending => (None, pending), + }; + + let updated = (!missing.is_empty()).then(|| executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT)); + let composite = executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT); + let scratch = Field::request(executor, region.size); + let output = executor.request_texture(crop.size); + let mut recorder = Recorder::new(pipeline, executor, ®ion); + + if let Some(updated) = &updated { + let target = updated.create_view(&wgpu::TextureViewDescriptor::default()); + recorder.clear(&target); + if let Some(base) = &base { + recorder.copy(&base.texture, base.origin, updated, crop.origin); + } + let mut strokes = StrokeRenderer { + recorder: &mut recorder, + executor, + region: ®ion, + crop: &crop, + scratch: &scratch, + }; + for (index, stroke) in missing.iter().enumerate() { + let scissor = crop.scissor(®ion, bounds[covered + index].clone()); + if !scissor.1.cmpgt(UVec2::ZERO).all() { + continue; + } + let previous = if finished_pending.as_ref().is_some_and(|pending| pending.matches(stroke)) { + finished_pending.take() + } else { + None + }; + strokes.render(stroke, previous, Tail::Commit, Target { view: &target, scissor }); + } + } + + let composite_view = composite.create_view(&wgpu::TextureViewDescriptor::default()); + match (&updated, &base) { + (Some(updated), _) => recorder.copy_texture(updated, &composite), + (None, Some(base)) => { + recorder.clear(&composite_view); + recorder.copy(&base.texture, base.origin, &composite, crop.origin); + } + (None, None) => recorder.clear(&composite_view), + } + let active_scissor = crop.scissor(®ion, active_bounds); + let pending = StrokeRenderer { + recorder: &mut recorder, + executor, + region: ®ion, + crop: &crop, + scratch: &scratch, + } + .render( + frame.active, + active_pending, + Tail::Preview, + Target { + view: &composite_view, + scissor: active_scissor, + }, + )?; + + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); + recorder.convert(&composite_view, &output_view); + recorder.submit(); + + let image = updated + .map(|texture| Placed { + texture: texture.downgrade(), + origin: crop.origin, + }) + .or_else(|| { + base.map(|placed| Placed { + texture: placed.texture.downgrade(), + origin: placed.origin, + }) + }); + let state = State { + finished: Finished { + strokes: keys.into_iter().zip(bounds).map(|(key, bounds)| Record { key, bounds }).collect(), + image, + }, + pending: Some(pending.park()), + output: Some(CachedOutput { + key: frame_key, + texture: output.downgrade(), + }), + }; + Some(Rendered { + texture: output, + transform: crop.transform(®ion), + state, + }) +} + +#[derive(Clone, Copy)] +enum Tail { + Commit, + Preview, +} + +enum Density<'a> { + Temporary(&'a Field), + Owned(Field), +} + +impl Density<'_> { + fn field(&self) -> &Field { + match self { + Self::Temporary(field) => field, + Self::Owned(field) => field, + } + } +} + +struct Target<'a> { + view: &'a wgpu::TextureView, + scissor: (UVec2, UVec2), +} + +struct StrokeRenderer<'a, 'gpu> { + recorder: &'a mut Recorder<'gpu>, + executor: &'gpu WgpuExecutor, + region: &'a Region, + crop: &'a Crop, + scratch: &'a Field, +} + +impl StrokeRenderer<'_, '_> { + fn render(&mut self, stroke: &StyledStroke, previous: Option, tail: Tail, target: Target<'_>) -> Option { + let (mut walk, density) = match previous { + Some(pending) => (pending.walk, Density::Owned(pending.field)), + None => match tail { + Tail::Commit => (Walk::default(), Density::Temporary(self.scratch)), + Tail::Preview => (Walk::default(), Density::Owned(Field::request(self.executor, self.region.size))), + }, + }; + let views = density.field().views(); + if walk.consumed == 0 { + self.recorder.clear_field(&views); + } + let kernel = self.recorder.kernel(stroke); + let mut update = walk.update(stroke, self.region); + match tail { + Tail::Commit => { + update.committed.append(&mut update.tail); + self.recorder.scatter(&views, &update.committed, &kernel); + self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor); + if let Density::Owned(field) = density { + self.recorder.keep(field.density); + self.recorder.keep(field.stamp); + } + None + } + Tail::Preview => { + self.recorder.scatter(&views, &update.committed, &kernel); + if update.tail.is_empty() { + self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor); + } else { + let field = density.field(); + self.recorder.copy_texture(&field.density, &self.scratch.density); + self.recorder.copy_texture(&field.stamp, &self.scratch.stamp); + let scratch_views = self.scratch.views(); + self.recorder.scatter(&scratch_views, &update.tail, &kernel); + self.recorder.resolve(stroke.color, self.crop, &scratch_views, target.view, target.scissor); + } + let Density::Owned(field) = density else { unreachable!() }; + Some(LivePending { + key: PendingKey::new(stroke, walk.consumed), + walk, + field, + }) + } + } + } +} + +fn stroke_key(stroke: &StyledStroke) -> StrokeKey { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + stroke.stroke.cache_hash(&mut hasher); + stroke.color.cache_hash(&mut hasher); + stroke.diameter.cache_hash(&mut hasher); + stroke.hardness.cache_hash(&mut hasher); + stroke.flow.cache_hash(&mut hasher); + StrokeKey(hasher.finish()) +} + +fn density_key(stroke: &StyledStroke) -> DensityKey { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (stroke.diameter.max(0.) as f32).to_bits().hash(&mut hasher); + (stroke.hardness.clamp(0., 1.) as f32).to_bits().hash(&mut hasher); + (stroke.flow.clamp(0., 1.) as f32).to_bits().hash(&mut hasher); + DensityKey(hasher.finish()) +} + +fn prefix_key(stroke: &StyledStroke, consumed: usize) -> PrefixKey { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for sample in stroke.stroke.samples().take(consumed) { + sample.position.x.to_bits().hash(&mut hasher); + sample.position.y.to_bits().hash(&mut hasher); + sample.pressure.clamp(0., 1.).to_bits().hash(&mut hasher); + } + PrefixKey(hasher.finish()) +} + +fn frame_key(finished: &[StrokeKey], active: StrokeKey) -> FrameKey { + FrameKey { finished: finished.to_vec(), active } +} + +#[cfg(test)] +mod tests { + use super::*; + use brush_types::{Channel, Stroke}; + use core_types::Color; + use glam::DVec2; + + fn stroke() -> StyledStroke { + StyledStroke { + color: Color::BLACK, + diameter: 20., + hardness: 0.8, + flow: 1., + stroke: Stroke { + position: vec![DVec2::new(1., 2.), DVec2::new(3., 4.), DVec2::new(5., 6.)], + pressure: Channel::Samples(vec![0.2, 0.4, 0.6]), + seed: 42, + ..Default::default() + }, + } + } + + #[test] + fn pending_key_accepts_an_appended_stroke() { + let original = stroke(); + let key = PendingKey::new(&original, original.stroke.len()); + let mut appended = stroke(); + appended.stroke.position.push(DVec2::new(7., 8.)); + let Channel::Samples(pressure) = &mut appended.stroke.pressure else { unreachable!() }; + pressure.push(0.8); + assert!(key.matches(&appended, original.stroke.len())); + } + + #[test] + fn pending_key_rejects_changed_render_data() { + let original = stroke(); + let key = PendingKey::new(&original, original.stroke.len()); + + let mut position = stroke(); + position.stroke.position[0].x += 1.; + assert!(!key.matches(&position, original.stroke.len())); + + let mut pressure = stroke(); + let Channel::Samples(samples) = &mut pressure.stroke.pressure else { unreachable!() }; + samples[1] += 0.1; + assert!(!key.matches(&pressure, original.stroke.len())); + + let mut flow = stroke(); + flow.flow *= 0.5; + assert!(!key.matches(&flow, original.stroke.len())); + } + + #[test] + fn pending_key_ignores_color() { + let original = stroke(); + let key = PendingKey::new(&original, original.stroke.len()); + let mut recolored = stroke(); + recolored.color = Color::WHITE; + assert!(key.matches(&recolored, original.stroke.len())); + } +} diff --git a/node-graph/nodes/brush/src/airbrush/resolve.wgsl b/node-graph/nodes/brush/src/airbrush/resolve.wgsl new file mode 100644 index 0000000000..f9053afa19 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/resolve.wgsl @@ -0,0 +1,43 @@ +// ============= +// VERTEX SHADER +// ============= + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4 { + let pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + return vec4f(pos[vertex_index], 0.0, 1.0); +} + +// =============== +// FRAGMENT SHADER +// =============== + +struct Uniforms { + color: vec4, + density_offset: vec2, + _pad: vec2, +}; + +override RIDGE_GAIN: f32; +override RIDGE_NORM: f32; + +@group(0) @binding(0) +var uniforms: Uniforms; + +@group(0) @binding(1) +var t_density: texture_2d; + +@group(0) @binding(2) +var t_stamp: texture_2d; + +@fragment +fn fs_main(@builtin(position) frag: vec4) -> @location(0) vec4 { + let texel = vec2(frag.xy + uniforms.density_offset); + let field = max(textureLoad(t_density, texel, 0).r, textureLoad(t_stamp, texel, 0).r); + let alpha = clamp((1.0 - exp(-field * RIDGE_GAIN)) * RIDGE_NORM, 0.0, 1.0) * uniforms.color.a; + return vec4(uniforms.color.rgb * alpha, alpha); +} diff --git a/node-graph/nodes/brush/src/airbrush/scatter.wgsl b/node-graph/nodes/brush/src/airbrush/scatter.wgsl new file mode 100644 index 0000000000..0d0ba68c9b --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/scatter.wgsl @@ -0,0 +1,117 @@ +override CUTOFF_SIGMA: f32; + +override LUT_SIZE: f32; +override LUT_V_MAX: f32; +override LUT_T_MAX: f32; + +// ============= +// VERTEX SHADER +// ============= + +struct Uniforms { + frame_size: vec2, + kernel_scale: f32, + kernel_exponent: f32, + kernel_section_scale: f32, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +@group(0) @binding(1) +var t_kernel: texture_2d; + +@group(0) @binding(2) +var s_kernel: sampler; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) @interpolate(flat) a: vec2, + @location(1) @interpolate(flat) b: vec2, + @location(2) @interpolate(flat) sigma: f32, + @location(3) @interpolate(flat) weight: f32, +}; + +@vertex +fn vs_main( + @builtin(vertex_index) vertex_index: u32, + @location(0) a: vec2, + @location(1) b: vec2, + @location(2) sigma: f32, + @location(3) weight: f32, +) -> VertexOutput { + let cutoff = CUTOFF_SIGMA * sigma; + + let d = b - a; + let len = length(d); + var e = vec2f(1.0, 0.0); + if (len > 1e-6) { + e = d / len; + } + let n = vec2f(-e.y, e.x); + let base = select(a - e * cutoff, b + e * cutoff, (vertex_index & 1u) == 1u); + let normal_sign = select(-1.0, 1.0, vertex_index >= 2u); + let corner = base + n * (cutoff * normal_sign); + + let ndc = vec2f(corner.x / uniforms.frame_size.x * 2.0 - 1.0, 1.0 - corner.y / uniforms.frame_size.y * 2.0); + + var out: VertexOutput; + out.clip_position = vec4f(ndc, 0.0, 1.0); + out.a = a; + out.b = b; + out.sigma = sigma; + out.weight = weight; + return out; +} + +// =============== +// FRAGMENT SHADER +// =============== + +fn sweep(v: f32, t: f32) -> f32 { + let texel = (LUT_SIZE - 1.0) / LUT_SIZE; + let uv = vec2f( + ((t + LUT_T_MAX) / (2.0 * LUT_T_MAX)) * texel + 0.5 / LUT_SIZE, + (v / LUT_V_MAX) * texel + 0.5 / LUT_SIZE, + ); + return textureSampleLevel(t_kernel, s_kernel, uv, 0.0).r; +} + +fn section(r2: f32) -> f32 { + // Max avoids pow undefined log at zero. + return exp(-pow(max(r2 * 0.5, 1e-20), uniforms.kernel_exponent)); +} + +struct FragmentOutput { + @location(0) density: f32, + @location(1) stamp: f32, +}; + +@fragment +fn fs_main(in: VertexOutput) -> FragmentOutput { + let p = in.clip_position.xy; + let inv_section = uniforms.kernel_section_scale / in.sigma; + let d = in.b - in.a; + let len = length(d); + if (len < 1e-6) { + let dab = in.weight * section(dot(p - in.a, p - in.a) * inv_section * inv_section); + return FragmentOutput(dab, dab); + } + let e = d / len; + let rel = p - in.a; + let along = dot(rel, e); + let perp2 = max(dot(rel, rel) - along * along, 0.0); + + let cutoff = CUTOFF_SIGMA * in.sigma; + if (perp2 > cutoff * cutoff || along < -cutoff || along > len + cutoff) { + return FragmentOutput(0.0, 0.0); + } + let inv_sp = uniforms.kernel_scale / in.sigma; + let v = sqrt(perp2) * inv_sp; + let ridge = sweep(v, along * inv_sp) - sweep(v, (along - len) * inv_sp); + + let overhang = max(max(-along, along - len), 0.0); + let stamp = in.weight * section((perp2 + overhang * overhang) * inv_section * inv_section); + + return FragmentOutput(in.weight * max(ridge, 0.0), stamp); +} diff --git a/node-graph/nodes/brush/src/airbrush/stroke.rs b/node-graph/nodes/brush/src/airbrush/stroke.rs new file mode 100644 index 0000000000..4a715c1a64 --- /dev/null +++ b/node-graph/nodes/brush/src/airbrush/stroke.rs @@ -0,0 +1,222 @@ +use super::consts::{RIDGE_GAIN, SIGMA_CUTOFF, SIGMA_PER_DIAMETER}; +use brush_types::{Sample, Stroke}; +use bytemuck::{Pod, Zeroable}; +use core_types::Color; +use core_types::math::bbox::AxisAlignedBbox; +use glam::DVec2; + +const MIN_SIGMA: f32 = f32::EPSILON; +const MAX_EDGE_SHIFT: f32 = 0.25; + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)] +pub(super) struct Edge { + a: [f32; 2], + b: [f32; 2], + sigma: f32, + weight: f32, +} + +pub(super) struct StyledStroke { + pub(super) color: Color, + pub(super) diameter: f64, + pub(super) hardness: f64, + pub(super) flow: f64, + pub(super) stroke: Stroke, +} + +#[derive(Clone, Copy)] +struct Dab { + position: DVec2, + sigma: f32, + weight: f32, +} + +fn dab(sample: &Sample, stroke: &StyledStroke) -> Dab { + let pressure = sample.pressure.clamp(0., 1.); + let flow = stroke.flow.clamp(0., 1.) as f32; + Dab { + position: sample.position, + sigma: (stroke.diameter.max(0.) * SIGMA_PER_DIAMETER) as f32 * pressure, + weight: -(1. - flow * (1. - (-RIDGE_GAIN).exp())).ln() / RIDGE_GAIN, + } +} + +fn dab_pad(dab: Dab, scale: f64) -> AxisAlignedBbox { + let sigma = (dab.sigma as f64).max(MIN_SIGMA as f64 / scale); + let pad = DVec2::splat(SIGMA_CUTOFF as f64 * sigma + 1f64.max(1. / scale)); + AxisAlignedBbox { + start: dab.position - pad, + end: dab.position + pad, + } +} + +pub(super) fn union(bounds: &mut Option, other: AxisAlignedBbox) { + *bounds = Some(match bounds.take() { + Some(existing) => existing.union(&other), + None => other, + }); +} + +pub(super) fn bounds(stroke: &StyledStroke, scale: f64) -> AxisAlignedBbox { + let mut bounds = None; + for sample in stroke.stroke.samples() { + union(&mut bounds, dab_pad(dab(&sample, stroke), scale)); + } + bounds.unwrap_or(AxisAlignedBbox::ZERO) +} + +pub(super) struct Update { + pub(super) committed: Vec, + pub(super) tail: Vec, +} + +#[derive(Clone)] +pub(super) struct Walk { + sigma_min: f32, + kept_last: Option, + kept: usize, + pub(super) consumed: usize, +} + +impl Default for Walk { + fn default() -> Self { + Self { + sigma_min: f32::MAX, + kept_last: None, + kept: 0, + consumed: 0, + } + } +} + +impl Walk { + fn advance(&mut self, stroke: &StyledStroke, scale: f64) -> Vec { + let mut kept = Vec::new(); + for index in self.consumed..stroke.stroke.len() { + let sample = stroke.stroke.sample(index); + let dab = dab(&sample, stroke); + self.sigma_min = self.sigma_min.min(dab.sigma); + let min_step = (self.sigma_min as f64 * 0.5).max(0.5 / scale); + if self.kept_last.is_none_or(|last| last.position.distance(dab.position) >= min_step) { + kept.push(dab); + self.kept_last = Some(dab); + self.kept += 1; + } + } + self.consumed = stroke.stroke.len(); + kept + } + + fn tail(&self, stroke: &StyledStroke) -> Option<(Dab, Dab)> { + let kept_last = self.kept_last?; + let dab = dab(&stroke.stroke.sample(stroke.stroke.len() - 1), stroke); + if dab.position == kept_last.position { + return (self.kept == 1).then_some((kept_last, kept_last)); + } + Some((kept_last, dab)) + } + + pub(super) fn update(&mut self, stroke: &StyledStroke, region: &super::region::Region) -> Update { + let previous = self.kept_last; + let kept = self.advance(stroke, region.scale); + let tail = self.tail(stroke); + Update { + committed: edges(region, previous, &kept, None), + tail: edges(region, None, &[], tail), + } + } +} + +fn texel(region: &super::region::Region, p: DVec2) -> [f32; 2] { + [((p.x - region.min.x) * region.scale) as f32, ((p.y - region.min.y) * region.scale) as f32] +} + +fn edge(region: &super::region::Region, a: Dab, b: Dab) -> Edge { + Edge { + a: texel(region, a.position), + b: texel(region, b.position), + sigma: ((a.sigma + b.sigma) / 2. * region.scale as f32).max(MIN_SIGMA), + weight: (a.weight + b.weight) / 2., + } +} + +fn mix(a: Dab, b: Dab, t: f32) -> Dab { + Dab { + position: a.position.lerp(b.position, t as f64), + sigma: a.sigma + (b.sigma - a.sigma) * t, + weight: a.weight + (b.weight - a.weight) * t, + } +} + +fn segment_edges(edges: &mut Vec, region: &super::region::Region, a: Dab, b: Dab) { + let scale = region.scale as f32; + let gradient = (a.sigma.min(b.sigma) * scale).max(1.); + let shift = (b.sigma - a.sigma).abs() * scale * SIGMA_CUTOFF; + let pieces = (shift / (MAX_EDGE_SHIFT * gradient)).ceil().clamp(1., 64.) as usize; + let mut previous = a; + for piece in 1..=pieces { + let next = if piece == pieces { b } else { mix(a, b, piece as f32 / pieces as f32) }; + edges.push(edge(region, previous, next)); + previous = next; + } +} + +fn edges(region: &super::region::Region, prev: Option, kept: &[Dab], tail: Option<(Dab, Dab)>) -> Vec { + let mut edges = Vec::with_capacity(kept.len() + 1); + let mut last = prev; + for &dab in kept { + if let Some(previous) = last { + segment_edges(&mut edges, region, previous, dab); + } + last = Some(dab); + } + if let Some((a, b)) = tail { + segment_edges(&mut edges, region, a, b); + } + edges +} + +#[cfg(test)] +mod tests { + use super::*; + use glam::{DVec2, UVec2}; + + fn stroke(points: &[[f64; 2]]) -> StyledStroke { + StyledStroke { + color: Color::BLACK, + diameter: 20., + hardness: 0.8, + flow: 1., + stroke: Stroke { + position: points.iter().copied().map(DVec2::from).collect(), + ..Default::default() + }, + } + } + + fn region() -> super::super::region::Region { + super::super::region::Region { + min: DVec2::ZERO, + scale: 2., + size: UVec2::splat(512), + } + } + + #[test] + fn chunked_walk_matches_whole_stroke() { + let partial = stroke(&[[10., 10.], [15., 12.], [20., 15.]]); + let complete = stroke(&[[10., 10.], [15., 12.], [20., 15.], [31., 19.], [45., 24.]]); + let region = region(); + + let mut chunked = Walk::default(); + let first = chunked.update(&partial, ®ion); + let second = chunked.update(&complete, ®ion); + let mut committed = first.committed; + committed.extend(second.committed); + + let whole = Walk::default().update(&complete, ®ion); + assert_eq!(committed, whole.committed); + assert_eq!(second.tail, whole.tail); + } +} diff --git a/node-graph/nodes/brush/src/lib.rs b/node-graph/nodes/brush/src/lib.rs index 8d35ac799a..0b6beb5458 100644 --- a/node-graph/nodes/brush/src/lib.rs +++ b/node-graph/nodes/brush/src/lib.rs @@ -3,20 +3,26 @@ use core_types::registry::types::Percentage; use core_types::{Color, Ctx}; use graphic_types::Graphic; +pub mod airbrush; pub mod brush; mod brush_cache; pub mod brush_stroke; pub use brush_types::*; +pub(crate) const DEFAULT_DIAMETER: f64 = 40.; +pub(crate) const DEFAULT_HARDNESS: f64 = 0.; +pub(crate) const DEFAULT_FLOW: f64 = 100.; +pub(crate) const DEFAULT_COLOR: Color = Color::BLACK; + #[node_macro::node(category("Raster: Brush"))] fn brush_strokes( _: impl Ctx, strokes: List, color: List, - #[default(40.)] diameter: f64, - #[default(0.)] hardness: Percentage, - #[default(100.)] flow: Percentage, + #[default(DEFAULT_DIAMETER)] diameter: f64, + #[default(DEFAULT_HARDNESS)] hardness: Percentage, + #[default(DEFAULT_FLOW)] flow: Percentage, ) -> List { List::new_from_item( Item::new_from_element(Graphic::from(strokes)) From f5afc33fd2f218ba9c3412866cef0104f784ee1e Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 20 Aug 2026 13:28:58 +0000 Subject: [PATCH 3/4] Rework the brush tool around the GPU airbrush --- Cargo.lock | 18 +- editor/src/consts.rs | 4 +- .../document/document_message_handler.rs | 5 +- .../graph_operation_message.rs | 19 +- .../graph_operation_message_handler.rs | 51 +- .../preferences_message_handler.rs | 1 + .../messages/tool/tool_messages/brush_tool.rs | 437 +++++++++++------- 7 files changed, 358 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e2d844b6c..9f432f3b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4165,20 +4165,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", - "thiserror 2.0.18", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -4186,9 +4185,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -4199,12 +4198,11 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", - "sha2", ] [[package]] diff --git a/editor/src/consts.rs b/editor/src/consts.rs index a014e8891f..9913023b56 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -135,7 +135,9 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.; // BRUSH TOOL pub const BRUSH_SIZE_CHANGE_KEYBOARD: f64 = 5.; -pub const DEFAULT_BRUSH_SIZE: f64 = 20.; +pub const BRUSH_SIZE_DEFAULT: f64 = 40.; +pub const BRUSH_HARDNESS_DEFAULT: f64 = 0.; +pub const BRUSH_FLOW_DEFAULT: f64 = 100.; // EYEDROPPER TOOL pub const EYEDROPPER_PREVIEW_AREA_RESOLUTION: u32 = 11; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index cb4dacbc06..c305de1853 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1065,7 +1065,10 @@ impl MessageHandler> for DocumentMes (true, Some(storage)) => storage .export_to_bytes( document_format::ExportFormat::Xz, - document_format::ExportOptions::default(), + document_format::ExportOptions { + include_history: false, + ..Default::default() + }, export_load_handle.as_ref(), Some(&legacy_document), ) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 7d986bce1b..b1a7feb6fd 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -5,7 +5,6 @@ use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; use graphene_std::Color; -use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; @@ -79,9 +78,23 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, modification_type: VectorModificationType, }, - Brush { + NewBrushGroupLayer { + id: NodeId, + strokes_node_id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + color: Color, + diameter: f64, + hardness: f64, + flow: f64, + }, + NewBrushStrokesNode { layer: LayerNodeIdentifier, - strokes: Vec, + strokes_node_id: NodeId, + color: Color, + diameter: f64, + hardness: f64, + flow: f64, }, SetUpstreamToChain { layer: LayerNodeIdentifier, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 4e64599e79..093e80bc93 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -2,6 +2,7 @@ use super::transform_utils; use super::utility_types::ModifyInputsContext; use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; @@ -9,6 +10,7 @@ use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::get_clip_mode; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::descriptor; +use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::list::List; use graphene_std::renderer::convert_usvg_path::convert_usvg_path; @@ -126,10 +128,35 @@ impl MessageHandler> for modify_inputs.vector_modify(modification_type); } } - GraphOperationMessage::Brush { layer, strokes } => { - if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { - modify_inputs.brush_modify(strokes); - } + GraphOperationMessage::NewBrushGroupLayer { + id, + strokes_node_id, + parent, + insert_index, + color, + diameter, + hardness, + flow, + } => { + let layer = ModifyInputsContext::new(network_interface, responses).create_layer(id); + insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow); + + responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index }); + responses.add(GraphOperationMessage::SetUpstreamToChain { layer }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + GraphOperationMessage::NewBrushStrokesNode { + layer, + strokes_node_id, + color, + diameter, + hardness, + flow, + } => { + insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow); + + responses.add(GraphOperationMessage::SetUpstreamToChain { layer }); + responses.add(NodeGraphMessage::RunDocumentGraph); } GraphOperationMessage::SetUpstreamToChain { layer } => { let Some(OutputConnector::Node { node_id: first_chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(layer.to_node(), 1), &[]) else { @@ -691,6 +718,22 @@ fn import_usvg_node_inner( } } +fn insert_brush_strokes_chain(network_interface: &mut NodeNetworkInterface, layer: LayerNodeIdentifier, strokes_node_id: NodeId, color: Color, diameter: f64, hardness: f64, flow: f64) { + let Some(strokes_node_type) = resolve_proto_node_type(graphene_std::brush::brush_strokes::IDENTIFIER) else { + log::error!("Brush strokes node does not exist"); + return; + }; + let strokes_node = strokes_node_type.node_template_input_override([ + Some(NodeInput::value(TaggedValue::Strokes(Vec::new()), false)), + Some(NodeInput::value(TaggedValue::Color(Some(color)), false)), + Some(NodeInput::value(TaggedValue::F64(diameter), false)), + Some(NodeInput::value(TaggedValue::F64(hardness), false)), + Some(NodeInput::value(TaggedValue::F64(flow), false)), + ]); + network_interface.insert_node(strokes_node_id, strokes_node, &[]); + network_interface.set_input(&InputConnector::node(layer.to_node(), 1), NodeInput::node(strokes_node_id, 0), &[]); +} + /// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer. fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap) { let subpaths = convert_usvg_path(path); diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 82a86d0829..58bfcc5035 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -93,6 +93,7 @@ impl MessageHandler> for Prefe zoom_with_scroll: self.zoom_with_scroll, }); responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale }); + responses.add(ToolMessage::RefreshToolShelf); } PreferencesMessage::ResetToDefaults => { responses.add(PreferencesMessage::Load { preferences: Self::default() }); diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index e166f696a1..21b1e0e55e 100644 --- a/editor/src/messages/tool/tool_messages/brush_tool.rs +++ b/editor/src/messages/tool/tool_messages/brush_tool.rs @@ -1,26 +1,20 @@ use super::tool_prelude::*; -use crate::consts::DEFAULT_BRUSH_SIZE; +use crate::application::generate_uuid; +use crate::consts::{BRUSH_FLOW_DEFAULT, BRUSH_HARDNESS_DEFAULT, BRUSH_SIZE_DEFAULT}; use crate::messages::portfolio::document::graph_operation::transform_utils::get_current_transform; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::FlowType; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid}; -use graph_craft::document::NodeId; +use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, OutputConnector}; +use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, selection_changed_since_last_sync, solid}; use graph_craft::document::value::TaggedValue; +use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; -use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle}; -use graphene_std::raster::BlendMode; +use graphene_std::brush::airbrush::airbrush as active_brush; +use graphene_std::brush::{Channel, Stroke}; use graphene_std::vector::style::{FillChoice, FillChoiceUI}; -const BRUSH_MAX_SIZE: f64 = 5000.; - -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum DrawMode { - Draw = 0, - Erase, - Restore, -} +const SAMPLE_MERGE_DISTANCE: f64 = 1.; +const SAMPLE_MERGE_PRESSURE: f64 = 0.01; #[derive(Default, ExtractField)] pub struct BrushTool { @@ -33,32 +27,35 @@ pub struct BrushOptions { diameter: f64, hardness: f64, flow: f64, - spacing: f64, color: ToolColorOptions, - blend_mode: BlendMode, - draw_mode: DrawMode, + last_synced_selection: Vec, } impl Default for BrushOptions { fn default() -> Self { Self { - diameter: DEFAULT_BRUSH_SIZE, - hardness: 0., - flow: 100., - spacing: 20., + diameter: BRUSH_SIZE_DEFAULT, + hardness: BRUSH_HARDNESS_DEFAULT, + flow: BRUSH_FLOW_DEFAULT, color: ToolColorOptions::default(), - blend_mode: BlendMode::Normal, - draw_mode: DrawMode::Draw, + last_synced_selection: Vec::new(), } } } +impl BrushOptions { + fn active_color(&self) -> Color { + self.color.active_color().unwrap_or_default() + } +} + #[impl_message(Message, ToolMessage, Brush)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum BrushToolMessage { // Standard messages Abort, + SelectionChanged, WorkingColorChanged, // Tool-specific messages @@ -71,14 +68,11 @@ pub enum BrushToolMessage { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum BrushToolMessageOptionsUpdate { - BlendMode(BlendMode), ChangeDiameter(f64), Color(Option), Diameter(f64), - DrawMode(DrawMode), - Flow(f64), Hardness(f64), - Spacing(f64), + Flow(f64), WorkingColorsChanged, } @@ -103,7 +97,7 @@ impl ToolMetadata for BrushTool { impl LayoutHolder for BrushTool { fn layout(&self) -> Layout { - let mut widgets = vec![ + let widgets = vec![ ColorInput::new(FillChoiceUI::from(self.options.color.fill_choice.as_ref().unwrap_or(&FillChoice::None))) .mixed(self.options.color.fill_choice.is_none()) .narrow(true) @@ -118,7 +112,6 @@ impl LayoutHolder for BrushTool { NumberInput::new(Some(self.options.diameter)) .label("Diameter") .min(1.) - .max(BRUSH_MAX_SIZE) /* Anything bigger would cause the application to be unresponsive and eventually die */ .unit(" px") .on_update(|number_input: &NumberInput| BrushToolMessage::UpdateOptions { options: BrushToolMessageOptionsUpdate::Diameter(number_input.value.unwrap()) }.into()) .widget_instance(), @@ -150,63 +143,8 @@ impl LayoutHolder for BrushTool { .into() }) .widget_instance(), - Separator::new(SeparatorStyle::Related).widget_instance(), - NumberInput::new(Some(self.options.spacing)) - .label("Spacing") - .min(1.) - .max(100.) - .mode_range() - .unit("%") - .on_update(|number_input: &NumberInput| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::Spacing(number_input.value.unwrap()), - } - .into() - }) - .widget_instance(), ]; - widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - let draw_mode_entries: Vec<_> = [DrawMode::Draw, DrawMode::Erase, DrawMode::Restore] - .into_iter() - .map(|draw_mode| { - RadioEntryData::new(format!("{draw_mode:?}")).label(format!("{draw_mode:?}")).on_update(move |_| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::DrawMode(draw_mode), - } - .into() - }) - }) - .collect(); - widgets.push(RadioInput::new(draw_mode_entries).selected_index(Some(self.options.draw_mode as u32)).widget_instance()); - - widgets.push(Separator::new(SeparatorStyle::Related).widget_instance()); - - let blend_mode_entries: Vec> = BlendMode::list() - .iter() - .map(|section| { - section - .iter() - .map(|blend_mode| { - MenuListEntry::new(format!("{blend_mode:?}")).label(blend_mode.to_string()).on_commit(|_| { - BrushToolMessage::UpdateOptions { - options: BrushToolMessageOptionsUpdate::BlendMode(*blend_mode), - } - .into() - }) - }) - .collect() - }) - .collect(); - widgets.push( - DropdownInput::new(blend_mode_entries) - .selected_index(self.options.blend_mode.index_in_list().map(|index| index as u32)) - .tooltip_description("The blend mode used with the background when performing a brush stroke. Only used in draw mode.") - .disabled(self.options.draw_mode != DrawMode::Draw) - .widget_instance(), - ); - Layout(vec![LayoutGroup::row(widgets)]) } } @@ -214,12 +152,18 @@ impl LayoutHolder for BrushTool { #[message_handler_data] impl<'a> MessageHandler> for BrushTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + if matches!(&message, ToolMessage::Brush(BrushToolMessage::SelectionChanged)) { + if self.fsm_state == BrushToolFsmState::Ready && selection_changed_since_last_sync(&mut self.options.last_synced_selection, context.document) { + self.sync_options_from_selection(context.document, responses); + } + return; + } + let ToolMessage::Brush(BrushToolMessage::UpdateOptions { options }) = message else { self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, true); return; }; match options { - BrushToolMessageOptionsUpdate::BlendMode(blend_mode) => self.options.blend_mode = blend_mode, BrushToolMessageOptionsUpdate::ChangeDiameter(change) => { let needs_rounding = ((self.options.diameter + change.abs() / 2.) % change.abs() - change.abs() / 2.).abs() > 0.5; if needs_rounding && change > 0. { @@ -233,12 +177,9 @@ impl<'a> MessageHandler> for Brus self.send_layout(responses, LayoutTarget::ToolOptions); } BrushToolMessageOptionsUpdate::Diameter(diameter) => self.options.diameter = diameter, - BrushToolMessageOptionsUpdate::DrawMode(draw_mode) => self.options.draw_mode = draw_mode, BrushToolMessageOptionsUpdate::Hardness(hardness) => self.options.hardness = hardness, BrushToolMessageOptionsUpdate::Flow(flow) => self.options.flow = flow, - BrushToolMessageOptionsUpdate::Spacing(spacing) => self.options.spacing = spacing, BrushToolMessageOptionsUpdate::Color(color) => { - // User picked a color: push to the global primary working color (no tool-local customization). if let Some(color) = color { responses.add(ToolMessage::SelectWorkingColor { color, primary: true }); } @@ -272,30 +213,116 @@ impl ToolTransition for BrushTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { tool_abort: Some(BrushToolMessage::Abort.into()), + selection_changed: Some(BrushToolMessage::SelectionChanged.into()), working_color_changed: Some(BrushToolMessage::WorkingColorChanged.into()), ..Default::default() } } } +impl BrushTool { + fn sync_options_from_selection(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + let Some(strokes_node) = selected_strokes_node(document) else { return }; + let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else { + return; + }; + let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value()); + if let Some(TaggedValue::F64(diameter)) = value(STROKES_DIAMETER_INPUT) { + self.options.diameter = *diameter; + } + if let Some(TaggedValue::F64(hardness)) = value(STROKES_HARDNESS_INPUT) { + self.options.hardness = *hardness; + } + if let Some(TaggedValue::F64(flow)) = value(STROKES_FLOW_INPUT) { + self.options.flow = *flow; + } + if let Some(TaggedValue::Color(Some(color))) = value(STROKES_COLOR_INPUT) + && *color != self.options.active_color() + { + responses.add(ToolMessage::SelectWorkingColor { color: *color, primary: true }); + } else { + self.send_layout(responses, LayoutTarget::ToolOptions); + } + } +} + +const STROKES_COLOR_INPUT: usize = 1; +const STROKES_DIAMETER_INPUT: usize = 2; +const STROKES_HARDNESS_INPUT: usize = 3; +const STROKES_FLOW_INPUT: usize = 4; + #[derive(Clone, Debug, Default)] struct BrushToolData { - strokes: Vec, + stroke: Stroke, + stroke_node_id: Option, + strokes_before: Vec, layer: Option, transform: DAffine2, + start_time: f64, + last_sample: (DVec2, Option), +} + +enum BrushTarget { + Existing { strokes_node_id: NodeId, strokes: Vec }, + NewGroup { parent: LayerNodeIdentifier, insert_index: usize }, + FillEmpty { layer: LayerNodeIdentifier }, } impl BrushToolData { - fn load_existing_strokes(&mut self, document: &DocumentMessageHandler) -> Option { - self.transform = DAffine2::IDENTITY; + fn resolve_target(&mut self, document: &DocumentMessageHandler, options: &BrushOptions) -> Option<(LayerNodeIdentifier, BrushTarget)> { + self.layer = None; + + let selected_nodes = document.network_interface.selected_nodes(); + let mut selected_layers = selected_nodes.selected_layers(document.metadata()); + let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?; + + if self.load_brush_layer(document, selected_layer) { + return Some(( + selected_layer, + BrushTarget::NewGroup { + parent: selected_layer, + insert_index: 0, + }, + )); + } - if document.network_interface.selected_nodes().selected_layers(document.metadata()).count() != 1 { + let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?; + if !self.load_brush_layer(document, parent) { return None; } - let layer = document.network_interface.selected_nodes().selected_layers(document.metadata()).next()?; - self.layer = Some(layer); - for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::HorizontalFlow) { + let Some(output) = document.network_interface.upstream_output_connector(&InputConnector::node(selected_layer.to_node(), 1), &[]) else { + return Some((parent, BrushTarget::FillEmpty { layer: selected_layer })); + }; + + let new_group = || { + let insert_index = parent.children(document.metadata()).position(|child| child == selected_layer).unwrap_or_default(); + BrushTarget::NewGroup { parent, insert_index } + }; + let OutputConnector::Node { node_id: strokes_node_id, .. } = output else { + return Some((parent, new_group())); + }; + if document.network_interface.reference(&strokes_node_id, &[]) != Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER)) { + return Some((parent, new_group())); + } + let strokes = document + .network_interface + .document_network() + .nodes + .get(&strokes_node_id) + .and_then(|node| node.inputs.first()) + .and_then(|input| input.as_value()) + .and_then(|value| if let TaggedValue::Strokes(strokes) = value { Some(strokes.clone()) } else { None }); + match strokes { + Some(strokes) if Self::style_matches(document, strokes_node_id, options) => Some((parent, BrushTarget::Existing { strokes_node_id, strokes })), + _ => Some((parent, new_group())), + } + } + + fn load_brush_layer(&mut self, document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool { + self.transform = DAffine2::IDENTITY; + + for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow) { let Some(node) = document.network_interface.document_network().nodes.get(&node_id) else { continue; }; @@ -303,12 +330,9 @@ impl BrushToolData { continue; }; - if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && node_id != layer.to_node() { - let points_input = node.inputs.get(1)?; - let Some(TaggedValue::BrushStrokes(strokes)) = points_input.as_value() else { continue }; - self.strokes = strokes.clone(); - - return Some(layer); + if reference == DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER) && node_id != candidate.to_node() { + self.layer = Some(candidate); + return true; } if reference == DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER) { @@ -317,13 +341,39 @@ impl BrushToolData { } self.transform = DAffine2::IDENTITY; - None + false + } + + fn style_matches(document: &DocumentMessageHandler, strokes_node: NodeId, options: &BrushOptions) -> bool { + let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else { + return false; + }; + let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value()); + matches!(value(STROKES_COLOR_INPUT), Some(TaggedValue::Color(color)) if *color == Some(options.active_color())) + && matches!(value(STROKES_DIAMETER_INPUT), Some(TaggedValue::F64(diameter)) if *diameter == options.diameter) + && matches!(value(STROKES_HARDNESS_INPUT), Some(TaggedValue::F64(hardness)) if *hardness == options.hardness) + && matches!(value(STROKES_FLOW_INPUT), Some(TaggedValue::F64(flow)) if *flow == options.flow) + } + + fn push_sample(&mut self, position: DVec2, pressure: Option, elapsed_seconds: f64) { + self.stroke.position.push(position); + if let Channel::Samples(times) = &mut self.stroke.time { + times.push(elapsed_seconds); + } + if let Channel::Samples(pressures) = &mut self.stroke.pressure { + pressures.push(pressure.unwrap_or(1.) as f32); + } } - fn update_strokes(&self, responses: &mut VecDeque) { - let Some(layer) = self.layer else { return }; - let strokes = self.strokes.clone(); - responses.add(GraphOperationMessage::Brush { layer, strokes }); + fn update_stroke(&self, responses: &mut VecDeque) { + let Some(stroke_node_id) = self.stroke_node_id else { return }; + let mut strokes = self.strokes_before.clone(); + strokes.push(self.stroke.clone()); + responses.add(NodeGraphMessage::SetInputValue { + node_id: stroke_node_id, + input_index: 0, + value: TaggedValue::Strokes(strokes), + }); } } @@ -345,60 +395,97 @@ impl Fsm for BrushToolFsmState { match (self, event) { (BrushToolFsmState::Ready, BrushToolMessage::DragStart) => { responses.add(DocumentMessage::StartTransaction); - let loaded_layer = tool_data.load_existing_strokes(document); - - if let Some(layer) = loaded_layer { - let pos = document - .network_interface - .document_metadata() - .downstream_transform_to_viewport(layer) - .inverse() - .transform_point2(input.mouse.position); - let layer_position = tool_data.transform.inverse().transform_point2(pos); - let layer_document_scale = document.metadata().downstream_transform_to_viewport(layer) * tool_data.transform; - - // TODO: Also scale it based on the input image ('Background' input). - // TODO: Resizing the input image results in a different brush size from the chosen diameter. - let layer_scale = 0.0001_f64 // Safety against division by zero - .max((layer_document_scale.matrix2 * glam::DVec2::X).length()) - .max((layer_document_scale.matrix2 * glam::DVec2::Y).length()); - - // Start a new stroke with a single sample - let blend_mode = match tool_options.draw_mode { - DrawMode::Draw => tool_options.blend_mode, - DrawMode::Erase => BlendMode::Erase, - DrawMode::Restore => BlendMode::Restore, - }; - tool_data.strokes.push(BrushStroke { - trace: vec![BrushInputSample { position: layer_position }], - style: BrushStyle { - color: tool_options.color.active_color().unwrap_or_default(), - diameter: tool_options.diameter / layer_scale, - hardness: tool_options.hardness, - flow: tool_options.flow, - spacing: tool_options.spacing, - blend_mode, - }, - }); - tool_data.update_strokes(responses); - BrushToolFsmState::Drawing - } - // Create the new layer, wait for the render output to return its transform, and then create the rest of the layer - else { + // A new brush layer needs a graph run before the stroke can start. + let Some((brush_layer, target)) = tool_data.resolve_target(document, tool_options) else { new_brush_layer(document, responses); responses.add(NodeGraphMessage::RunDocumentGraph); responses.add(DeferMessage::AfterGraphRun { messages: vec![BrushToolMessage::DragStart.into()], }); - BrushToolFsmState::Ready + return BrushToolFsmState::Ready; + }; + + let pos = document + .network_interface + .document_metadata() + .downstream_transform_to_viewport(brush_layer) + .inverse() + .transform_point2(input.mouse.position); + let layer_position = tool_data.transform.inverse().transform_point2(pos); + + let pressure = input.mouse.pressure; + tool_data.start_time = input.mouse.time.unwrap_or(input.time as f64); + tool_data.stroke = Stroke { + time: Channel::Samples(Vec::new()), + seed: generate_uuid(), + ..Default::default() + }; + if pressure.is_some() { + tool_data.stroke.pressure = Channel::Samples(Vec::new()); } + tool_data.push_sample(layer_position, pressure, 0.); + tool_data.last_sample = (input.mouse.position, pressure); + + match target { + BrushTarget::Existing { strokes_node_id, strokes } => { + tool_data.stroke_node_id = Some(strokes_node_id); + tool_data.strokes_before = strokes; + } + BrushTarget::NewGroup { parent, insert_index } => { + let group_id = NodeId::new(); + let strokes_node_id = NodeId::new(); + tool_data.stroke_node_id = Some(strokes_node_id); + tool_data.strokes_before = Vec::new(); + responses.add(GraphOperationMessage::NewBrushGroupLayer { + id: group_id, + strokes_node_id, + parent, + insert_index, + color: tool_options.active_color(), + diameter: tool_options.diameter, + hardness: tool_options.hardness, + flow: tool_options.flow, + }); + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![group_id] }); + } + BrushTarget::FillEmpty { layer } => { + let strokes_node_id = NodeId::new(); + tool_data.stroke_node_id = Some(strokes_node_id); + tool_data.strokes_before = Vec::new(); + responses.add(GraphOperationMessage::NewBrushStrokesNode { + layer, + strokes_node_id, + color: tool_options.active_color(), + diameter: tool_options.diameter, + hardness: tool_options.hardness, + flow: tool_options.flow, + }); + } + } + tool_data.update_stroke(responses); + + BrushToolFsmState::Drawing } (BrushToolFsmState::Drawing, BrushToolMessage::PointerMove) => { - if let Some(layer) = tool_data.layer - && let Some(stroke) = tool_data.strokes.last_mut() - { + let pressure = input.mouse.pressure; + + if pressure == Some(0.) { + return BrushToolFsmState::Drawing; + } + + let (last_position, last_pressure) = tool_data.last_sample; + let moved = input.mouse.position.distance(last_position) >= SAMPLE_MERGE_DISTANCE; + let pressure_changed = match (pressure, last_pressure) { + (Some(pressure), Some(last_pressure)) => (pressure - last_pressure).abs() >= SAMPLE_MERGE_PRESSURE, + (pressure, last_pressure) => pressure.is_some() != last_pressure.is_some(), + }; + if !moved && !pressure_changed { + return BrushToolFsmState::Drawing; + } + + if let Some(layer) = tool_data.layer { let layer_position = document .network_interface .document_metadata() @@ -407,26 +494,32 @@ impl Fsm for BrushToolFsmState { .transform_point2(input.mouse.position); let layer_position = tool_data.transform.inverse().transform_point2(layer_position); - stroke.trace.push(BrushInputSample { position: layer_position }) + let elapsed = (input.mouse.time.unwrap_or(input.time as f64) - tool_data.start_time).max(0.) / 1000.; + tool_data.push_sample(layer_position, pressure, elapsed); + tool_data.last_sample = (input.mouse.position, pressure); } - tool_data.update_strokes(responses); + tool_data.update_stroke(responses); BrushToolFsmState::Drawing } (BrushToolFsmState::Drawing, BrushToolMessage::DragStop) => { - if !tool_data.strokes.is_empty() { + if tool_data.stroke_node_id.is_some() { responses.add(DocumentMessage::EndTransaction); } else { responses.add(DocumentMessage::AbortTransaction); } - tool_data.strokes.clear(); + tool_data.stroke_node_id = None; + tool_data.stroke = Stroke::default(); + tool_data.strokes_before = Vec::new(); BrushToolFsmState::Ready } (BrushToolFsmState::Drawing, BrushToolMessage::Abort) => { responses.add(DocumentMessage::AbortTransaction); - tool_data.strokes.clear(); + tool_data.stroke_node_id = None; + tool_data.stroke = Stroke::default(); + tool_data.strokes_before = Vec::new(); BrushToolFsmState::Ready } @@ -457,12 +550,40 @@ impl Fsm for BrushToolFsmState { } } +fn selected_strokes_node(document: &DocumentMessageHandler) -> Option { + let selected_nodes = document.network_interface.selected_nodes(); + let mut selected_layers = selected_nodes.selected_layers(document.metadata()); + let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?; + + let group = if is_brush_layer(document, selected_layer) { + selected_layer.children(document.metadata()).next()? + } else { + let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?; + if !is_brush_layer(document, parent) { + return None; + } + selected_layer + }; + + let OutputConnector::Node { node_id, .. } = document.network_interface.upstream_output_connector(&InputConnector::node(group.to_node(), 1), &[])? else { + return None; + }; + (document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER))).then_some(node_id) +} + +fn is_brush_layer(document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool { + document + .network_interface + .upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow) + .any(|node_id| node_id != candidate.to_node() && document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER))) +} + fn new_brush_layer(document: &DocumentMessageHandler, responses: &mut VecDeque) -> LayerNodeIdentifier { responses.add(DocumentMessage::DeselectAllLayers); - let brush_node = resolve_proto_node_type(graphene_std::brush::brush::brush::IDENTIFIER) + let brush_node = resolve_proto_node_type(active_brush::IDENTIFIER) .expect("Brush node does not exist") - .default_node_template(); + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::BrushCache(Default::default()), false))]); let id = NodeId::new(); responses.add(GraphOperationMessage::NewCustomLayer { From 2fbce4d926d97739eea4a15b0bc4ad74f239eaf3 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 20 Aug 2026 13:29:44 +0000 Subject: [PATCH 4/4] Remove the CPU brush implementation --- Cargo.lock | 1 - .../document/graph_operation/utility_types.rs | 8 - .../document/node_graph/node_properties.rs | 29 -- .../messages/portfolio/document_migration.rs | 66 --- node-graph/graph-craft/src/document/value.rs | 17 - node-graph/graph-craft/src/proto.rs | 2 +- .../interpreted-executor/src/node_registry.rs | 3 - node-graph/nodes/brush/Cargo.toml | 3 +- node-graph/nodes/brush/src/brush.rs | 440 ------------------ node-graph/nodes/brush/src/brush_cache.rs | 116 ----- node-graph/nodes/brush/src/brush_stroke.rs | 108 ----- node-graph/nodes/brush/src/lib.rs | 30 -- 12 files changed, 2 insertions(+), 821 deletions(-) delete mode 100644 node-graph/nodes/brush/src/brush.rs delete mode 100644 node-graph/nodes/brush/src/brush_cache.rs delete mode 100644 node-graph/nodes/brush/src/brush_stroke.rs diff --git a/Cargo.lock b/Cargo.lock index 9f432f3b22..4b0e724b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,7 +379,6 @@ dependencies = [ "graphic-types", "half", "node-macro", - "raster-nodes", "raster-types", "serde", "tokio", diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 4920f0aeb5..90fceeea35 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -9,7 +9,6 @@ use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete, descriptor}; -use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::list::List; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; @@ -828,13 +827,6 @@ impl<'a> ModifyInputsContext<'a> { self.responses.add(NodeGraphMessage::RunDocumentGraph); } - pub fn brush_modify(&mut self, strokes: Vec) { - let Some(brush_node_id) = self.existing_proto_node_id(graphene_std::brush::brush::brush::IDENTIFIER, true) else { - return; - }; - self.set_input_with_refresh(InputConnector::node(brush_node_id, 1), NodeInput::value(TaggedValue::BrushStrokes(strokes), false), false); - } - pub fn resize_artboard(&mut self, location: DVec2, dimensions: DVec2) { let Some(artboard_node_id) = self.existing_network_node_id("Artboard", true) else { return; diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 36eb417ca3..559d429467 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -19,7 +19,6 @@ use graph_craft::{Type, concrete}; use graphene_std::Graphic; use graphene_std::NodeInputDecleration; use graphene_std::animation::RealTimeMode; -use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::list::List; @@ -251,7 +250,6 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::>() => array_of_number_widget(default_info, TextInput::default()).into(), Some(x) if x == TypeId::of::>() => color_widget(default_info, ColorInput::default().allow_none(true)), Some(x) if x == TypeId::of::>() => color_widget(default_info, ColorInput::default().allow_none(false)), - Some(x) if x == TypeId::of::>() => brush_strokes_widget(default_info).into(), // ============ // STRUCT TYPES // ============ @@ -449,33 +447,6 @@ pub fn vector_modification_widget(parameter_widgets_info: ParameterWidgetsInfo) widgets } -pub fn brush_strokes_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec { - let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info; - - let mut widgets = start_widgets(parameter_widgets_info); - - let Some(document_node) = document_node else { return widgets }; - let Some(input) = document_node.inputs.get(index) else { return widgets }; - - if let Some(TaggedValue::BrushStrokes(strokes)) = input.as_non_exposed_value() { - let stroke_count = strokes.len(); - let sample_count: usize = strokes.iter().map(|s| s.trace.len()).sum(); - let label = if stroke_count == 0 { - "Empty".to_string() - } else { - format!( - "{stroke_count} {} / {sample_count} {}", - if stroke_count == 1 { "Stroke" } else { "Strokes" }, - if sample_count == 1 { "Sample" } else { "Samples" } - ) - }; - - widgets.extend_from_slice(&[Separator::new(SeparatorStyle::Unrelated).widget_instance(), TextLabel::new(label).widget_instance()]); - } - - widgets -} - pub fn image_data_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec { let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info; diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index ec9483d4a2..95f0fdec9a 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -62,25 +62,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::raster::OpacityNode", "graphene_core::blending_nodes::OpacityNode"], }, // ================================ - // brush - // ================================ - NodeReplacement { - node: graphene_std::brush::brush::blit::IDENTIFIER, - aliases: &["graphene_brush::BlitNode", "graphene_std::brush::BlitNode", "graphene_brush::brush::BlitNode"], - }, - NodeReplacement { - node: graphene_std::brush::brush::brush::IDENTIFIER, - aliases: &["graphene_brush::BrushNode", "graphene_std::brush::BrushNode", "graphene_brush::brush::BrushNode"], - }, - NodeReplacement { - node: graphene_std::brush::brush::brush_stamp_generator::IDENTIFIER, - aliases: &[ - "graphene_brush::BrushStampGeneratorNode", - "graphene_std::brush::BrushStampGeneratorNode", - "graphene_brush::brush::BrushStampGeneratorNode", - ], - }, - // ================================ // gcore // ================================ NodeReplacement { @@ -1148,30 +1129,6 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } - // The "Brush" wrapper network was replaced with the `brush` proto node directly. Convert old `Network("Brush")` instances to the proto node, forwarding all 3 inputs (Background, Trace, Cache) one-to-one. - // This must run as a pre-pass before the recursive iteration below: replacing the outer Brush's network impl orphans its child paths, and the recursive iteration would log errors for those stale paths. - let brush_layers: Vec<(NodeId, Vec)> = document - .network_interface - .document_network() - .recursive_nodes() - .filter_map(|(node_id, _, path)| (document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::Network("Brush".into()))).then_some((*node_id, path))) - .collect(); - for (node_id, network_path) in &brush_layers { - // Pre-load `outward_wires` so the chain-break check inside `set_input` resolves the original upstream→node wire from cache - // rather than triggering a fresh rebuild from the (already-mutated) post-`replace_inputs` state, which would orphan wires. - let _ = document.network_interface.outward_wires(network_path); - let new_reference = DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER); - let Some(definition) = resolve_document_node_type(&new_reference) else { continue }; - let mut node_template = definition.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); - let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else { - continue; - }; - for (index, input) in old_inputs.iter().take(3).enumerate() { - document.network_interface.set_input(&InputConnector::node(*node_id, index), input.clone(), network_path); - } - } - // The "Transform" wrapper network was replaced with the `transform` proto node directly. Convert old `Network("Transform")` instances to the proto node, forwarding the 5 user-facing inputs (Value, Translation, Rotation, Scale, Skew) and dropping the legacy migration sentinels (Origin Offset, Scale Appearance) at indices 5 and 6 if present. // Pre-pass for the same reason as the Brush migration above: replacing the outer Transform's network impl orphans its child paths. let transform_layers: Vec<(NodeId, Vec, usize)> = document @@ -1963,29 +1920,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); } - // Old shape: [background, bounds, trace, cache]. Both "bounds" (input 1) and "cache" (input 3) are dropped, and "cache" is now stored as - // internal node state via `#[data]` on the brush node, so it is not a node input at all in the new shape. - if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 4 { - let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); - - let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - - document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); - document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[2].clone(), network_path); - } - - // Old shape: [background, trace, cache]. The "cache" input is dropped because the brush node now stores its cache as internal `#[data]` state. - if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 3 { - let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); - - let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - - document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); - document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); - } - if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::RemoveHandlesNode")) { let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::auto_tangents::IDENTIFIER))?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index c7c7c8c413..dd85069316 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,7 +2,6 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; -use brush_nodes::brush_stroke::BrushStroke; use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::List; @@ -70,10 +69,6 @@ macro_rules! tagged_value { #[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient_stops")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] Gradient(GradientStops), - /// Stored compactly as a `Vec`, materializes as `List` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. - #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code - #[serde(alias = "BrushStrokeTable")] - BrushStrokes(Vec), Strokes(Vec), BrushCache(BrushCache), // ======================= @@ -117,7 +112,6 @@ macro_rules! tagged_value { Self::F64Array(values) => values.cache_hash(state), Self::Color(color) => color.cache_hash(state), Self::Gradient(stops) => stops.cache_hash(state), - Self::BrushStrokes(strokes) => strokes.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), Self::BrushCache(cache) => cache.cache_hash(state), // ======================= @@ -163,10 +157,6 @@ macro_rules! tagged_value { Box::new(list) } Self::Gradient(stops) => Box::new(List::::new_from_element(stops)), - Self::BrushStrokes(strokes) => { - let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); - Box::new(list) - } Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Box::new(list) @@ -218,10 +208,6 @@ macro_rules! tagged_value { Arc::new(list) } Self::Gradient(stops) => Arc::new(List::::new_from_element(stops)), - Self::BrushStrokes(strokes) => { - let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); - Arc::new(list) - } Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Arc::new(list) @@ -257,7 +243,6 @@ macro_rules! tagged_value { Self::F64Array(_) => concrete!(List), Self::Color(_) => concrete!(List), Self::Gradient(_) => concrete!(List), - Self::BrushStrokes(_) => concrete!(List), Self::Strokes(_) => concrete!(List), Self::BrushCache(_) => concrete!(BrushCache), // ======================= @@ -336,7 +321,6 @@ macro_rules! tagged_value { if name == std::any::type_name::>() { return Some(TaggedValue::Gradient(GradientStops::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::F64Array(Vec::new())) } - if name == std::any::type_name::>() { return Some(TaggedValue::BrushStrokes(Vec::new())) } if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BrushCache(Default::default())) } @@ -368,7 +352,6 @@ macro_rules! tagged_value { Self::F64Array(values) => format!("F64Array({values:?})"), Self::Color(color) => format!("Color({color:?})"), Self::Gradient(stops) => format!("Gradient({stops:?})"), - Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), Self::BrushCache(cache) => format!("{cache:?}"), // ======================= diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index f87e75d5f2..03a0da7e2d 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -951,7 +951,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] + vec![NodeId(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 6f2c5fc6a5..e948e5e070 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -6,7 +6,6 @@ use graph_craft::document::value::RenderOutput; use graph_craft::proto::{NodeConstructor, TypeErasedBox}; use graphene_std::any::DynAnyNode; use graphene_std::application_io::Texture; -use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::gradient::GradientStops; use graphene_std::list::{AttributeDyn, AttributeValueDyn, List, ListDyn}; #[cfg(target_family = "wasm")] @@ -160,7 +159,6 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => ListDyn]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Graphic]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text::Font]), - async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DocumentNode]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::Footprint]), @@ -250,7 +248,6 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => glam::f32::Affine2]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::style::Stroke]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::Font]), - async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => DocumentNode]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::ContextFeatures]), diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index 3e1681b277..dcc767238e 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0" [features] default = ["serde"] -serde = ["dep:serde", "core-types/serde", "raster-types/serde", "raster-nodes/serde"] +serde = ["dep:serde", "core-types/serde", "raster-types/serde"] [dependencies] # Local dependencies @@ -18,7 +18,6 @@ core-types = { workspace = true } graphene-hash = { workspace = true } graphic-types = { workspace = true } raster-types = { workspace = true, features = ["wgpu"] } -raster-nodes = { workspace = true } wgpu-executor = { workspace = true } node-macro = { workspace = true } diff --git a/node-graph/nodes/brush/src/brush.rs b/node-graph/nodes/brush/src/brush.rs deleted file mode 100644 index 53b0ea0052..0000000000 --- a/node-graph/nodes/brush/src/brush.rs +++ /dev/null @@ -1,440 +0,0 @@ -use crate::brush_cache::BrushCache; -use crate::brush_stroke::{BrushStroke, BrushStyle}; -use core_types::blending::BlendMode; -use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::color::{Alpha, Color, Pixel, Sample}; -use core_types::generic::FnNode; -use core_types::list::{Item, List}; -use core_types::math::bbox::{AxisAlignedBbox, Bbox}; -use core_types::registry::FutureWrapperNode; -use core_types::transform::Transform; -use core_types::uuid::NodeId; -use core_types::value::ClonedNode; -use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM}; -use core_types::{Ctx, Node}; -use glam::{DAffine2, DVec2}; -use raster_nodes::blending_nodes::blend_colors; -use raster_nodes::std_nodes::{empty_image, extend_image_to_bounds}; -use raster_types::BitmapMut; -use raster_types::Image; -use raster_types::{CPU, Raster}; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct BrushStampGenerator { - color: P, - feather_exponent: f32, - transform: DAffine2, -} - -impl Transform for BrushStampGenerator

{ - fn transform(&self) -> DAffine2 { - self.transform - } -} - -impl Sample for BrushStampGenerator

{ - type Pixel = P; - - #[inline] - fn sample(&self, position: DVec2, area: DVec2) -> Option

{ - let position = self.transform.inverse().transform_point2(position); - let area = self.transform.inverse().transform_vector2(area); - let aa_blur_radius = area.length() as f32 * 2.; - let center = DVec2::splat(0.5); - - let distance = (position + area / 2. - center).length() as f32 * 2.; - - let edge_opacity = 1. - (1. - aa_blur_radius).powf(self.feather_exponent); - let result = if distance < 1. - aa_blur_radius { - 1. - distance.powf(self.feather_exponent) - } else if distance < 1. { - // TODO: Replace this with a proper analytical AA implementation - edge_opacity * ((1. - distance) / aa_blur_radius) - } else { - return None; - }; - - use core_types::color::Channel; - Some(self.color.multiplied_alpha(P::AlphaChannel::from_linear(result))) - } -} - -/// Controls the brush shape with diameter and hardness, plus color and opacity (via flow). -/// The feather exponent is calculated from hardness to determine edge softness. -/// Used internally to create the brush texture before stamping it repeatedly along a stroke path. -#[node_macro::node(category(""), skip_impl)] -fn brush_stamp_generator(#[unit(" px")] diameter: f64, color: Color, hardness: f64, flow: f64) -> BrushStampGenerator { - // Diameter - let radius = diameter / 2.; - - // Hardness - let hardness = hardness / 100.; - let feather_exponent = 1. / (1. - hardness) as f32; - - // Flow - let flow = flow / 100.; - - // Color - let color = color.apply_opacity(flow as f32); - - let transform = DAffine2::from_scale_angle_translation(DVec2::splat(diameter), 0., -DVec2::splat(radius)); - BrushStampGenerator { color, feather_exponent, transform } -} - -/// Used to efficiently paint brush strokes. Applies the same texture repeatedly at different positions with proper blending and boundary handling. -#[node_macro::node(category(""), skip_impl)] -fn blit(mut target: List>, texture: Raster, positions: Vec, blend_mode: BlendFn) -> List> -where - BlendFn: for<'any_input> Node<'any_input, (Color, Color), Output = Color>, -{ - if positions.is_empty() { - return target; - } - - let (elements, transforms) = target.element_and_attribute_slices_mut::(ATTR_TRANSFORM); - for (element, transform_attribute) in elements.iter_mut().zip(transforms.iter()) { - let target_width = element.width; - let target_height = element.height; - let target_size = DVec2::new(target_width as f64, target_height as f64); - - let texture_size = DVec2::new(texture.width as f64, texture.height as f64); - - let document_to_target = DAffine2::from_translation(-texture_size / 2.) * DAffine2::from_scale(target_size) * transform_attribute.inverse(); - - for position in &positions { - let start = document_to_target.transform_point2(*position).round(); - let stop = start + texture_size; - - // Half-open integer ranges [start, stop). - let clamp_start = start.clamp(DVec2::ZERO, target_size).as_uvec2(); - let clamp_stop = stop.clamp(DVec2::ZERO, target_size).as_uvec2(); - - let blit_area_offset = (clamp_start.as_dvec2() - start).as_uvec2().min(texture_size.as_uvec2()); - let blit_area_dimensions = (clamp_stop - clamp_start).min(texture_size.as_uvec2() - blit_area_offset); - - // Tight blitting loop. Eagerly assert bounds to hopefully eliminate bounds check inside loop. - let texture_index = |x: u32, y: u32| -> usize { (y as usize * texture.width as usize) + (x as usize) }; - let target_index = |x: u32, y: u32| -> usize { (y as usize * target_width as usize) + (x as usize) }; - - let max_y = (blit_area_offset.y + blit_area_dimensions.y).saturating_sub(1); - let max_x = (blit_area_offset.x + blit_area_dimensions.x).saturating_sub(1); - assert!(texture_index(max_x, max_y) < texture.data.len()); - assert!(target_index(max_x, max_y) < element.data.len()); - - for y in blit_area_offset.y..blit_area_offset.y + blit_area_dimensions.y { - for x in blit_area_offset.x..blit_area_offset.x + blit_area_dimensions.x { - let src_pixel = texture.data[texture_index(x, y)]; - let dst_pixel = &mut element.data_mut().data[target_index(x + clamp_start.x, y + clamp_start.y)]; - *dst_pixel = blend_mode.eval((src_pixel, *dst_pixel)); - } - } - } - } - - target -} - -pub async fn create_brush_texture(brush_style: &BrushStyle) -> Raster { - let stamp = brush_stamp_generator(brush_style.diameter, brush_style.color, brush_style.hardness, brush_style.flow); - let transform = DAffine2::from_scale_angle_translation(DVec2::splat(brush_style.diameter), 0., -DVec2::splat(brush_style.diameter / 2.)); - let blank_texture = empty_image((), transform, List::new_from_element(Color::TRANSPARENT)).into_iter().next().unwrap_or_default(); - let image = blend_stamp_closure(stamp, blank_texture, |a, b| blend_colors(a, b, BlendMode::Normal, 1.)); - - image.into_element() -} - -pub fn blend_with_mode(background: Item>, foreground: Item>, blend_mode: BlendMode, opacity: f64) -> Item> { - let opacity = opacity as f32 / 100.; - match std::hint::black_box(blend_mode) { - // Normal group - BlendMode::Normal => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Normal, opacity)), - // Darken group - BlendMode::Darken => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Darken, opacity)), - BlendMode::Multiply => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Multiply, opacity)), - BlendMode::ColorBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorBurn, opacity)), - BlendMode::LinearBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearBurn, opacity)), - BlendMode::DarkerColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::DarkerColor, opacity)), - // Lighten group - BlendMode::Lighten => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Lighten, opacity)), - BlendMode::Screen => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Screen, opacity)), - BlendMode::ColorDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorDodge, opacity)), - BlendMode::LinearDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearDodge, opacity)), - BlendMode::LighterColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LighterColor, opacity)), - // Contrast group - BlendMode::Overlay => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Overlay, opacity)), - BlendMode::SoftLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::SoftLight, opacity)), - BlendMode::HardLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardLight, opacity)), - BlendMode::VividLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::VividLight, opacity)), - BlendMode::LinearLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearLight, opacity)), - BlendMode::PinLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::PinLight, opacity)), - BlendMode::HardMix => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardMix, opacity)), - // Inversion group - BlendMode::Difference => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Difference, opacity)), - BlendMode::Exclusion => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Exclusion, opacity)), - BlendMode::Subtract => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Subtract, opacity)), - BlendMode::Divide => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Divide, opacity)), - // Component group - BlendMode::Hue => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Hue, opacity)), - BlendMode::Saturation => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Saturation, opacity)), - BlendMode::Color => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Color, opacity)), - BlendMode::Luminosity => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Luminosity, opacity)), - // Other utility blend modes (hidden from the normal list) - BlendMode::Erase => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Erase, opacity)), - BlendMode::Restore => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Restore, opacity)), - BlendMode::MultiplyAlpha => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, opacity)), - } -} - -/// Generates the brush strokes painted with the Brush tool as a raster image. -/// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed. -#[node_macro::node(category("Raster"))] -async fn brush( - _: impl Ctx, - /// Optional raster content that may be drawn onto. - mut background: List>, - /// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles. - trace: List, - /// Internal cache data used to accelerate rendering of the brush content. - #[data] - cache: BrushCache, -) -> List> { - if background.is_empty() { - background.push(Item::default()); - } - // TODO: Find a way to handle more than one item - let list_item = background.clone_item(0).expect("Expected the one item we just pushed"); - - let bounds = List::new_from_item(list_item.clone()).bounding_box(DAffine2::IDENTITY, false); - let [start, end] = if let RenderBoundingBox::Rectangle(rect) = bounds { rect } else { [DVec2::ZERO, DVec2::ZERO] }; - let background_bbox = AxisAlignedBbox { start, end }; - let stroke_bbox = trace.iter_element_values().map(|s| s.bounding_box()).reduce(|a, b| a.union(&b)).unwrap_or(AxisAlignedBbox::ZERO); - let bbox = if background_bbox.size().length() < 0.1 { - stroke_bbox - } else { - stroke_bbox.union(&background_bbox) - }; - let background_bounds = bbox.to_transform(); - - let mut draw_strokes: Vec<_> = trace - .iter_element_values() - .filter(|&s| !matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore)) - .cloned() - .collect(); - - let mut brush_plan = cache.compute_brush_plan(list_item, &draw_strokes); - - // TODO: Find a way to handle more than one item - let Some(mut actual_image) = extend_image_to_bounds((), List::new_from_item(brush_plan.background), background_bounds).into_iter().next() else { - return List::new(); - }; - - let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1); - for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() { - // Create brush texture. - // TODO: apply rotation from layer to stamp for non-rotationally-symmetric brushes. - let mut brush_texture = cache.get_cached_brush(&stroke.style); - if brush_texture.is_none() { - let tex = create_brush_texture(&stroke.style).await; - cache.store_brush(stroke.style.clone(), tex.clone()); - brush_texture = Some(tex); - } - let brush_texture = brush_texture.unwrap(); - - // Compute transformation from stroke texture space into layer space, and create the stroke texture. - let skip = if idx == 0 { brush_plan.first_stroke_point_skip } else { 0 }; - let positions: Vec<_> = stroke.compute_blit_points().into_iter().skip(skip).collect(); - let stroke_texture = if idx == 0 && positions.is_empty() { - core::mem::take(&mut brush_plan.first_stroke_texture) - } else { - let mut bbox = stroke.bounding_box(); - bbox.start = bbox.start.floor(); - bbox.end = bbox.end.floor(); - let stroke_size = bbox.size() + DVec2::splat(stroke.style.diameter); - // For numerical stability we want to place the first blit point at a stable, integer offset in layer space. - let snap_offset = positions[0].floor() - positions[0]; - let stroke_origin_in_layer = bbox.start - snap_offset - DVec2::splat(stroke.style.diameter / 2.); - let stroke_to_layer = DAffine2::from_translation(stroke_origin_in_layer) * DAffine2::from_scale(stroke_size); - - let normal_blend = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::Normal, 1.)); - let blit_node = BlitNode::new( - FutureWrapperNode::new(ClonedNode::new(brush_texture)), - FutureWrapperNode::new(ClonedNode::new(positions)), - FutureWrapperNode::new(ClonedNode::new(normal_blend)), - ); - let blit_target = if idx == 0 { - let target = core::mem::take(&mut brush_plan.first_stroke_texture); - extend_image_to_bounds((), List::new_from_item(target), stroke_to_layer) - } else { - empty_image((), stroke_to_layer, List::new_from_element(Color::TRANSPARENT)) - // EmptyImageNode::new(CopiedNode::new(stroke_to_layer), CopiedNode::new(Color::TRANSPARENT)).eval(()) - }; - - let list = blit_node.eval(blit_target).await; - assert_eq!(list.len(), 1); - list.into_iter().next().unwrap_or_default() - }; - - // Cache image before doing final blend, and store final stroke texture. - if idx == final_stroke_idx { - cache.cache_results(core::mem::take(&mut draw_strokes), actual_image.clone(), stroke_texture.clone()); - } - - // TODO: Is this the correct way to do opacity in blending? - actual_image = blend_with_mode(actual_image, stroke_texture, stroke.style.blend_mode, (stroke.style.color.a() * 100.) as f64); - } - - let has_erase_or_restore_strokes = trace.iter_element_values().any(|s| matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore)); - if has_erase_or_restore_strokes { - let opaque_image = Image::new(bbox.size().x as u32, bbox.size().y as u32, Color::WHITE); - let mut erase_restore_mask = Item::new_from_element(Raster::new_cpu(opaque_image)).with_attribute(ATTR_TRANSFORM, background_bounds); - - for stroke in trace.into_iter().map(|row| row.into_element()) { - let mut brush_texture = cache.get_cached_brush(&stroke.style); - if brush_texture.is_none() { - let tex = create_brush_texture(&stroke.style).await; - cache.store_brush(stroke.style.clone(), tex.clone()); - brush_texture = Some(tex); - } - let brush_texture = brush_texture.unwrap(); - let positions: Vec<_> = stroke.compute_blit_points().into_iter().collect(); - - // For mask composition: Erase subtracts alpha, Restore adds alpha, and Draw acts like Restore to allow repainting erased areas. - let mask_blend_mode = match stroke.style.blend_mode { - BlendMode::Erase => BlendMode::Erase, - BlendMode::Restore => BlendMode::Restore, - _ => BlendMode::Restore, - }; - - let blend_params = FnNode::new(move |(a, b)| blend_colors(a, b, mask_blend_mode, 1.)); - let blit_node = BlitNode::new( - FutureWrapperNode::new(ClonedNode::new(brush_texture)), - FutureWrapperNode::new(ClonedNode::new(positions)), - FutureWrapperNode::new(ClonedNode::new(blend_params)), - ); - erase_restore_mask = blit_node.eval(List::new_from_item(erase_restore_mask)).await.into_iter().next().unwrap_or_default(); - } - - let blend_params = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.)); - actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_params.eval((a, b))); - } - - let transform: DAffine2 = actual_image.attribute_cloned_or_default(ATTR_TRANSFORM); - let blend_mode: BlendMode = actual_image.attribute_cloned_or_default(ATTR_BLEND_MODE); - let opacity: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY, 1.); - let fill: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); - let clip: bool = actual_image.attribute_cloned_or_default(ATTR_CLIPPING_MASK); - let layer: List = actual_image.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH); - - *background.element_mut(0).unwrap() = actual_image.into_element(); - background.set_attribute(ATTR_TRANSFORM, 0, transform); - background.set_attribute(ATTR_BLEND_MODE, 0, blend_mode); - background.set_attribute(ATTR_OPACITY, 0, opacity); - background.set_attribute(ATTR_OPACITY_FILL, 0, fill); - background.set_attribute(ATTR_CLIPPING_MASK, 0, clip); - background.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer); - - background -} - -pub fn blend_image_closure(foreground: Item>, mut background: Item>, map_fn: impl Fn(Color, Color) -> Color) -> Item> { - let foreground_size = DVec2::new(foreground.element().width as f64, foreground.element().height as f64); - let background_size = DVec2::new(background.element().width as f64, background.element().height as f64); - - // Transforms a point from the background image to the foreground image - let foreground_transform: DAffine2 = foreground.attribute_cloned_or_default(ATTR_TRANSFORM); - let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM); - let background_to_foreground = DAffine2::from_scale(foreground_size) * foreground_transform.inverse() * background_transform * DAffine2::from_scale(1. / background_size); - - // Footprint of the foreground image (0, 0)..(1, 1) in the background image space - let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground_transform).to_axis_aligned_bbox(); - - // Clamp the foreground image to the background image - let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2(); - let end = (background_aabb.end * background_size).min(background_size).as_uvec2(); - - for y in start.y..end.y { - for x in start.x..end.x { - let background_point = DVec2::new(x as f64, y as f64); - let foreground_point = background_to_foreground.transform_point2(background_point); - - let source_pixel = foreground.element().sample(foreground_point); - let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else { - continue; - }; - - *destination_pixel = map_fn(source_pixel, *destination_pixel); - } - } - - background -} - -pub fn blend_stamp_closure(foreground: BrushStampGenerator, mut background: Item>, map_fn: impl Fn(Color, Color) -> Color) -> Item> { - let background_size = DVec2::new(background.element().width as f64, background.element().height as f64); - - // Transforms a point from the background image to the foreground image - let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM); - let background_to_foreground = background_transform * DAffine2::from_scale(1. / background_size); - - // Footprint of the foreground image (0, 0)..(1, 1) in the background image space - let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground.transform()).to_axis_aligned_bbox(); - - // Clamp the foreground image to the background image - let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2(); - let end = (background_aabb.end * background_size).min(background_size).as_uvec2(); - - let area = background_to_foreground.transform_point2(DVec2::new(1., 1.)) - background_to_foreground.transform_point2(DVec2::ZERO); - for y in start.y..end.y { - for x in start.x..end.x { - let background_point = DVec2::new(x as f64, y as f64); - let foreground_point = background_to_foreground.transform_point2(background_point); - - let Some(source_pixel) = foreground.sample(foreground_point, area) else { continue }; - let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else { - continue; - }; - - *destination_pixel = map_fn(source_pixel, *destination_pixel); - } - } - - background -} - -#[cfg(test)] -mod test { - use super::*; - use core_types::transform::Transform; - use glam::DAffine2; - - #[test] - fn test_brush_texture() { - let size = 20.; - let image = brush_stamp_generator(size, Color::BLACK, 100., 100.); - assert_eq!(image.transform(), DAffine2::from_scale_angle_translation(DVec2::splat(size.ceil()), 0., -DVec2::splat(size / 2.))); - // center pixel should be BLACK - assert_eq!(image.sample(DVec2::splat(0.), DVec2::ONE), Some(Color::BLACK)); - } - - #[tokio::test] - async fn test_brush_output_size() { - let image = brush( - (), - &BrushCache::default(), - List::new_from_element(Raster::new_cpu(Image::::default())), - List::new_from_element(BrushStroke { - trace: vec![crate::brush_stroke::BrushInputSample { position: DVec2::ZERO }], - style: BrushStyle { - color: Color::BLACK, - diameter: 20., - hardness: 20., - flow: 20., - spacing: 20., - blend_mode: BlendMode::Normal, - }, - }), - ) - .await; - assert_eq!(image.element(0).unwrap().width, 20); - } -} diff --git a/node-graph/nodes/brush/src/brush_cache.rs b/node-graph/nodes/brush/src/brush_cache.rs deleted file mode 100644 index f4b980efb8..0000000000 --- a/node-graph/nodes/brush/src/brush_cache.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::brush_stroke::BrushStroke; -use crate::brush_stroke::BrushStyle; -use core_types::ATTR_TRANSFORM; -use core_types::graphene_hash::CacheHashWrapper; -use core_types::list::Item; -use raster_types::CPU; -use raster_types::Raster; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -#[derive(Clone, Debug, Default)] -struct BrushCacheImpl { - // The full previous input that was cached. - prev_input: Vec, - - // The strokes that have been fully processed and blended into the background. - background: Item>, - blended_image: Item>, - last_stroke_texture: Item>, - - // A cache for brush textures. - brush_texture_cache: HashMap, Raster>, -} - -impl BrushCacheImpl { - fn compute_brush_plan(&mut self, mut background: Item>, input: &[BrushStroke]) -> BrushPlan { - // Do background invalidation. - if background != self.background { - self.background = background.clone(); - return BrushPlan { - strokes: input.to_vec(), - background, - ..Default::default() - }; - } - - // Do blended_image invalidation. - let blended_strokes = &self.prev_input[..self.prev_input.len().saturating_sub(1)]; - let num_blended_strokes = blended_strokes.len(); - if input.get(..num_blended_strokes) != Some(blended_strokes) { - return BrushPlan { - strokes: input.to_vec(), - background, - ..Default::default() - }; - } - - // Take our previous blended image (and invalidate the cache). - // Since we're about to replace our cache anyway, this saves a clone. - background = std::mem::take(&mut self.blended_image); - - // Check if the first non-blended stroke is an extension of the last one. - // Transform is set to ZERO (not the default IDENTITY) as a sentinel to mark this item as uninitialized. - let mut first_stroke_texture = Item::new_from_element(Raster::::default()).with_attribute(ATTR_TRANSFORM, glam::DAffine2::ZERO); - let mut first_stroke_point_skip = 0; - let strokes = input[num_blended_strokes..].to_vec(); - if !strokes.is_empty() && self.prev_input.len() > num_blended_strokes { - let last_stroke = &self.prev_input[num_blended_strokes]; - let same_style = strokes[0].style == last_stroke.style; - let prev_points = last_stroke.compute_blit_points(); - let new_points = strokes[0].compute_blit_points(); - let is_point_prefix = new_points.get(..prev_points.len()) == Some(&prev_points); - if same_style && is_point_prefix { - first_stroke_texture = std::mem::take(&mut self.last_stroke_texture); - first_stroke_point_skip = prev_points.len(); - } - } - - self.prev_input = Vec::new(); - BrushPlan { - strokes, - background, - first_stroke_texture, - first_stroke_point_skip, - } - } - - pub fn cache_results(&mut self, input: Vec, blended_image: Item>, last_stroke_texture: Item>) { - self.prev_input = input; - self.blended_image = blended_image; - self.last_stroke_texture = last_stroke_texture; - } -} - -#[derive(Clone, Debug, Default)] -pub struct BrushPlan { - pub strokes: Vec, - pub background: Item>, - pub first_stroke_texture: Item>, - pub first_stroke_point_skip: usize, -} - -#[derive(Debug, Default, Clone)] -pub struct BrushCache(Arc>); - -impl BrushCache { - pub fn compute_brush_plan(&self, background: Item>, input: &[BrushStroke]) -> BrushPlan { - let mut inner = self.0.lock().unwrap(); - inner.compute_brush_plan(background, input) - } - - pub fn cache_results(&self, input: Vec, blended_image: Item>, last_stroke_texture: Item>) { - let mut inner = self.0.lock().unwrap(); - inner.cache_results(input, blended_image, last_stroke_texture) - } - - pub fn get_cached_brush(&self, style: &BrushStyle) -> Option> { - let inner = self.0.lock().unwrap(); - inner.brush_texture_cache.get(&CacheHashWrapper(style.clone())).cloned() - } - - pub fn store_brush(&self, style: BrushStyle, brush: Raster) { - let mut inner = self.0.lock().unwrap(); - inner.brush_texture_cache.insert(CacheHashWrapper(style), brush); - } -} diff --git a/node-graph/nodes/brush/src/brush_stroke.rs b/node-graph/nodes/brush/src/brush_stroke.rs deleted file mode 100644 index 0de410fea2..0000000000 --- a/node-graph/nodes/brush/src/brush_stroke.rs +++ /dev/null @@ -1,108 +0,0 @@ -use core_types::CacheHash; -use core_types::blending::BlendMode; -use core_types::color::Color; -use core_types::math::bbox::AxisAlignedBbox; -use dyn_any::DynAny; -use glam::DVec2; -/// The style of a brush. -#[derive(Clone, Debug, CacheHash, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct BrushStyle { - pub color: Color, - pub diameter: f64, - pub hardness: f64, - pub flow: f64, - pub spacing: f64, // Spacing as a fraction of the diameter. - pub blend_mode: BlendMode, -} - -impl Default for BrushStyle { - fn default() -> Self { - Self { - color: Color::BLACK, - diameter: 40., - hardness: 50., - flow: 100., - spacing: 50., // Percentage of diameter. - blend_mode: BlendMode::Normal, - } - } -} - -impl Eq for BrushStyle {} - -impl PartialEq for BrushStyle { - fn eq(&self, other: &Self) -> bool { - self.color == other.color - && self.diameter.to_bits() == other.diameter.to_bits() - && self.hardness.to_bits() == other.hardness.to_bits() - && self.flow.to_bits() == other.flow.to_bits() - && self.spacing.to_bits() == other.spacing.to_bits() - && self.blend_mode == other.blend_mode - } -} - -/// A single sample of brush parameters across the brush stroke. -#[derive(Clone, Debug, PartialEq, core_types::CacheHash, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct BrushInputSample { - pub position: DVec2, -} - -/// The parameters for a single stroke brush. -#[derive(Clone, Debug, PartialEq, core_types::CacheHash, Default, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct BrushStroke { - pub style: BrushStyle, - pub trace: Vec, -} - -impl BrushStroke { - pub fn bounding_box(&self) -> AxisAlignedBbox { - let radius = self.style.diameter / 2.; - self.compute_blit_points() - .iter() - .map(|pos| AxisAlignedBbox { - start: *pos + DVec2::new(-radius, -radius), - end: *pos + DVec2::new(radius, radius), - }) - .reduce(|a, b| a.union(&b)) - .unwrap_or(AxisAlignedBbox::ZERO) - } - - pub fn compute_blit_points(&self) -> Vec { - // We always travel in a straight line towards the next user input, - // placing a blit point every time we travelled our spacing distance. - let spacing_dist = self.style.spacing / 100. * self.style.diameter; - - let Some(first_sample) = self.trace.first() else { - return Vec::new(); - }; - - let mut cur_pos = first_sample.position; - let mut result = vec![cur_pos]; - let mut dist_until_next_blit = spacing_dist; - for sample in &self.trace[1..] { - // Travel to the next sample. - let delta = sample.position - cur_pos; - let mut dist_left = delta.length(); - let unit_step = delta / dist_left; - - while dist_left >= dist_until_next_blit { - // Take a step to the next blit point. - cur_pos += dist_until_next_blit * unit_step; - dist_left -= dist_until_next_blit; - - // Blit. - result.push(cur_pos); - dist_until_next_blit = spacing_dist; - } - - // Take the partial step to land at the sample. - dist_until_next_blit -= dist_left; - cur_pos = sample.position; - } - - result - } -} diff --git a/node-graph/nodes/brush/src/lib.rs b/node-graph/nodes/brush/src/lib.rs index 0b6beb5458..de881848bf 100644 --- a/node-graph/nodes/brush/src/lib.rs +++ b/node-graph/nodes/brush/src/lib.rs @@ -4,9 +4,6 @@ use core_types::{Color, Ctx}; use graphic_types::Graphic; pub mod airbrush; -pub mod brush; -mod brush_cache; -pub mod brush_stroke; pub use brush_types::*; @@ -32,30 +29,3 @@ fn brush_strokes( .with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)), ) } - -pub mod migrations { - use crate::brush_stroke::BrushStroke; - - // TODO: Eventually remove this migration document upgrade code - pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - use serde::Deserialize; - - #[derive(serde::Deserialize)] - struct LegacyTable { - #[serde(alias = "instances", alias = "instance")] - element: Vec, - } - - #[derive(serde::Deserialize)] - #[serde(untagged)] - enum BrushStrokesFormat { - Strokes(Vec), - List(LegacyTable), - } - - Ok(match BrushStrokesFormat::deserialize(deserializer)? { - BrushStrokesFormat::Strokes(strokes) => strokes, - BrushStrokesFormat::List(list) => list.element, - }) - } -}