From 5cfe68dd326dfec47d37578585505b3f5ca1cc2b Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 26 Oct 2026 00:16:39 +0000 Subject: [PATCH 01/14] Run raster nodes on GPU rasters through hand-written WGSL kernels --- Cargo.lock | 1 + .../messages/portfolio/document_migration.rs | 44 +- .../interpreted-executor/src/node_registry.rs | 2 + node-graph/libraries/wgpu-executor/src/lib.rs | 5 + .../libraries/wgpu-executor/src/pipeline.rs | 6 + .../wgpu-executor/src/shader_runtime/mod.rs | 1 + .../shader_runtime/raster_kernel_entry.wgsl | 10 + .../shader_runtime/raster_kernel_input.wgsl | 34 + .../raster_kernel_input_sample.wgsl | 3 + .../shader_runtime/raster_kernel_runtime.rs | 369 +++++ .../wgpu-executor/src/texture_conversion.rs | 102 +- .../wgpu-executor/src/vello_render.rs | 3 +- .../nodes/brush/src/basic_brush/convert.rs | 2 +- .../nodes/brush/src/basic_brush/convert.wgsl | 9 +- .../nodes/brush/src/basic_brush/render.rs | 2 +- node-graph/nodes/raster/Cargo.toml | 1 + node-graph/nodes/raster/src/adjust.rs | 27 - node-graph/nodes/raster/src/adjustments.rs | 1191 ++++++++++++----- node-graph/nodes/raster/src/blending_nodes.rs | 53 +- .../nodes/raster/src/color_lookup_table.rs | 49 + node-graph/nodes/raster/src/filter.rs | 248 +++- node-graph/nodes/raster/src/lib.rs | 2 +- node-graph/nodes/raster/src/raster_kernel.rs | 566 ++++++++ node-graph/nodes/raster/src/std_nodes.rs | 221 ++- .../raster/src/wgsl/black_and_white.wgsl | 28 + .../nodes/raster/src/wgsl/blending.wgsl | 333 +++++ node-graph/nodes/raster/src/wgsl/blur.wgsl | 41 + .../nodes/raster/src/wgsl/blur_halve.wgsl | 13 + .../raster/src/wgsl/blur_horizontal.wgsl | 15 + .../nodes/raster/src/wgsl/blur_upsample.wgsl | 4 + .../nodes/raster/src/wgsl/blur_vertical.wgsl | 37 + .../raster/src/wgsl/brightness_contrast.wgsl | 97 ++ .../nodes/raster/src/wgsl/channel_mixer.wgsl | 22 + .../nodes/raster/src/wgsl/color_balance.wgsl | 5 + .../nodes/raster/src/wgsl/color_lookup.wgsl | 50 + .../nodes/raster/src/wgsl/color_overlay.wgsl | 6 + .../nodes/raster/src/wgsl/desaturate.wgsl | 43 + .../nodes/raster/src/wgsl/exposure.wgsl | 17 + .../nodes/raster/src/wgsl/gradient_map.wgsl | 23 + node-graph/nodes/raster/src/wgsl/hue.wgsl | 21 + .../nodes/raster/src/wgsl/hue_saturation.wgsl | 138 ++ node-graph/nodes/raster/src/wgsl/levels.wgsl | 15 + .../nodes/raster/src/wgsl/levels_curve.wgsl | 14 + node-graph/nodes/raster/src/wgsl/luma.wgsl | 3 + node-graph/nodes/raster/src/wgsl/lut_1d.wgsl | 16 + node-graph/nodes/raster/src/wgsl/lut_3d.wgsl | 9 + .../nodes/raster/src/wgsl/median_filter.wgsl | 54 + .../nodes/raster/src/wgsl/photo_filter.wgsl | 21 + node-graph/nodes/raster/src/wgsl/prelude.wgsl | 86 ++ .../raster/src/wgsl/selective_color.wgsl | 91 ++ .../nodes/raster/src/wgsl/vibrance.wgsl | 49 + 51 files changed, 3806 insertions(+), 396 deletions(-) create mode 100644 node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs create mode 100644 node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_entry.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input_sample.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs delete mode 100644 node-graph/nodes/raster/src/adjust.rs create mode 100644 node-graph/nodes/raster/src/raster_kernel.rs create mode 100644 node-graph/nodes/raster/src/wgsl/black_and_white.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blending.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blur.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blur_halve.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blur_horizontal.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blur_upsample.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/blur_vertical.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/brightness_contrast.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/channel_mixer.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/color_balance.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/color_lookup.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/color_overlay.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/desaturate.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/exposure.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/gradient_map.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/hue.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/hue_saturation.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/levels.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/levels_curve.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/luma.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/lut_1d.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/lut_3d.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/median_filter.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/photo_filter.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/prelude.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/selective_color.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/vibrance.wgsl diff --git a/Cargo.lock b/Cargo.lock index 3fd7e5f6d6a..3ab655ab390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5853,6 +5853,7 @@ dependencies = [ "tsify", "vector-types", "wasm-bindgen", + "wgpu-executor", ] [[package]] diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 690f2a947a0..0a9781f8042 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -3519,6 +3519,47 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } + let raster_kernel_pipeline = NodeInput::scope(graphene_std::raster_nodes::raster_kernel::raster_kernel_pipeline::IDENTIFIER.as_str().to_string()); + let raster_kernel_nodes = { + use graphene_std::raster_nodes::adjustments::*; + [ + desaturate::IDENTIFIER, + gamma_correction::IDENTIFIER, + make_opaque::IDENTIFIER, + brightness_contrast::IDENTIFIER, + levels::IDENTIFIER, + curves::IDENTIFIER, + black_and_white::IDENTIFIER, + hue_saturation::IDENTIFIER, + invert::IDENTIFIER, + threshold::IDENTIFIER, + gradient_map::IDENTIFIER, + vibrance::IDENTIFIER, + channel_mixer::IDENTIFIER, + selective_color::IDENTIFIER, + posterize::IDENTIFIER, + exposure::IDENTIFIER, + color_balance::IDENTIFIER, + photo_filter::IDENTIFIER, + color_lookup::IDENTIFIER, + graphene_std::raster_nodes::blending_nodes::color_overlay::IDENTIFIER, + graphene_std::raster_nodes::std_nodes::channels_to_image::IDENTIFIER, + graphene_std::raster_nodes::std_nodes::mask::IDENTIFIER, + graphene_std::raster_nodes::std_nodes::extend_image_to_bounds::IDENTIFIER, + graphene_std::raster_nodes::filter::median_filter::IDENTIFIER, + graphene_std::raster_nodes::filter::blur::IDENTIFIER, + ] + }; + if raster_kernel_nodes.iter().any(|identifier| reference == DefinitionIdentifier::ProtoNode(identifier.clone())) && node.inputs.last() != Some(&raster_kernel_pipeline) { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let value_inputs = node_template.inputs.len() - 1; + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (index, input) in old_inputs.into_iter().take(value_inputs).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + } + // A value input stored as a List-form TypeDefault adopts the definition's current default when the connector's declared default has since changed (e.g. the connector was ranked down to Item). // The red-slash no-paint choice shares that stored form but is a deliberate value, not a stale disconnect default, so it is exempt. if let Some(definition) = resolve_document_node_type(&reference) { @@ -4478,7 +4519,8 @@ mod tests { let desaturate_node = node(1); assert_eq!(desaturate_node.implementation, DocumentNodeImplementation::ProtoNode(desaturate::IDENTIFIER)); - assert_eq!(desaturate_node.inputs, [NodeInput::node(NodeId(0), 0), method]); + let pipeline = NodeInput::scope(graphene_std::raster_nodes::raster_kernel::raster_kernel_pipeline::IDENTIFIER.as_str().to_string()); + assert_eq!(desaturate_node.inputs, [NodeInput::node(NodeId(0), 0), method, pipeline]); let brightness_contrast = node(2); assert_eq!( diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index b6d0e390e2d..2171cf4abf2 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -51,6 +51,8 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item>]), + #[cfg(feature = "gpu")] + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 087703265c0..c49f941067e 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -1,6 +1,7 @@ mod buffer; mod context; mod pipeline; +pub mod shader_runtime; mod texture_cache; pub mod texture_conversion; mod vello_render; @@ -25,6 +26,7 @@ pub use raster_types::Texture; pub use rendering::vello_scene_builder::VelloSceneBuilder; pub use wgpu::Backends as WgpuBackends; pub use wgpu::Features as WgpuFeatures; +pub use wgpu::TextureFormat as WgpuTextureFormat; pub use wgpu_sync::CurrentSurfaceTexture as WgpuCurrentSurfaceTexture; pub use wgpu_sync::Instance as WgpuInstance; pub use wgpu_sync::Queue as WgpuQueue; @@ -35,6 +37,9 @@ const TEXTURE_CACHE_SIZE: u64 = 1024 * 1024 * 1024; // 1GB #[cfg(target_family = "wasm")] const TEXTURE_CACHE_SIZE: u64 = 512 * 1024 * 1024; // 512MB +/// The format of every raster texture, holding linear-light straight-alpha values. +pub const RASTER_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; + #[derive(dyn_any::DynAny, Clone)] pub struct WgpuExecutor { inner: Arc, diff --git a/node-graph/libraries/wgpu-executor/src/pipeline.rs b/node-graph/libraries/wgpu-executor/src/pipeline.rs index 2717b73ea6d..329903cd511 100644 --- a/node-graph/libraries/wgpu-executor/src/pipeline.rs +++ b/node-graph/libraries/wgpu-executor/src/pipeline.rs @@ -32,6 +32,12 @@ impl PipelineCache { let pipeline = (**entry).downcast_ref::

().ok_or(PipelineError::TypeMismatch { expected: std::any::type_name::

() })?; Ok(pipeline.run(executor, args)) } + + /// Runs the pipeline, or returns `None` when it was never initialized because no GPU is available. + pub fn try_run(&self, args: &P::Args<'_>) -> Option { + self.executor.get()?; + self.run::

(args).ok() + } } impl std::fmt::Debug for PipelineCache { diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs b/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs new file mode 100644 index 00000000000..de683ada20e --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs @@ -0,0 +1 @@ +pub mod raster_kernel_runtime; diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_entry.wgsl b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_entry.wgsl new file mode 100644 index 00000000000..ed75ec75243 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_entry.wgsl @@ -0,0 +1,10 @@ +@vertex +fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + let corner = vec2(f32((index << 1u) & 2u), f32(index & 2u)); + return vec4(corner * 2. - 1., 0., 1.); +} + +@fragment +fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { + return kernel(vec2(position.xy)); +} diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input.wgsl b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input.wgsl new file mode 100644 index 00000000000..c381cbf02ca --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input.wgsl @@ -0,0 +1,34 @@ + +@group(0) @binding(BINDING) var INPUT_texture: texture_2d; + +fn INPUT_size() -> vec2 { + return vec2(kernel_frame[PLACEMENT].zw); +} + +fn INPUT_load(coord: vec2) -> vec4 { + return textureLoad(INPUT_texture, clamp(coord, vec2(0), INPUT_size() - 1), 0); +} + +fn INPUT_premultiplied(texel: vec2) -> vec4 { + if any(texel < vec2(0)) || any(texel >= INPUT_size()) { + return vec4(0.); + } + let color = textureLoad(INPUT_texture, texel, 0); + return vec4(color.rgb * color.a, color.a); +} + +fn INPUT_at(coord: vec2) -> vec4 { + let axes = kernel_frame[AXES]; + let center = vec2(coord) + 0.5; + let texel = axes.xy * center.x + axes.zw * center.y + kernel_frame[PLACEMENT].xy - 0.5; + let base = floor(texel); + let t = texel - base; + let corner = vec2(base); + let top = mix(INPUT_premultiplied(corner), INPUT_premultiplied(corner + vec2(1, 0)), t.x); + let bottom = mix(INPUT_premultiplied(corner + vec2(0, 1)), INPUT_premultiplied(corner + vec2(1, 1)), t.x); + let premultiplied = mix(top, bottom, t.y); + if premultiplied.a <= 0. { + return vec4(0.); + } + return vec4(premultiplied.rgb / premultiplied.a, premultiplied.a); +} diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input_sample.wgsl b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input_sample.wgsl new file mode 100644 index 00000000000..677852b0cfa --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_input_sample.wgsl @@ -0,0 +1,3 @@ +fn INPUT_sample(texel: vec2) -> vec4 { + return textureSampleLevel(INPUT_texture, raster_kernel_sampler, texel / vec2(INPUT_size()), 0.); +} diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs new file mode 100644 index 00000000000..8cc834b3e07 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs @@ -0,0 +1,369 @@ +use crate::{WgpuExecutor, WgpuPipeline}; +use core_types::Color; +use glam::{DAffine2, UVec2}; +use raster_types::{GPU, Raster}; +use std::any::TypeId; +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; +use std::sync::Mutex; +use wgpu::util::BufferInitDescriptor; + +/// An input raster of a kernel, with the affine mapping output pixel coordinates to the input's texel coordinates. +pub struct RasterKernelInput<'a> { + pub raster: &'a Raster, + pub output_to_texel: DAffine2, +} + +/// A lookup table of a kernel, bound as an `Rgba32Float` texture after its inputs. +pub enum RasterKernelLookup<'a> { + /// A `width x 1` 2D texture of at most 4096 entries. + Lut1d(&'a [Color]), + /// A `size x size x size` 3D texture, its entries ordered with x fastest, then y, then z. + Lut3d { size: u32, data: &'a [Color] }, +} + +impl RasterKernelLookup<'_> { + fn data(&self) -> &[Color] { + match self { + Self::Lut1d(data) | Self::Lut3d { data, .. } => data, + } + } + + fn extent(&self) -> wgpu::Extent3d { + match self { + Self::Lut1d(data) => wgpu::Extent3d { + width: data.len() as u32, + height: 1, + depth_or_array_layers: 1, + }, + Self::Lut3d { size, .. } => wgpu::Extent3d { + width: *size, + height: *size, + depth_or_array_layers: *size, + }, + } + } + + fn dimension(&self) -> (wgpu::TextureDimension, wgpu::TextureViewDimension) { + match self { + Self::Lut1d(_) => (wgpu::TextureDimension::D2, wgpu::TextureViewDimension::D2), + Self::Lut3d { .. } => (wgpu::TextureDimension::D3, wgpu::TextureViewDimension::D3), + } + } + + fn content_hash(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + let extent = self.extent(); + (extent.width, extent.height, extent.depth_or_array_layers, matches!(self, Self::Lut3d { .. })).hash(&mut hasher); + bytemuck::cast_slice::(self.data()).hash(&mut hasher); + hasher.finish() + } +} + +/// One run of a raster kernel, writing a texture of `output_size` in `output_format`. +/// +/// `wgsl` must define `fn kernel(coord: vec2) -> vec4`, and reads its parameters from `uniform_params`, the 32-bit `params` packed into `vec4` slots. +/// It may call `output_size()` and, for each input `i`, `input_i_size()`, `input_i_load(coord)` reading the input's own texel clamped to its edge, +/// and `input_i_at(coord)` sampling the input bilinearly where the output pixel's center lies, transparent beyond the input. +/// With `filtered`, it may also call `input_i_sample(texel)`, sampling the input bilinearly at texel coordinates, whose texel centers lie at +0.5, clamped to its edge, +/// which requires every input to be in a filterable format such as `Rgba16Float` rather than [`crate::RASTER_FORMAT`]. +/// It declares the texture of each of the `lookups` itself, the `k`th at binding `2 + inputs + k`. +/// Every run of the same `kernel` must have the same number of inputs, the same kinds of lookups, and the same `filtered`. +pub struct RasterKernelArgs<'a> { + pub kernel: TypeId, + pub label: &'static str, + pub wgsl: fn() -> String, + pub params: Vec, + pub inputs: Vec>, + pub lookups: Vec>, + pub filtered: bool, + pub output_size: UVec2, + pub output_format: wgpu::TextureFormat, +} + +struct KernelPipeline { + bind_group_layout: wgpu::BindGroupLayout, + pipeline: wgpu::RenderPipeline, +} + +/// The number of lookup textures kept uploaded, the most recently used first, so a kernel run again with the same table skips the upload. +const LOOKUP_CACHE_SIZE: usize = 16; + +pub struct RasterKernelRuntime { + pipelines: Mutex>, + lookups: Mutex>, + sampler: wgpu::Sampler, +} + +impl RasterKernelRuntime { + fn lookup_texture(&self, executor: &WgpuExecutor, lookup: &RasterKernelLookup<'_>) -> wgpu::Texture { + let hash = lookup.content_hash(); + let mut lookups = self.lookups.lock().unwrap(); + if let Some(index) = lookups.iter().position(|(cached, _)| *cached == hash) { + let entry = lookups.remove(index).unwrap(); + let texture = entry.1.clone(); + lookups.push_front(entry); + return texture; + } + + let size = lookup.extent(); + let texture = executor.context().device.create_texture(&wgpu::TextureDescriptor { + label: Some("raster_kernel_lookup"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: lookup.dimension().0, + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + executor.context().queue.write_texture( + texture.as_image_copy(), + bytemuck::cast_slice(lookup.data()), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(16 * size.width), + rows_per_image: Some(size.height), + }, + size, + ); + lookups.push_front((hash, texture.clone())); + lookups.truncate(LOOKUP_CACHE_SIZE); + texture + } +} + +impl WgpuPipeline for RasterKernelRuntime { + type Args<'a> = RasterKernelArgs<'a>; + type Out = Raster; + + fn create(executor: &WgpuExecutor) -> Self { + let sampler = executor.context().device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("raster_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() + }); + Self { + pipelines: Mutex::default(), + lookups: Mutex::default(), + sampler, + } + } + + fn run(&self, executor: &WgpuExecutor, args: &RasterKernelArgs<'_>) -> Raster { + let device = &executor.context().device; + let param_slots = slots(args.params.len()); + + let mut pipelines = self.pipelines.lock().unwrap(); + let kernel_pipeline = pipelines.entry((args.kernel, args.output_format)).or_insert_with(|| create_pipeline(device, args, param_slots)); + + let mut params = args.params.clone(); + params.resize(param_slots * 4, 0); + let params = executor.create_buffer_init(&BufferInitDescriptor { + label: Some(args.label), + contents: bytemuck::cast_slice(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let frame = executor.create_buffer_init(&BufferInitDescriptor { + label: Some(args.label), + contents: bytemuck::cast_slice(&frame_data(args)), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let input_views = args + .inputs + .iter() + .map(|input| input.raster.texture.create_view(&wgpu::TextureViewDescriptor::default())) + .collect::>(); + let lookup_views = args + .lookups + .iter() + .map(|lookup| self.lookup_texture(executor, lookup).create_view(&wgpu::TextureViewDescriptor::default())) + .collect::>(); + let mut entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: frame.as_entire_binding(), + }, + ]; + entries.extend(input_views.iter().enumerate().map(|(index, view)| wgpu::BindGroupEntry { + binding: 2 + index as u32, + resource: wgpu::BindingResource::TextureView(view), + })); + entries.extend(lookup_views.iter().enumerate().map(|(index, view)| wgpu::BindGroupEntry { + binding: (2 + args.inputs.len() + index) as u32, + resource: wgpu::BindingResource::TextureView(view), + })); + if args.filtered { + entries.push(wgpu::BindGroupEntry { + binding: sampler_binding(args), + resource: wgpu::BindingResource::Sampler(&self.sampler), + }); + } + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(args.label), + layout: &kernel_pipeline.bind_group_layout, + entries: &entries, + }); + + let output = executor.request_texture_with_format(args.output_size, args.output_format); + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(args.label) }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(args.label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + 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(&kernel_pipeline.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } + executor.context().queue.submit([encoder.finish()]); + + Raster::new_gpu(output) + } +} + +fn create_pipeline(device: &wgpu::Device, args: &RasterKernelArgs<'_>, param_slots: usize) -> KernelPipeline { + let uniform = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let texture = |binding, view_dimension, filterable| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable }, + view_dimension, + multisampled: false, + }, + count: None, + }; + let mut entries = vec![uniform(0), uniform(1)]; + entries.extend((0..args.inputs.len()).map(|index| texture(2 + index as u32, wgpu::TextureViewDimension::D2, args.filtered))); + entries.extend( + args.lookups + .iter() + .enumerate() + .map(|(index, lookup)| texture((2 + args.inputs.len() + index) as u32, lookup.dimension().1, false)), + ); + if args.filtered { + entries.push(wgpu::BindGroupLayoutEntry { + binding: sampler_binding(args), + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); + } + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(args.label), + entries: &entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(args.label), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let source = format!("{}\n{}\n\n{}", bindings_wgsl(param_slots, args), (args.wgsl)(), include_str!("raster_kernel_entry.wgsl")); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(args.label), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(args.label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(args.output_format.into())], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + KernelPipeline { bind_group_layout, pipeline } +} + +/// The frame uniform: the output size, then for each input its affine's matrix columns, translation, and size. +fn frame_data(args: &RasterKernelArgs<'_>) -> Vec { + let mut data = vec![args.output_size.x as f32, args.output_size.y as f32, 0., 0.]; + for input in &args.inputs { + let affine = input.output_to_texel; + let texture = &input.raster.texture; + data.extend([affine.matrix2.x_axis.x, affine.matrix2.x_axis.y, affine.matrix2.y_axis.x, affine.matrix2.y_axis.y].map(|value| value as f32)); + data.extend([affine.translation.x as f32, affine.translation.y as f32, texture.width() as f32, texture.height() as f32]); + } + data +} + +/// The binding of the sampler of a `filtered` kernel's inputs, after its lookups. +fn sampler_binding(args: &RasterKernelArgs<'_>) -> u32 { + (2 + args.inputs.len() + args.lookups.len()) as u32 +} + +fn bindings_wgsl(param_slots: usize, args: &RasterKernelArgs<'_>) -> String { + let inputs = args.inputs.len(); + let frame_slots = 1 + 2 * inputs; + let mut source = format!( + "@group(0) @binding(0) var uniform_params: array, {param_slots}>;\n@group(0) @binding(1) var kernel_frame: array, {frame_slots}>;\n\nfn output_size() -> vec2 {{\n\treturn vec2(kernel_frame[0].xy);\n}}\n" + ); + if args.filtered { + source += &format!("\n@group(0) @binding({}) var raster_kernel_sampler: sampler;\n", sampler_binding(args)); + } + for index in 0..inputs { + let mut input = include_str!("raster_kernel_input.wgsl").to_string(); + if args.filtered { + input += include_str!("raster_kernel_input_sample.wgsl"); + } + source += &input + .replace("INPUT", &format!("input_{index}")) + .replace("BINDING", &(2 + index).to_string()) + .replace("AXES", &(1 + 2 * index).to_string()) + .replace("PLACEMENT", &(2 + 2 * index).to_string()); + } + source +} + +/// The `vec4` slot count holding `params` 32-bit values, at least one since WGSL arrays can't be empty. +fn slots(params: usize) -> usize { + params.div_ceil(4).max(1) +} diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index 05c516038fb..b05f723a689 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -2,22 +2,62 @@ use crate::{Buffer, WgpuExecutor}; use core_types::Color; use core_types::color::SRGBA8; use core_types::list::{Item, List}; -use core_types::ops::ConvertAsync; +use core_types::ops::{Convert, ConvertAsync}; use core_types::transform::Footprint; use raster_types::Image; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, GPU, Raster, Texture}; use wgpu::Extent3d; +/// Uploads CPU image data to a GPU texture +fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raster) -> Texture { + let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), crate::RASTER_FORMAT); + + if image.width == 0 || image.height == 0 { + queue.write_texture( + texture.as_image_copy(), + bytemuck::bytes_of(&Color::TRANSPARENT), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(16), + rows_per_image: Some(1), + }, + Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + return texture; + } + + queue.write_texture( + texture.as_image_copy(), + bytemuck::cast_slice(image.data.as_slice()), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(16 * image.width), + rows_per_image: Some(image.height), + }, + Extent3d { + width: image.width, + height: image.height, + depth_or_array_layers: 1, + }, + ); + texture +} + /// Converts a Raster texture to Raster by downloading the underlying texture data. /// /// Assumptions: /// - 2D texture, mip level 0 -/// - 4 bytes-per-pixel RGBA8 +/// - 4 bytes-per-pixel RGBA8, or `RASTER_FORMAT` /// - Texture has COPY_SRC usage struct RasterGpuToRasterCpuConverter { buffer: Buffer, width: u32, height: u32, + format: wgpu::TextureFormat, unpadded_bytes_per_row: u32, padded_bytes_per_row: u32, _source: raster_types::Texture, @@ -27,7 +67,8 @@ impl RasterGpuToRasterCpuConverter { let texture = data_gpu.data(); let width = texture.width(); let height = texture.height(); - let bytes_per_pixel = 4; // RGBA8 + let format = texture.format(); + let bytes_per_pixel = if format == crate::RASTER_FORMAT { 16 } else { 4 }; let unpadded_bytes_per_row = width * bytes_per_pixel; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; @@ -66,6 +107,7 @@ impl RasterGpuToRasterCpuConverter { buffer, width, height, + format, unpadded_bytes_per_row, padded_bytes_per_row, // Keep source texture alive @@ -114,8 +156,12 @@ impl RasterGpuToRasterCpuConverter { for row in 0..self.height as usize { let start = row * row_stride; let row_slice = &view[start..start + row_bytes]; - for &[red, green, blue, alpha] in row_slice.as_chunks::<4>().0 { - cpu_data.push(SRGBA8::new(red, green, blue, alpha).into()); + if self.format == crate::RASTER_FORMAT { + cpu_data.extend(bytemuck::pod_collect_to_vec::(row_slice)); + } else { + for &[red, green, blue, alpha] in row_slice.as_chunks::<4>().0 { + cpu_data.push(SRGBA8::new(red, green, blue, alpha).into()); + } } } @@ -171,6 +217,50 @@ async fn yield_to_event_loop() -> bool { .await } +/// Passthrough conversion for GPU `List`s - no conversion needed +impl Convert>, WgpuExecutor> for List> { + fn convert(self, _: Footprint, _converter: WgpuExecutor) -> List> { + self + } +} + +/// Converts a `List>` to `List>` by uploading each image to a texture +impl Convert>, WgpuExecutor> for List> { + fn convert(self, _: Footprint, executor: WgpuExecutor) -> List> { + let queue = executor.context().queue.lock(); + let list = self + .into_iter() + .map(|row| { + let (image, attributes) = row.into_parts(); + let texture = upload_to_texture(&executor, &queue, &image); + + Item::from_parts(Raster::new_gpu(texture), attributes) + }) + .collect(); + + queue.submit([]); + list + } +} + +/// Converts single CPU raster to GPU by uploading to texture +impl Convert, WgpuExecutor> for Raster { + fn convert(self, _: Footprint, executor: WgpuExecutor) -> Raster { + let queue = executor.context().queue.lock(); + let texture = upload_to_texture(&executor, &queue, &self); + + queue.submit([]); + Raster::new_gpu(texture) + } +} + +/// Passthrough conversion for CPU `List`s - no conversion needed +impl Convert>, WgpuExecutor> for List> { + fn convert(self, _: Footprint, _converter: WgpuExecutor) -> List> { + self + } +} + /// Converts a `List>` to `List>` by downloading texture data in one go then asynchronously maps all buffers and processes the results. impl ConvertAsync>, WgpuExecutor> for List> { async fn convert(self, _: Footprint, executor: WgpuExecutor) -> List> { diff --git a/node-graph/libraries/wgpu-executor/src/vello_render.rs b/node-graph/libraries/wgpu-executor/src/vello_render.rs index 01bc289eeaf..5ed3aff53dd 100644 --- a/node-graph/libraries/wgpu-executor/src/vello_render.rs +++ b/node-graph/libraries/wgpu-executor/src/vello_render.rs @@ -361,7 +361,8 @@ impl ImageTextures { if let SceneImage::Texture(source) = image { let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - alpha_conversion.premultiply(device, encoder, &source_view, source.format().is_srgb(), &view); + let loads_linear_light = source.format().is_srgb() || source.format() == crate::RASTER_FORMAT; + alpha_conversion.premultiply(device, encoder, &source_view, loads_linear_light, &view); } Some(view) diff --git a/node-graph/nodes/brush/src/basic_brush/convert.rs b/node-graph/nodes/brush/src/basic_brush/convert.rs index ab3e4a94f2c..2f3d5b072a0 100644 --- a/node-graph/nodes/brush/src/basic_brush/convert.rs +++ b/node-graph/nodes/brush/src/basic_brush/convert.rs @@ -37,7 +37,7 @@ impl Convert { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), - targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())], + targets: &[Some(wgpu_executor::RASTER_FORMAT.into())], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, diff --git a/node-graph/nodes/brush/src/basic_brush/convert.wgsl b/node-graph/nodes/brush/src/basic_brush/convert.wgsl index a92aa5d3835..3fad38dc4fd 100644 --- a/node-graph/nodes/brush/src/basic_brush/convert.wgsl +++ b/node-graph/nodes/brush/src/basic_brush/convert.wgsl @@ -19,13 +19,6 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4< @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); @@ -33,5 +26,5 @@ fn fs_main(@builtin(position) frag: vec4) -> @location(0) vec4 { 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); + return vec4(straight, premultiplied.a); } diff --git a/node-graph/nodes/brush/src/basic_brush/render.rs b/node-graph/nodes/brush/src/basic_brush/render.rs index 92704103fa2..ed6cedec23f 100644 --- a/node-graph/nodes/brush/src/basic_brush/render.rs +++ b/node-graph/nodes/brush/src/basic_brush/render.rs @@ -183,7 +183,7 @@ pub(super) fn render(pipeline: &BasicBrushPipeline, executor: &WgpuExecutor, fra 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 output = executor.request_texture_with_format(crop.size, wgpu_executor::RASTER_FORMAT); let mut recorder = Recorder::new(pipeline, executor, ®ion); if let Some(updated) = &updated { diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index 3f9e6d56d91..5128cf35fde 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -35,6 +35,7 @@ graphene-resource = { workspace = true } graphene-hash = { workspace = true } raster-types = { workspace = true } vector-types = { workspace = true } +wgpu-executor = { workspace = true } # Workspace dependencies bytemuck = { workspace = true } diff --git a/node-graph/nodes/raster/src/adjust.rs b/node-graph/nodes/raster/src/adjust.rs deleted file mode 100644 index d0625fee9a1..00000000000 --- a/node-graph/nodes/raster/src/adjust.rs +++ /dev/null @@ -1,27 +0,0 @@ -use core_types::color::Color; -use raster_types::{CPU, Raster}; -use vector_types::Gradient; - -pub trait Adjust

{ - fn adjust(&mut self, map_fn: impl Fn(&P) -> P); -} - -impl Adjust for Color { - fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { - *self = map_fn(self); - } -} - -impl Adjust for Raster { - fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { - for color in self.data_mut().data.iter_mut() { - *color = map_fn(color); - } - } -} - -impl Adjust for Gradient { - fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { - *self = self.map_colors(map_fn); - } -} diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index c7e7b6508be..1d1f1d8b0e7 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -1,24 +1,29 @@ #![allow(clippy::too_many_arguments)] -use crate::adjust::Adjust; -use crate::color_lookup_table::{LutCache, LutParseError}; +use crate::color_lookup_table::{Lut, LutCache}; +use crate::raster_kernel::{Adjust, Lut1d, Lut3d, MapColor, ToGpuParams, Wgsl, raster_kernel_pipeline}; +use crate::{gpu_param_enum, gpu_params}; use core::fmt::Debug; use core_types::color::Color; use core_types::color::{linear_to_srgb, set_luminosity, srgb_to_linear}; use core_types::context::Ctx; use core_types::list::{Item, List}; use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator}; +use core_types::{Interrupt, NodeError}; use glam::DVec2; use glam::Vec3; use graphene_resource::Resource; use num_enum::{FromPrimitive, IntoPrimitive}; -use raster_types::{CPU, Image, Raster}; +use raster_types::{CPU, GPU, Image, Raster}; +use std::sync::Arc; use vector_types::Gradient; +use wgpu_executor::WgpuPipelineCache; /// Conversion from a color to grayscale. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, dyn_any::DynAny, node_macro::ChoiceType, bytemuck::NoUninit, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] #[repr(u32)] pub enum DesaturateMethod { @@ -60,15 +65,31 @@ pub enum DesaturateMethod { LightnessHsl, } +gpu_param_enum!(u32: DesaturateMethod); + #[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"))] -fn desaturate>(_: impl Ctx, #[implementations(Raster, Color, Gradient)] input: Item, method: Item) -> Item { - let mut input = input; - let method = method.into_element(); +fn desaturate( + _: impl Ctx, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, + method: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Desaturate { method: method.into_element() }; + Ok(input.adjust(&kernel, pipeline.element())?) +} - input.element_mut().adjust(|color| { +gpu_params! { + #[derive(Clone)] + struct Desaturate { + method: DesaturateMethod, + } +} + +impl MapColor for Desaturate { + fn map_cpu(&self, color: Color) -> Color { // Gamma-encoded formulas are decoded as if they were a gray let gamma = || color.to_gamma_srgb_channels(); - let luminance = match method { + let luminance = match self.method { DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(), DesaturateMethod::LumaRec709 => { let [r, g, b, _] = gamma(); @@ -94,28 +115,48 @@ fn desaturate>(_: impl Ctx, #[implementations(Raster, Colo } }; color.map_rgb(|_| luminance) - }); - input + } + + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/desaturate.wgsl")) + } } #[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"))] -fn gamma_correction>( +fn gamma_correction( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] input: Item, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, #[default(2.2)] #[range] #[hard(0.0001..)] #[soft(0.01..10)] gamma: Item, inverse: Item, -) -> Item { - let mut input = input; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let gamma = gamma.into_element(); let inverse = inverse.into_element(); let exponent = if inverse { 1. / gamma } else { gamma }; - input.element_mut().adjust(|color| color.apply_gamma_exponent(exponent as f32)); - input + let kernel = GammaCorrection { exponent: exponent as f32 }; + Ok(input.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct GammaCorrection { + exponent: f32, + } +} + +impl MapColor for GammaCorrection { + fn map_cpu(&self, color: Color) -> Color { + color.apply_gamma_exponent(self.exponent) + } + + fn wgsl() -> Wgsl { + Wgsl::body("return apply_gamma_exponent(color, exponent);") + } } /// The red, green, blue, and alpha channels of an image, split into separate node outputs. @@ -155,10 +196,27 @@ fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { } #[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"))] -fn make_opaque>(_: impl Ctx, #[implementations(Raster, Color, Gradient)] input: Item) -> Item { - let mut input = input; - input.element_mut().adjust(|color| color.with_alpha(1.)); - input +fn make_opaque( + _: impl Ctx, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + Ok(input.adjust(&MakeOpaque {}, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct MakeOpaque {} +} + +impl MapColor for MakeOpaque { + fn map_cpu(&self, color: Color) -> Color { + color.with_alpha(1.) + } + + fn wgsl() -> Wgsl { + Wgsl::body("return vec4(color.rgb, 1.);") + } } /// Remaps a gamma-space channel through the stages of a Levels adjustment: the input range, the midtones gamma, and the output range. @@ -189,12 +247,15 @@ fn brightness_contrast_classic(value: f32, brightness: f32, contrast: f32, pivot result.clamp(0., 1.) } -/// One brightness curve of the current algorithm, for a magnitude in 0..100: a line of slope 2^(b/110) up to an output of 0.5, -/// continued by a cubic Hermite segment that eases into (1, 1). -struct BrightnessCurve { - slope: f32, - knee: f32, - end_slope: f32, +gpu_params! { + /// One brightness curve of the current algorithm, for a magnitude in 0..100: a line of slope 2^(b/110) up to an output of 0.5, + /// continued by a cubic Hermite segment that eases into (1, 1). + #[derive(Clone, Copy)] + struct BrightnessCurve { + slope: f32, + knee: f32, + end_slope: f32, + } } impl BrightnessCurve { @@ -264,9 +325,9 @@ impl BrightnessCurve { // // TODO: A Lab-only mode once Graphite supports the CIE Lab color space. #[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), icon("NodeBrightnessContrast"), inspector("brightness_contrast_inspector"))] -fn brightness_contrast>( +fn brightness_contrast( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] input: Item, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, #[unit("%")] #[range] #[hard(-150..150)] @@ -280,8 +341,8 @@ fn brightness_contrast>( #[soft(0..255)] #[default(127.)] classic_pivot: Item, -) -> Item { - let mut input = input; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let use_classic = use_classic.into_element(); let classic_pivot = (classic_pivot.into_element() / 255.) as f32; @@ -295,32 +356,59 @@ fn brightness_contrast>( let first_curve = BrightnessCurve::new(magnitude.min(100.)); let second_curve = BrightnessCurve::new((magnitude - 100.).max(0.)); - input.element_mut().adjust(|color| { + let kernel = BrightnessContrast { + use_classic, + brightness, + contrast, + classic_pivot, + first_curve, + second_curve, + }; + Ok(input.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct BrightnessContrast { + use_classic: bool, + brightness: f32, + contrast: f32, + classic_pivot: f32, + first_curve: BrightnessCurve, + second_curve: BrightnessCurve, + } +} + +impl MapColor for BrightnessContrast { + fn map_cpu(&self, color: Color) -> Color { color.map_gamma_rgb(|c| { - if use_classic { - return brightness_contrast_classic(c, brightness / 255., contrast, classic_pivot); + if self.use_classic { + return brightness_contrast_classic(c, self.brightness / 255., self.contrast, self.classic_pivot); } // Negative brightness runs the same curves in reverse - let brightened = if brightness >= 0. { - second_curve.forward(first_curve.forward(c)) + let brightened = if self.brightness >= 0. { + self.second_curve.forward(self.first_curve.forward(c)) } else { - first_curve.inverse(second_curve.inverse(c)) + self.first_curve.inverse(self.second_curve.inverse(c)) }; // Contrast pushes away from (or pulls toward) the midpoint, most strongly at the quarter tones - let contrasted = brightened + 0.76 * contrast * (2. * brightened - 1.) * brightened.min(1. - brightened); + let contrasted = brightened + 0.76 * self.contrast * (2. * brightened - 1.) * brightened.min(1. - brightened); contrasted.clamp(0., 1.) }) - }); + } - input + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/brightness_contrast.wgsl")) + } } #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] /// The channel whose settings are shown, with RGB adjusting all three color channels together. pub enum AdjustmentChannel { @@ -343,12 +431,14 @@ struct LevelsRecord { output_maximums: f32, } -/// A record's input curve followed by its output range. -#[derive(Clone, Copy)] -struct LevelsStage { - curve: LevelsCurve, - output_minimum: f32, - output_maximum: f32, +gpu_params! { + /// A record's input curve followed by its output range. + #[derive(Clone, Copy)] + struct LevelsStage { + curve: LevelsCurve, + output_minimum: f32, + output_maximum: f32, + } } impl LevelsRecord { @@ -377,12 +467,14 @@ impl LevelsStage { } } -/// A channel's record followed by the composite record. -#[derive(Clone, Copy)] -struct LevelsChain { - first: LevelsStage, - second: LevelsStage, - two_stages: bool, +gpu_params! { + /// A channel's record followed by the composite record. + #[derive(Clone, Copy)] + struct LevelsChain { + first: LevelsStage, + second: LevelsStage, + two_stages: bool, + } } impl LevelsChain { @@ -423,9 +515,9 @@ impl LevelsChain { // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html #[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"))] -fn levels>( +fn levels( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -567,8 +659,8 @@ fn levels>( #[default(100.)] alpha_output_maximums: Item, _channel: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let composite = LevelsRecord::new( shadows.into_element() as f32, midtones.into_element() as f32, @@ -617,13 +709,31 @@ fn levels>( ); let alpha = alpha.stage(alpha.midtones); - image.element_mut().adjust(|color| { + let kernel = Levels { red, green, blue, alpha }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct Levels { + red: LevelsChain, + green: LevelsChain, + blue: LevelsChain, + alpha: LevelsStage, + } +} + +impl MapColor for Levels { + fn map_cpu(&self, color: Color) -> Color { // Levels math operates in gamma space let [r, g, b, a] = color.to_gamma_srgb_channels(); - Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), alpha.apply(a)) - }); - image + Color::from_gamma_srgb_channels(self.red.apply(r), self.green.apply(g), self.blue.apply(b), self.alpha.apply(a)) + } + + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/levels_curve.wgsl"), include_str!("wgsl/levels.wgsl"))) + } } // Aims for interoperable compatibility with: @@ -633,36 +743,83 @@ fn levels>( // Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost // points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages // stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. +// Needs the heap for its curves, so it stays off the shader build for now. #[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"))] -fn curves + Send>( +fn curves( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, curve: Item, #[name("(Red) Curve")] red_curve: Item, #[name("(Green) Curve")] green_curve: Item, #[name("(Blue) Curve")] blue_curve: Item, #[name("(Alpha) Curve")] alpha_curve: Item, _channel: Item, -) -> Item { - let mut image = image; - let composite = curve.into_element().evaluator(); - let red = red_curve.into_element().evaluator(); - let green = green_curve.into_element().evaluator(); - let blue = blue_curve.into_element().evaluator(); - let alpha = alpha_curve.into_element().evaluator(); - let map = |channel: &TransferCurveEvaluator, value: f32| composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32; - - image.element_mut().adjust(|color| { - // Curves math operates in gamma space - let [r, g, b, a] = color.to_gamma_srgb_channels(); + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Curves { + composite: curve.into_element().evaluator(), + red: red_curve.into_element().evaluator(), + green: green_curve.into_element().evaluator(), + blue: blue_curve.into_element().evaluator(), + alpha: alpha_curve.into_element().evaluator(), + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +struct Curves { + composite: TransferCurveEvaluator, + red: TransferCurveEvaluator, + green: TransferCurveEvaluator, + blue: TransferCurveEvaluator, + alpha: TransferCurveEvaluator, +} + +impl Curves { + fn apply(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] { + let map = |channel: &TransferCurveEvaluator, value: f32| self.composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32; // Alpha stands apart from the composite curve that the three color channels pass through - let a = alpha.evaluate(a as f64).clamp(0., 1.) as f32; + let a = self.alpha.evaluate(a as f64).clamp(0., 1.) as f32; - Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a) - }); + [map(&self.red, r), map(&self.green, g), map(&self.blue, b), a] + } +} + +gpu_params! { + struct CurvesTable { + curves: Lut1d, + } +} + +impl ToGpuParams for Curves { + type Params = CurvesTable; + fn to_params(&self) -> CurvesTable { + const ENTRIES: usize = 4096; + let entries = (0..ENTRIES) + .map(|index| { + let [r, g, b, a] = self.apply([index as f32 / (ENTRIES - 1) as f32; 4]); + Color::from_rgbaf32_unchecked(r, g, b, a) + }) + .collect(); + CurvesTable { curves: Lut1d(entries) } + } +} - image +impl MapColor for Curves { + fn map_cpu(&self, color: Color) -> Color { + // Curves math operates in gamma space + let [r, g, b, a] = self.apply(color.to_gamma_srgb_channels()); + Color::from_gamma_srgb_channels(r, g, b, a) + } + + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let channels = to_gamma_srgb_channels(color); + return from_gamma_srgb_channels(vec4(curves(channels.r).r, curves(channels.g).g, curves(channels.b).b, curves(channels.a).a)); + "#, + ) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -691,9 +848,9 @@ fn points_to_transfer_curve( // https://stackoverflow.com/a/55233732/775283 // Works the same for gamma and linear color #[node_macro::node(name("Black & White"), category("Raster: Adjustment"), icon("NodeBlackAndWhite"), inspector("black_and_white_inspector"))] -fn black_and_white>( +fn black_and_white( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, use_tint: Item, #[default("#e1d3b3")] tint: Item, #[unit("%")] @@ -726,28 +883,51 @@ fn black_and_white>( #[range] #[soft(-200..300)] magentas: Item, -) -> Item { - let mut image = image; - let tint = tint.into_element(); - let use_tint = use_tint.into_element(); - let reds = reds.into_element(); - let yellows = yellows.into_element(); - let greens = greens.into_element(); - let cyans = cyans.into_element(); - let blues = blues.into_element(); - let magentas = magentas.into_element(); - - image.element_mut().adjust(|color| { + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = BlackAndWhite { + use_tint: use_tint.into_element(), + tint: tint.into_element(), + reds: (reds.into_element() / 100.) as f32, + yellows: (yellows.into_element() / 100.) as f32, + greens: (greens.into_element() / 100.) as f32, + cyans: (cyans.into_element() / 100.) as f32, + blues: (blues.into_element() / 100.) as f32, + magentas: (magentas.into_element() / 100.) as f32, + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct BlackAndWhite { + use_tint: bool, + tint: Color, + reds: f32, + yellows: f32, + greens: f32, + cyans: f32, + blues: f32, + magentas: f32, + } +} + +impl MapColor for BlackAndWhite { + fn map_cpu(&self, color: Color) -> Color { + let BlackAndWhite { + use_tint, + tint, + reds, + yellows, + greens, + cyans, + blues, + magentas, + } = *self; + // Black & White channel weights are tuned for gamma-space values let [r, g, b, alpha_part] = color.to_gamma_srgb_channels(); - let reds = (reds / 100.) as f32; - let yellows = (yellows / 100.) as f32; - let greens = (greens / 100.) as f32; - let cyans = (cyans / 100.) as f32; - let blues = (blues / 100.) as f32; - let magentas = (magentas / 100.) as f32; - let gray_base = r.min(g).min(b); let red_part = r - gray_base; @@ -776,14 +956,18 @@ fn black_and_white>( let [tinted_r, tinted_g, tinted_b] = set_luminosity(tint_r, tint_g, tint_b, tint_luma, luminance); Color::from_gamma_srgb_channels(tinted_r, tinted_g, tinted_b, alpha_part) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::function(format!("{}\n{}", include_str!("wgsl/luma.wgsl"), include_str!("wgsl/black_and_white.wgsl"))) + } } #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] pub enum HueSaturationRange { #[default] @@ -856,32 +1040,39 @@ fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { [(r + m).clamp(0., 1.), (g + m).clamp(0., 1.), (b + m).clamp(0., 1.)] } -/// One set of Hue/Saturation sliders: a hue shift in degrees and saturation and lightness amounts in -1..1. -#[derive(Clone, Copy)] -struct HueSaturationSettings { - hue: f32, - saturation: f32, - lightness: f32, +gpu_params! { + /// One set of Hue/Saturation sliders: a hue shift in degrees and saturation and lightness amounts in -1..1. + #[derive(Clone, Copy)] + struct HueSaturationSettings { + hue: f32, + saturation: f32, + saturation_gain: f32, + lightness: f32, + } } impl HueSaturationSettings { fn new(hue: f32, saturation_percent: f32, lightness_percent: f32) -> Self { + let saturation = saturation_percent / 100.; Self { hue, - saturation: saturation_percent / 100., + saturation, + saturation_gain: saturation_gain(saturation), lightness: lightness_percent / 100., } } } -/// A hue range with its falloff: full weight from `range_start` to `range_end`, fading linearly to zero at the falloff ends. -#[derive(Clone, Copy)] -struct HueSaturationRangeSettings { - falloff_start: f32, - range_start: f32, - range_end: f32, - falloff_end: f32, - settings: HueSaturationSettings, +gpu_params! { + /// A hue range with its falloff: full weight from `range_start` to `range_end`, fading linearly to zero at the falloff ends. + #[derive(Clone, Copy)] + struct HueSaturationRangeSettings { + falloff_start: f32, + range_start: f32, + range_end: f32, + falloff_end: f32, + settings: HueSaturationSettings, + } } impl HueSaturationRangeSettings { @@ -936,7 +1127,7 @@ impl HueSaturationRangeEffect { if range.settings.saturation >= 1. { self.fully_saturate = true; } else { - self.saturation_factor *= 1. + (saturation_gain(range.settings.saturation) - 1.) * weight; + self.saturation_factor *= 1. + (range.settings.saturation_gain - 1.) * weight; } self.rgb = lightness_toward_max_or_min(self.rgb, range.settings.lightness * weight); } @@ -971,9 +1162,9 @@ fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { // // TODO: Residuals in 8-bit PSD interop: the byte-hue colorize table, the colorize lightness slider (up to 2.5 levels), and the range edges (a few tenths of a degree) #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), icon("NodeHueSaturation"), inspector("hue_saturation_inspector"))] -fn hue_saturation>( +fn hue_saturation( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] input: Item, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, #[unit("°")] #[range] #[soft(-180..180)] @@ -1240,8 +1431,8 @@ fn hue_saturation>( #[default(345.)] magentas_falloff_end: Item, _range: Item, -) -> Item { - let mut input = input; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let master = HueSaturationSettings::new(hue.into_element() as f32, saturation.into_element() as f32, lightness.into_element() as f32); let colorize = colorize.into_element(); let colorize_settings = HueSaturationSettings::new(colorize_hue.into_element() as f32, colorize_saturation.into_element() as f32, colorize_lightness.into_element() as f32); @@ -1290,7 +1481,48 @@ fn hue_saturation>( ), ); - input.element_mut().adjust(|color| { + let kernel = HueSaturation { + master, + colorize, + colorize_settings, + reds, + yellows, + greens, + cyans, + blues, + magentas, + }; + Ok(input.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone, Copy)] + struct HueSaturation { + master: HueSaturationSettings, + colorize: bool, + colorize_settings: HueSaturationSettings, + reds: HueSaturationRangeSettings, + yellows: HueSaturationRangeSettings, + greens: HueSaturationRangeSettings, + cyans: HueSaturationRangeSettings, + blues: HueSaturationRangeSettings, + magentas: HueSaturationRangeSettings, + } +} + +impl MapColor for HueSaturation { + fn map_cpu(&self, color: Color) -> Color { + let Self { + master, + colorize, + colorize_settings, + reds, + yellows, + greens, + cyans, + blues, + magentas, + } = *self; let [r, g, b, alpha] = color.to_gamma_srgb_channels(); if colorize { @@ -1323,7 +1555,7 @@ fn hue_saturation>( if master.saturation >= 1. { fully_saturate = true; } else { - saturation_factor *= saturation_gain(master.saturation); + saturation_factor *= master.saturation_gain; } // The master lightness blends toward white or black before the hue and saturation, which work in HSL of the gamma channels @@ -1343,25 +1575,50 @@ fn hue_saturation>( let [r, g, b] = hsl_to_gamma_rgb(hue + hue_shift, saturation, lightness); Color::from_gamma_srgb_channels(r, g, b, alpha) - }); - input + } + + fn wgsl() -> Wgsl { + Wgsl::function(format!("{}\n{}", include_str!("wgsl/hue.wgsl"), include_str!("wgsl/hue_saturation.wgsl"))) + } } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize #[node_macro::node(category("Raster: Adjustment"), icon("NodeInvert"))] -fn invert>(_: impl Ctx, #[implementations(Raster, Color, Gradient)] input: Item) -> Item { - let mut input = input; - input.element_mut().adjust(|color| color.map_gamma_rgb(|channel| 1. - channel)); - input +fn invert( + _: impl Ctx, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + Ok(input.adjust(&Invert {}, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct Invert {} +} + +impl MapColor for Invert { + fn map_cpu(&self, color: Color) -> Color { + color.map_gamma_rgb(|channel| 1. - channel) + } + + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let channels = to_gamma_srgb_channels(color); + return from_gamma_srgb_channels(vec4(1. - channels.rgb, channels.a)); + "#, + ) + } } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient #[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"))] -fn threshold>( +fn threshold( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -1372,20 +1629,47 @@ fn threshold>( #[hard(0..100)] #[default(100.)] max_luminance: Item, -) -> Item { - let mut image = image; - let min_luminance = (min_luminance.into_element() / 100.) as f32; - let max_luminance = (max_luminance.into_element() / 100.) as f32; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Threshold { + min_luminance: (min_luminance.into_element() / 100.) as f32, + max_luminance: (max_luminance.into_element() / 100.) as f32, + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct Threshold { + min_luminance: f32, + max_luminance: f32, + } +} - image.element_mut().adjust(|color| { +impl MapColor for Threshold { + fn map_cpu(&self, color: Color) -> Color { // For PSD interop, we compare this 14-bit fixed-point Rec. 601 luma against the level unrounded let [r, g, b, _] = color.to_gamma_srgb_channels(); let luminance = (4915. * r + 9667. * g + 1802. * b) / 16384.; - let output = if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK }; + let output = if luminance >= self.min_luminance && luminance <= self.max_luminance { + Color::WHITE + } else { + Color::BLACK + }; output.with_alpha(color.a()) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let channels = to_gamma_srgb_channels(color); + let luminance = (4915. * channels.r + 9667. * channels.g + 1802. * channels.b) / 16384.; + let output = select(vec3(0.), vec3(1.), luminance >= min_luminance && luminance <= max_luminance); + return vec4(output, color.a); + "#, + ) + } } // Aims for interoperable compatibility with: @@ -1402,37 +1686,83 @@ fn threshold>( // TODO: `(c[i + 1] - c[i - 1]) / 2`, the end stops repeated past the ends, so two stops give `0.5 p + 1.5 p^2 - p^3`. // TODO: - The ramp is `(1 - s) * linear + s * smooth` for smoothness s, clamped per interval to its two stop colors. #[node_macro::node(category("Raster: Adjustment"), icon("NodeGradientMap"))] -fn gradient_map + Send>( +fn gradient_map( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[default(Color::BLACK, Color::WHITE)] gradient: Item, reverse: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let settings = vector_types::GradientSettings::from(&gradient); - let evaluator = gradient.into_element().evaluator(settings); - let reverse = reverse.into_element(); + let gradient = gradient.into_element(); + let kernel = GradientMap { + evaluator: gradient.evaluator(settings), + padded: gradient.evaluator(vector_types::GradientSettings { + spread: vector_types::GradientSpread::Pad, + ..settings + }), + spread: settings.spread, + stepped: settings.interpolation == vector_types::gradient::GradientInterpolation::Stepped, + reverse: reverse.into_element(), + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +struct GradientMap { + evaluator: vector_types::gradient::GradientEvaluator, + /// The gradient across 0 to 1 without its spread, which the GPU applies before reading the table. + padded: vector_types::gradient::GradientEvaluator, + spread: vector_types::GradientSpread, + stepped: bool, + reverse: bool, +} + +gpu_params! { + struct GradientMapTable { + reverse: bool, + stepped: bool, + spread: u32, + gradient: Lut1d, + } +} - image.element_mut().adjust(|color| { +impl ToGpuParams for GradientMap { + type Params = GradientMapTable; + fn to_params(&self) -> GradientMapTable { + const ENTRIES: usize = 1024; + let entries = (0..ENTRIES).map(|index| self.padded.evaluate(index as f64 / (ENTRIES - 1) as f64)).collect(); + GradientMapTable { + reverse: self.reverse, + stepped: self.stepped, + spread: self.spread as u32, + gradient: Lut1d(entries), + } + } +} + +impl MapColor for GradientMap { + fn map_cpu(&self, color: Color) -> Color { // The classic 0.3/0.59/0.11 luma of the gamma-encoded channels picks the position along the gradient let [r, g, b, alpha] = color.to_gamma_srgb_channels(); let intensity = 0.3 * r + 0.59 * g + 0.11 * b; - let intensity = if reverse { 1. - intensity } else { intensity }; + let intensity = if self.reverse { 1. - intensity } else { intensity }; // The source alpha is kept and the gradient's own alpha stops are ignored - evaluator.evaluate(intensity as f64).with_alpha(alpha) - }); + self.evaluator.evaluate(intensity as f64).with_alpha(alpha) + } - image + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/gradient_map.wgsl")) + } } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3) #[node_macro::node(category("Raster: Adjustment"), icon("NodeVibrance"), inspector("vibrance_inspector"))] -fn vibrance>( +fn vibrance( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(-100..100)] @@ -1441,16 +1771,29 @@ fn vibrance>( #[range] #[hard(-100..100)] saturation: Item, -) -> Item { - let mut image = image; - let vibrance = (vibrance.into_element() / 100.) as f32; - let saturation_scale = (1. + saturation.into_element() / 100.) as f32; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Vibrance { + vibrance: (vibrance.into_element() / 100.) as f32, + saturation_scale: (1. + saturation.into_element() / 100.) as f32, + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} - // Vibrance then saturation, both in linear light, which equals applying each alone in turn - image.element_mut().adjust(|color| { +gpu_params! { + #[derive(Clone)] + struct Vibrance { + vibrance: f32, + saturation_scale: f32, + } +} + +impl MapColor for Vibrance { + fn map_cpu(&self, color: Color) -> Color { + // Vibrance then saturation, both in linear light, which equals applying each alone in turn let (r, g, b) = (color.r(), color.g(), color.b()); let maximum = r.max(g).max(b); - let [chroma_factor, brightness_factor] = vibrance_factors(r, g, b, vibrance); + let [chroma_factor, brightness_factor] = vibrance_factors(r, g, b, self.vibrance); let after_vibrance = Color::from_rgbaf32_unchecked( scale_about_maximum(r, maximum, chroma_factor, brightness_factor), scale_about_maximum(g, maximum, chroma_factor, brightness_factor), @@ -1460,9 +1803,12 @@ fn vibrance>( // For PSD interop, saturation scales each channel's distance from a gray weighted by ProPhoto's luminance coefficients let gray = 0.288040 * after_vibrance.r() + 0.711874 * after_vibrance.g() + 0.000086 * after_vibrance.b(); - after_vibrance.map_rgb(|c| (gray + (c - gray) * saturation_scale).clamp(0., 1.)) - }); - image + after_vibrance.map_rgb(|c| (gray + (c - gray) * self.saturation_scale).clamp(0., 1.)) + } + + fn wgsl() -> Wgsl { + Wgsl::function(format!("{}\n{}", include_str!("wgsl/hue.wgsl"), include_str!("wgsl/vibrance.wgsl"))) + } } fn scale_about_maximum(channel: f32, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> f32 { @@ -1512,8 +1858,9 @@ fn skin_tone_window(hue: f32) -> f32 { #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] #[widget(Radio)] pub enum RedGreenBlue { #[default] @@ -1523,8 +1870,9 @@ pub enum RedGreenBlue { } // Kept only to deserialize the channel input of old Extract Channel nodes for document migration +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum RedGreenBlueAlpha { #[default] Red, @@ -1535,8 +1883,9 @@ pub enum RedGreenBlueAlpha { /// Style of noise pattern. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] #[widget(Dropdown)] pub enum NoiseType { #[default] @@ -1553,8 +1902,9 @@ pub enum NoiseType { /// Style of layered levels of the noise pattern. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] pub enum FractalType { #[default] None, @@ -1570,8 +1920,9 @@ pub enum FractalType { /// Distance function used by the cellular noise. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] pub enum CellularDistanceFunction { #[default] Euclidean, @@ -1582,8 +1933,9 @@ pub enum CellularDistanceFunction { } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] pub enum CellularReturnType { CellValue, #[default] @@ -1602,8 +1954,9 @@ pub enum CellularReturnType { } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] #[widget(Dropdown)] pub enum DomainWarpType { #[default] @@ -1621,9 +1974,9 @@ pub enum DomainWarpType { // // TODO: CMYK source channels once Graphite supports the CMYK color space. #[node_macro::node(category("Raster: Adjustment"), icon("NodeChannelMixer"), inspector("channel_mixer_inspector"))] -fn channel_mixer>( +fn channel_mixer( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, monochrome: Item, @@ -1729,8 +2082,8 @@ fn channel_mixer>( // Display-only parameters (not used within the node) _output_channel: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let monochrome = monochrome.into_element(); let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = ( monochrome_r.into_element() as f32, @@ -1747,39 +2100,83 @@ fn channel_mixer>( ); let (blue_r, blue_g, blue_b, blue_c) = (blue_r.into_element() as f32, blue_g.into_element() as f32, blue_b.into_element() as f32, blue_c.into_element() as f32); - image.element_mut().adjust(|color| { + // Weights and constants are 10-bit fixed point truncated toward zero, which PSD interop depends on + let weight = |percent: f32| (percent * 1024. / 100.).trunc() / 1024.; + + let kernel = ChannelMixer { + monochrome, + monochrome_r: weight(monochrome_r), + monochrome_g: weight(monochrome_g), + monochrome_b: weight(monochrome_b), + monochrome_c: weight(monochrome_c), + red_r: weight(red_r), + red_g: weight(red_g), + red_b: weight(red_b), + red_c: weight(red_c), + green_r: weight(green_r), + green_g: weight(green_g), + green_b: weight(green_b), + green_c: weight(green_c), + blue_r: weight(blue_r), + blue_g: weight(blue_g), + blue_b: weight(blue_b), + blue_c: weight(blue_c), + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct ChannelMixer { + monochrome: bool, + monochrome_r: f32, + monochrome_g: f32, + monochrome_b: f32, + monochrome_c: f32, + red_r: f32, + red_g: f32, + red_b: f32, + red_c: f32, + green_r: f32, + green_g: f32, + green_b: f32, + green_c: f32, + blue_r: f32, + blue_g: f32, + blue_b: f32, + blue_c: f32, + } +} + +impl MapColor for ChannelMixer { + fn map_cpu(&self, color: Color) -> Color { let [r, g, b, a] = color.to_gamma_srgb_channels(); - // Weights and constants are 10-bit fixed point truncated toward zero, which PSD interop depends on - let weight = |percent: f32| (percent * 1024. / 100.).trunc() / 1024.; - - let (out_r, out_g, out_b) = if monochrome { - let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (weight(monochrome_r), weight(monochrome_g), weight(monochrome_b), weight(monochrome_c)); - - let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.); + let (out_r, out_g, out_b) = if self.monochrome { + let gray = (r * self.monochrome_r + g * self.monochrome_g + b * self.monochrome_b + self.monochrome_c).clamp(0., 1.); (gray, gray, gray) } else { - let (red_r, red_g, red_b, red_c) = (weight(red_r), weight(red_g), weight(red_b), weight(red_c)); - let (green_r, green_g, green_b, green_c) = (weight(green_r), weight(green_g), weight(green_b), weight(green_c)); - let (blue_r, blue_g, blue_b, blue_c) = (weight(blue_r), weight(blue_g), weight(blue_b), weight(blue_c)); - - let red = (r * red_r + g * red_g + b * red_b + red_c).clamp(0., 1.); - let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.); - let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.); + let red = (r * self.red_r + g * self.red_g + b * self.red_b + self.red_c).clamp(0., 1.); + let green = (r * self.green_r + g * self.green_g + b * self.green_b + self.green_c).clamp(0., 1.); + let blue = (r * self.blue_r + g * self.blue_g + b * self.blue_b + self.blue_c).clamp(0., 1.); (red, green, blue) }; Color::from_gamma_srgb_channels(out_r, out_g, out_b, a) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/channel_mixer.wgsl")) + } } #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] #[widget(Radio)] pub enum RelativeAbsolute { #[default] @@ -1787,10 +2184,13 @@ pub enum RelativeAbsolute { Absolute, } +gpu_param_enum!(u32: RelativeAbsolute); + #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] pub enum SelectiveColorChoice { #[default] Reds, @@ -1806,6 +2206,8 @@ pub enum SelectiveColorChoice { Blacks, } +gpu_param_enum!(u32: SelectiveColorChoice); + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27selc%27%20%3D%20Selective%20color // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=from%20%2D100...100.%20.-,Selective%20Color,-Selective%20Color%20settings @@ -1813,9 +2215,9 @@ pub enum SelectiveColorChoice { // Algorithm based on: // https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html #[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"))] -fn selective_color>( +fn selective_color( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, mode: Item, @@ -2009,8 +2411,8 @@ fn selective_color>( k_k: Item, _colors: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let mode = mode.into_element(); let (r_c, r_m, r_y, r_k) = (r_c.into_element() as f32, r_m.into_element() as f32, r_y.into_element() as f32, r_k.into_element() as f32); let (y_c, y_m, y_y, y_k) = (y_c.into_element() as f32, y_m.into_element() as f32, y_y.into_element() as f32, y_k.into_element() as f32); @@ -2022,7 +2424,62 @@ fn selective_color>( let (n_c, n_m, n_y, n_k) = (n_c.into_element() as f32, n_m.into_element() as f32, n_y.into_element() as f32, n_k.into_element() as f32); let (k_c, k_m, k_y, k_k) = (k_c.into_element() as f32, k_m.into_element() as f32, k_y.into_element() as f32, k_k.into_element() as f32); - image.element_mut().adjust(|color| { + // For PSD interop, the combined percent (c + k + c k / 100) rounds half up to an integer + let ink = |color: f32, k: f32| { + let percent = ((2. * (100. * (color + k) + color * k) + 100.) / 200.).floor(); + match mode { + // The multiplier is stored as one byte, 255 / b above 1 and b / 255 below, so 99% and 100% both act as 127/128 + RelativeAbsolute::Relative => { + let multiplier = 1. + percent / 100.; + if multiplier >= 1. { + 255. / (255. / multiplier).round() - 1. + } else { + (255. * multiplier).round() / 255. - 1. + } + } + RelativeAbsolute::Absolute => percent / 100., + } + }; + + let groups = [ + (SelectiveColorChoice::Reds, (r_c, r_m, r_y, r_k)), + (SelectiveColorChoice::Yellows, (y_c, y_m, y_y, y_k)), + (SelectiveColorChoice::Greens, (g_c, g_m, g_y, g_k)), + (SelectiveColorChoice::Cyans, (c_c, c_m, c_y, c_k)), + (SelectiveColorChoice::Blues, (b_c, b_m, b_y, b_k)), + (SelectiveColorChoice::Magentas, (m_c, m_m, m_y, m_k)), + (SelectiveColorChoice::Whites, (w_c, w_m, w_y, w_k)), + (SelectiveColorChoice::Neutrals, (n_c, n_m, n_y, n_k)), + (SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)), + ] + .map(|(color_parameter_group, (c, m, y, k))| SelectiveColorGroup { + color_parameter_group, + cmyk: [c, m, y, k], + inks: [ink(c, k), ink(m, k), ink(y, k)], + }); + + Ok(image.adjust(&SelectiveColor { mode, groups }, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone, Copy)] + struct SelectiveColorGroup { + color_parameter_group: SelectiveColorChoice, + cmyk: [f32; 4], + inks: [f32; 3], + } +} + +gpu_params! { + #[derive(Clone)] + struct SelectiveColor { + mode: RelativeAbsolute, + groups: [SelectiveColorGroup; 9], + } +} + +impl MapColor for SelectiveColor { + fn map_cpu(&self, color: Color) -> Color { let [r, g, b, a] = color.to_gamma_srgb_channels(); let min = |a: f32, b: f32, c: f32| a.min(b).min(c); @@ -2049,24 +2506,18 @@ fn selective_color>( let color_parameter_group_scale_factor_cmy = med(r, g, b) - min(r, g, b); // Used to apply the r, g, or b channel slope (by multiplying it by 1) in relative mode, or no slope (by multiplying it by 0) in absolute mode - let (slope_r, slope_g, slope_b) = match mode { + let (slope_r, slope_g, slope_b) = match self.mode { RelativeAbsolute::Relative => (r - 1., g - 1., b - 1.), RelativeAbsolute::Absolute => (-1., -1., -1.), }; - let array = [ - (SelectiveColorChoice::Reds, (r_c, r_m, r_y, r_k)), - (SelectiveColorChoice::Yellows, (y_c, y_m, y_y, y_k)), - (SelectiveColorChoice::Greens, (g_c, g_m, g_y, g_k)), - (SelectiveColorChoice::Cyans, (c_c, c_m, c_y, c_k)), - (SelectiveColorChoice::Blues, (b_c, b_m, b_y, b_k)), - (SelectiveColorChoice::Magentas, (m_c, m_m, m_y, m_k)), - (SelectiveColorChoice::Whites, (w_c, w_m, w_y, w_k)), - (SelectiveColorChoice::Neutrals, (n_c, n_m, n_y, n_k)), - (SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)), - ]; let mut sum = Vec3::ZERO; - for &(color_parameter_group, (c, m, y, k)) in &array { + for SelectiveColorGroup { + color_parameter_group, + cmyk: [c, m, y, k], + inks: [ink_c, ink_m, ink_y], + } in self.groups + { // Skip this color parameter group... // ...if it's unchanged from the default of zero offset on all CMYK parameters, or... // ...if this pixel's color isn't in the range affected by this color parameter group @@ -2082,26 +2533,9 @@ fn selective_color>( SelectiveColorChoice::Blacks => 1. - max(r, g, b) * 2., }; - // For PSD interop, the combined percent (c + k + c k / 100) rounds half up to an integer - let ink = |color: f32| { - let percent = ((2. * (100. * (color + k) + color * k) + 100.) / 200.).floor(); - match mode { - // The multiplier is stored as one byte, 255 / b above 1 and b / 255 below, so 99% and 100% both act as 127/128 - RelativeAbsolute::Relative => { - let multiplier = 1. + percent / 100.; - if multiplier >= 1. { - 255. / (255. / multiplier).round() - 1. - } else { - (255. * multiplier).round() / 255. - 1. - } - } - RelativeAbsolute::Absolute => percent / 100., - } - }; - - let offset_r = (ink(c) * slope_r).max(-r).min(-r + 1.) * color_parameter_group_scale_factor; - let offset_g = (ink(m) * slope_g).max(-g).min(-g + 1.) * color_parameter_group_scale_factor; - let offset_b = (ink(y) * slope_b).max(-b).min(-b + 1.) * color_parameter_group_scale_factor; + let offset_r = (ink_c * slope_r).max(-r).min(-r + 1.) * color_parameter_group_scale_factor; + let offset_g = (ink_m * slope_g).max(-g).min(-g + 1.) * color_parameter_group_scale_factor; + let offset_b = (ink_y * slope_b).max(-b).min(-b + 1.) * color_parameter_group_scale_factor; // An 8-bit PSD document sums the groups' 8-bit offsets, which this float node does not currently attempt to reproduce sum += Vec3::new(offset_r, offset_g, offset_b); @@ -2111,32 +2545,59 @@ fn selective_color>( let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE); Color::from_gamma_srgb_channels(out.x, out.y, out.z, a) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/selective_color.wgsl")) + } } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold #[node_macro::node(category("Raster: Adjustment"), icon("NodePosterize"))] -fn posterize>( +fn posterize( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] input: Item, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, #[default(4)] #[hard(2..)] levels: Item, -) -> Item { - let mut input = input; - let levels = levels.into_element() as f32; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Posterize { + levels: levels.into_element().clamp(i32::MIN as i64, i32::MAX as i64) as i32, + }; + Ok(input.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct Posterize { + levels: i32, + } +} + +impl MapColor for Posterize { + fn map_cpu(&self, color: Color) -> Color { + let levels = self.levels as f32; - input.element_mut().adjust(|color| { color.map_gamma_rgb(|c| { // Bins as floor(c * levels) with the outputs spread evenly to white. // The sliver of slack keeps an input exactly on an edge in the upper bin despite float ties. let bin = ((c + 2e-7) * levels).floor().min(levels - 1.); bin / (levels - 1.) }) - }); - input + } + + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let channels = to_gamma_srgb_channels(color); + let level_count = f32(levels); + let bin = min(floor((channels.rgb + 2e-7) * level_count), vec3(level_count - 1.)); + return from_gamma_srgb_channels(vec4(bin / (level_count - 1.), channels.a)); + "#, + ) + } } // Aims for interoperable compatibility with: @@ -2146,9 +2607,9 @@ fn posterize>( // The exposure, offset, and gamma operations follow: // https://geraldbakker.nl/psnumbers/exposure.html #[node_macro::node(category("Raster: Adjustment"), icon("NodeExposure"), inspector("exposure_inspector"))] -fn exposure>( +fn exposure( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] input: Item, + #[implementations(Raster, Raster, Color, Gradient)] input: Item, #[range] #[soft(-20..20)] exposure: Item, @@ -2160,33 +2621,51 @@ fn exposure>( #[hard(0.0001..)] #[soft(0.01..10)] gamma_correction: Item, -) -> Item { - let mut input = input; - let exposure = exposure.into_element() as f32; - let offset = offset.into_element() as f32; - let gamma_correction = gamma_correction.into_element() as f32; - - // Linearizes with a 2.2 power above a straight toe of slope 1/32, the two meeting at this constant - const TOE_END: f32 = 0.05568117; // 32^(-1. / 1.2) - - let decode = |value: f32| if value < TOE_END { value / 32. } else { value.powf(2.2) }; - let encode = |linear: f32| if linear < TOE_END / 32. { linear * 32. } else { linear.powf(1. / 2.2) }; - let adjust = |c: f32| { - let linear = decode(c) * 2_f32.powf(exposure) + offset; - encode(linear.max(0.).powf(1. / gamma_correction).min(1.)) + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = Exposure { + exposure: exposure.into_element() as f32, + offset: offset.into_element() as f32, + gamma_correction: gamma_correction.into_element() as f32, }; + Ok(input.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct Exposure { + exposure: f32, + offset: f32, + gamma_correction: f32, + } +} + +impl MapColor for Exposure { + fn map_cpu(&self, color: Color) -> Color { + // Linearizes with a 2.2 power above a straight toe of slope 1/32, the two meeting at this constant + const TOE_END: f32 = 0.05568117; // 32^(-1. / 1.2) + + let decode = |value: f32| if value < TOE_END { value / 32. } else { value.powf(2.2) }; + let encode = |linear: f32| if linear < TOE_END / 32. { linear * 32. } else { linear.powf(1. / 2.2) }; + let adjust = |c: f32| { + let linear = decode(c) * 2_f32.powf(self.exposure) + self.offset; + encode(linear.max(0.).powf(1. / self.gamma_correction).min(1.)) + }; - input.element_mut().adjust(|color| { let [r, g, b, a] = color.to_gamma_srgb_channels(); Color::from_gamma_srgb_channels(adjust(r), adjust(g), adjust(b), a) - }); - input + } + + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/exposure.wgsl")) + } } #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(dyn_any::DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] pub enum TonalRange { Shadows, @@ -2195,17 +2674,19 @@ pub enum TonalRange { Highlights, } -/// A Levels-style tone curve: input black and white points on a 0..255 scale and a gamma exponent. For gamma above 1 the -/// power curve's slope is unbounded at black, so a cubic toe holds it to 2^gamma until past where that line meets the curve. -#[derive(Debug, Clone, Copy)] -struct LevelsCurve { - black: f32, - white: f32, - exponent: f32, - toe_end: f32, - toe_value: f32, - toe_slope_start: f32, - toe_slope_end: f32, +gpu_params! { + /// A Levels-style tone curve: input black and white points on a 0..255 scale and a gamma exponent. For gamma above 1 the + /// power curve's slope is unbounded at black, so a cubic toe holds it to 2^gamma until past where that line meets the curve. + #[derive(Debug, Clone, Copy)] + struct LevelsCurve { + black: f32, + white: f32, + exponent: f32, + toe_end: f32, + toe_value: f32, + toe_slope_start: f32, + toe_slope_end: f32, + } } impl LevelsCurve { @@ -2279,9 +2760,9 @@ fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i3 // Every channel is a Levels curve whose black point, white point, and two-decimal gamma are derived from // the nine sliders, see `color_balance_curve`. #[node_macro::node(category("Raster: Adjustment"), icon("NodeColorBalance"), inspector("color_balance_inspector"))] -fn color_balance>( +fn color_balance( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[name("(Shadows) Cyan-Red")] #[unit("%")] @@ -2335,8 +2816,8 @@ fn color_balance>( // Display-only parameter (not used within the node) _tone: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let preserve_luminosity = preserve_luminosity.into_element(); // The derivation below is integer arithmetic, so the sliders round to whole percentages first @@ -2365,21 +2846,38 @@ fn color_balance>( let green = color_balance_curve(s_g, m_g, h_g, s_max, m_max, m_min, h_min, preserve_luminosity); let blue = color_balance_curve(s_b, m_b, h_b, s_max, m_max, m_min, h_min, preserve_luminosity); - image.element_mut().adjust(|color| { + let kernel = ColorBalance { red, green, blue }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct ColorBalance { + red: LevelsCurve, + green: LevelsCurve, + blue: LevelsCurve, + } +} + +impl MapColor for ColorBalance { + fn map_cpu(&self, color: Color) -> Color { // The curves operate on gamma-space channel values let [r, g, b, a] = color.to_gamma_srgb_channels(); - Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), a) - }); - image + Color::from_gamma_srgb_channels(self.red.apply(r), self.green.apply(g), self.blue.apply(b), a) + } + + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/levels_curve.wgsl"), include_str!("wgsl/color_balance.wgsl"))) + } } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl #[node_macro::node(category("Raster: Adjustment"), icon("NodePhotoFilter"))] -fn photo_filter>( +fn photo_filter( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[default("#ec8a00")] color: Item, #[unit("%")] #[range] @@ -2387,8 +2885,8 @@ fn photo_filter>( #[default(25.)] density: Item, #[default(true)] preserve_luminosity: Item, -) -> Item { - let mut image = image; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let color = color.into_element(); let density = (density.into_element() / 100.) as f32; let preserve_luminosity = preserve_luminosity.into_element(); @@ -2401,7 +2899,22 @@ fn photo_filter>( 1. + density * (filter_xyz[2] / WHITE_XYZ_D50[2] - 1.), ]; - image.element_mut().adjust(|pixel| { + let kernel = PhotoFilter { factor, preserve_luminosity }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_params! { + #[derive(Clone)] + struct PhotoFilter { + factor: [f32; 3], + preserve_luminosity: bool, + } +} + +impl MapColor for PhotoFilter { + fn map_cpu(&self, pixel: Color) -> Color { + let PhotoFilter { factor, preserve_luminosity } = *self; + let [r_in, g_in, b_in, alpha] = pixel.to_gamma_srgb_channels(); let xyz = multiply_matrix(&SRGB_TO_XYZ_D50, [srgb_to_linear(r_in), srgb_to_linear(g_in), srgb_to_linear(b_in)]); let filtered = multiply_matrix(&XYZ_D50_TO_SRGB, [xyz[0] * factor[0], xyz[1] * factor[1], xyz[2] * factor[2]]); @@ -2415,8 +2928,11 @@ fn photo_filter>( } Color::from_gamma_srgb_channels(r, g, b, alpha) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::function(format!("{}\n{}", include_str!("wgsl/luma.wgsl"), include_str!("wgsl/photo_filter.wgsl"))) + } } // sRGB colorants adapted to D50 as in the sRGB IEC61966-2.1 ICC profile, row major, and their inverse @@ -2444,32 +2960,64 @@ fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { // TODO: Support dither, which needs the pixel position that a per-color adjustment never sees. // TODO: Verify the tetrahedral interpolation, which is unconfirmed against other implementations. #[node_macro::node(category("Raster: Adjustment"), icon("NodeColorLookup"))] -fn color_lookup + Send>( +fn color_lookup( _: impl Ctx, /// The image whose colors are remapped by the LUT (lookup table). - #[implementations(Raster, Color, Gradient)] + #[implementations(Raster, Raster, Color, Gradient)] image: Item, /// A LUT (lookup table) file in the `.cube`, `.3dl`, `.look`, `.csp`, or `.icc` (*abstract* or *device link* ICC profile) format. #[name("LUT File")] #[widget(ParsedWidgetOverride::Custom = "lut_file")] lut_file: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, #[data] lut_cache: LutCache, -) -> Result, LutParseError> { - let mut image = image; +) -> Result, Interrupt> { if lut_file.element().is_empty() { return Ok(image); } - let lut = lut_cache.parse(lut_file.element())?; + let lut = lut_cache.parse(lut_file.element()).map_err(NodeError::new)?; + Ok(image.adjust(&ColorLookup { lut }, pipeline.element())?) +} + +struct ColorLookup { + lut: Arc, +} + +gpu_params! { + struct ColorLookupTable { + domain_min: [f32; 3], + domain_max: [f32; 3], + table: Lut3d, + } +} + +impl ToGpuParams for ColorLookup { + type Params = ColorLookupTable; + fn to_params(&self) -> ColorLookupTable { + let grid = self.lut.grid(); + ColorLookupTable { + domain_min: grid.domain_min, + domain_max: grid.domain_max, + table: Lut3d { + size: grid.size as u32, + data: grid.entries.into_iter().map(|[r, g, b]| Color::from_rgbaf32_unchecked(r, g, b, 1.)).collect(), + }, + } + } +} - image.element_mut().adjust(|color| { +impl MapColor for ColorLookup { + fn map_cpu(&self, color: Color) -> Color { // Lookup tables address the gamma-encoded channels let [r, g, b, a] = color.to_gamma_srgb_channels(); - let [r, g, b] = lut.apply([r, g, b]); + let [r, g, b] = self.lut.apply([r, g, b]); Color::from_gamma_srgb_channels(r, g, b, a) - }); + } - Ok(image) + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/color_lookup.wgsl")) + } } mod _graphene_hash_impls { @@ -2514,8 +3062,16 @@ mod tests { /// Runs the node on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_brightness_contrast(value: f32, brightness: f64, contrast: f64, use_classic: bool) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); - let result = brightness_contrast((), Item::new_from_element(pixel), brightness.into(), contrast.into(), use_classic.into(), 127_f64.into()); - result.into_element().to_gamma_srgb_channels()[0] * 255. + let result = brightness_contrast( + (), + Item::new_from_element(pixel), + brightness.into(), + contrast.into(), + use_classic.into(), + 127_f64.into(), + Item::default(), + ); + result.unwrap().into_element().to_gamma_srgb_channels()[0] * 255. } #[test] @@ -2612,8 +3168,9 @@ mod tests { 0_f64.into(), 100_f64.into(), AdjustmentChannel::Rgb.into(), + Item::default(), ); - let [r, g, _, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, _, _] = result.unwrap().into_element().to_gamma_srgb_channels(); [r * 255., g * 255.] } @@ -2657,7 +3214,9 @@ mod tests { 60_f64.into(), 20_f64.into(), 80_f64.into(), - ); + Item::default(), + ) + .unwrap(); let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2767,8 +3326,9 @@ mod tests { 315_f64.into(), 345_f64.into(), HueSaturationRange::Master.into(), + Item::default(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = result.unwrap().into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2857,7 +3417,7 @@ mod tests { fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); - let inverted = invert((), Item::new_from_element(color)).into_element(); + let inverted = invert((), Item::new_from_element(color), Item::default()).unwrap().into_element(); let [r, g, b, a] = inverted.to_gamma_srgb_channels(); assert!((r - 0.).abs() < 1e-5 && (g - 0.75).abs() < 1e-5 && (b - 1.).abs() < 1e-5, "inverted channels were {r} {g} {b}"); @@ -2867,7 +3427,7 @@ mod tests { /// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255). fn threshold_is_white(input: [f32; 3], level: f64) -> bool { let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); - let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f64.into()); + let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f64.into(), Item::default()).unwrap(); result.into_element().r() == 1. } @@ -2896,8 +3456,8 @@ mod tests { /// Runs the node on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. fn run_vibrance(input: [f32; 3], vibrance_amount: f64, saturation: f64) -> [f32; 3] { let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); - let result = vibrance((), Item::new_from_element(pixel), vibrance_amount.into(), saturation.into()); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let result = vibrance((), Item::new_from_element(pixel), vibrance_amount.into(), saturation.into(), Item::default()); + let [r, g, b, _] = result.unwrap().into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2951,9 +3511,9 @@ mod tests { g(0, 0), g(0, 1), g(0, 2), g(0, 3), g(1, 0), g(1, 1), g(1, 2), g(1, 3), g(2, 0), g(2, 1), g(2, 2), g(2, 3), g(3, 0), g(3, 1), g(3, 2), g(3, 3), g(4, 0), g(4, 1), g(4, 2), g(4, 3), g(5, 0), g(5, 1), g(5, 2), g(5, 3), g(6, 0), g(6, 1), g(6, 2), g(6, 3), g(7, 0), g(7, 1), g(7, 2), g(7, 3), g(8, 0), g(8, 1), g(8, 2), g(8, 3), - SelectiveColorChoice::Reds.into(), + SelectiveColorChoice::Reds.into(), Item::default(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = result.unwrap().into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2989,7 +3549,11 @@ mod tests { /// Runs Posterize on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_posterize(value: f32, levels: i64) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); - posterize((), Item::new_from_element(pixel), levels.into()).into_element().to_gamma_srgb_channels()[0] * 255. + posterize((), Item::new_from_element(pixel), levels.into(), Item::default()) + .unwrap() + .into_element() + .to_gamma_srgb_channels()[0] + * 255. } #[test] @@ -3012,8 +3576,8 @@ mod tests { /// Runs Exposure on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_exposure(value: f32, exposure: f64, offset: f64, gamma_correction: f64) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); - let result = super::exposure((), Item::new_from_element(pixel), exposure.into(), offset.into(), gamma_correction.into()); - result.into_element().to_gamma_srgb_channels()[0] * 255. + let result = super::exposure((), Item::new_from_element(pixel), exposure.into(), offset.into(), gamma_correction.into(), Item::default()); + result.unwrap().into_element().to_gamma_srgb_channels()[0] * 255. } #[test] @@ -3064,8 +3628,9 @@ mod tests { highlights[2].into(), preserve_luminosity.into(), TonalRange::Midtones.into(), + Item::default(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = result.unwrap().into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -3109,7 +3674,7 @@ mod tests { fn run_photo_filter(input: [f32; 3], filter: [f32; 3], density: f64, preserve_luminosity: bool) -> [f32; 3] { let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); let filter = Color::from_gamma_srgb_channels(filter[0] / 255., filter[1] / 255., filter[2] / 255., 1.); - let result = photo_filter((), Item::new_from_element(pixel), filter.into(), density.into(), preserve_luminosity.into()); + let result = photo_filter((), Item::new_from_element(pixel), filter.into(), density.into(), preserve_luminosity.into(), Item::default()).unwrap(); let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 1be51ba6433..bc6e16d2a9a 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -1,10 +1,12 @@ -use crate::adjust::Adjust; -use core_types::Ctx; +use crate::raster_kernel::{Adjust, MapColor, Wgsl, raster_kernel_pipeline}; +use crate::{gpu_param_enum, gpu_params}; use core_types::blending::BlendMode; use core_types::color::Color; use core_types::list::Item; -use raster_types::{CPU, Raster}; +use core_types::{Ctx, Interrupt}; +use raster_types::{CPU, GPU, Raster}; use vector_types::Gradient; +use wgpu_executor::WgpuPipelineCache; pub use core_types::blending::{apply_blend_mode, blend_colors}; @@ -31,10 +33,11 @@ fn mix_colors( // TODO: Rename to "Paint Overlay" and take a `Graphic` paint like the `Fill` node, enabling this to serve the cases of Color Overlay, Gradient Overlay, and Pattern Overlay. #[node_macro::node(category("Raster: Effect"), icon("NodeColorOverlay"))] -fn color_overlay>( +fn color_overlay( _: impl Ctx, #[implementations( Raster, + Raster, Color, Gradient, )] @@ -46,19 +49,38 @@ fn color_overlay>( #[hard(0..100)] #[default(100.)] opacity: Item, -) -> Item { - let mut image = image; - let color = color.into_element(); - let blend_mode = blend_mode.into_element(); - let opacity = (opacity.into_element() / 100.) as f32; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + let kernel = ColorOverlay { + color: color.into_element(), + blend_mode: blend_mode.into_element(), + opacity: (opacity.into_element() / 100.) as f32, + }; + Ok(image.adjust(&kernel, pipeline.element())?) +} + +gpu_param_enum!(i32: BlendMode); + +gpu_params! { + #[derive(Clone)] + struct ColorOverlay { + color: Color, + blend_mode: BlendMode, + opacity: f32, + } +} - image.element_mut().adjust(|pixel| { - let overlay = apply_blend_mode(color, *pixel, blend_mode); - let mix = |image: f32, overlay: f32| image + (overlay - image) * opacity; +impl MapColor for ColorOverlay { + fn map_cpu(&self, pixel: Color) -> Color { + let overlay = apply_blend_mode(self.color, pixel, self.blend_mode); + let mix = |image: f32, overlay: f32| image + (overlay - image) * self.opacity; Color::from_rgbaf32_unchecked(mix(pixel.r(), overlay.r()), mix(pixel.g(), overlay.g()), mix(pixel.b(), overlay.b()), pixel.a()) - }); - image + } + + fn wgsl() -> Wgsl { + Wgsl::function(format!("{}\n{}", include_str!("wgsl/blending.wgsl"), include_str!("wgsl/color_overlay.wgsl"))) + } } #[cfg(test)] @@ -86,8 +108,9 @@ mod test { overlay_color.into(), BlendMode::Multiply.into(), opacity.into(), + Item::default(), ); - let result = result.into_element(); + let result = result.unwrap().into_element(); // The output should just be the original green and alpha channels (as we multiply them by 1 and other channels by 0) assert_eq!(result.data[0], Color::from_rgbaf32_unchecked(0., image_color.g(), 0., image_color.a())); diff --git a/node-graph/nodes/raster/src/color_lookup_table.rs b/node-graph/nodes/raster/src/color_lookup_table.rs index 06362d32d70..9445c2f05e2 100644 --- a/node-graph/nodes/raster/src/color_lookup_table.rs +++ b/node-graph/nodes/raster/src/color_lookup_table.rs @@ -348,6 +348,55 @@ impl Lut { None => result, } } + + /// The mapping as one grid, which [`Lut::apply`] matches by tetrahedral interpolation of the input normalized over the grid's domain: + /// the 3D table itself when the mapping is nothing more, otherwise the whole mapping sampled at `GRID_SIZE` points per axis across 0 to 1. + pub fn grid(&self) -> LutGrid { + if self.lab_encoding.is_none() + && self.input_curves.is_none() + && self.output_stages.is_empty() + && let Some(LutTable::ThreeDimensional { size, red_fastest, entries }) = &self.table + && size[0] == size[1] + && size[1] == size[2] + { + let size = size[0]; + let entries = if *red_fastest { + entries.clone() + } else { + (0..size.pow(3)) + .map(|index| entries[((index % size) * size + index / size % size) * size + index / (size * size)]) + .collect() + }; + return LutGrid { + domain_min: self.domain_min, + domain_max: self.domain_max, + size, + entries, + }; + } + + let last = (GRID_SIZE - 1) as f32; + let entries = (0..GRID_SIZE.pow(3)) + .map(|index| self.apply([index % GRID_SIZE, index / GRID_SIZE % GRID_SIZE, index / (GRID_SIZE * GRID_SIZE)].map(|step| step as f32 / last))) + .collect(); + LutGrid { + domain_min: [0.; 3], + domain_max: [1.; 3], + size: GRID_SIZE, + entries, + } + } +} + +/// The points per axis of the grid that samples a mapping holding more than one 3D table. +const GRID_SIZE: usize = 33; + +/// A cube of `size` entries per axis, red fastest, then green, then blue, spanning the input range `domain_min..domain_max`. +pub struct LutGrid { + pub domain_min: [f32; 3], + pub domain_max: [f32; 3], + pub size: usize, + pub entries: Vec<[f32; 3]>, } /// Gamma-encoded sRGB to CIELAB against the D50 white of the ICC profile connection space. diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index 23306d5b688..b1fdc637bf4 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -1,11 +1,18 @@ use crate::parallel_rows::fill_rows; +use crate::raster_kernel::{FILTERABLE_FORMAT, RasterKernel, ToGpuParams, Wgsl, raster_kernel_pipeline, run_raster_kernel, run_raster_kernel_with_format}; +use crate::{gpu_param_enum, gpu_params}; use bytemuck::{Pod, Zeroable}; use core_types::color::{Alpha, Color, Pixel, RGB}; use core_types::context::Ctx; use core_types::list::Item; +use core_types::{Interrupt, NodeError}; +use glam::{DAffine2, UVec2}; +use num_enum::IntoPrimitive; use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; -use raster_types::{CPU, Raster}; +use raster_types::{CPU, GPU, Raster}; +use wgpu_executor::WgpuPipelineCache; +use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; /// Working-buffer pixel for the blur algorithms' `gamma` mode: premultiplied sRGB-gamma `f32` channels. /// Only used internally so the working buffer's color space is reflected in the type instead of stuffed into `Color` (which is linear-light by invariant). @@ -85,10 +92,11 @@ fn unpremultiply_gamma_to_linear(buffer: Image) -> Imag /// Blurs the image with a Gaussian or box blur kernel filter. #[node_macro::node(category("Raster: Filter"), icon("NodeBlur"))] -fn blur( +fn blur( _: impl Ctx, /// The image to be blurred. - image_frame: Item>, + #[implementations(Raster, Raster)] + image_frame: Item, /// The radius of the blur kernel. #[unit(" px")] #[range] @@ -99,9 +107,13 @@ fn blur( box_blur: Item, /// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software. gamma: Item, -) -> Item> { + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let (radius, box_blur, gamma) = (*radius.element(), *box_blur.element(), *gamma.element()); + Ok(T::blur(image_frame, radius, box_blur, gamma, pipeline.element())?) +} +fn blur_cpu(image_frame: Item>, radius: f64, box_blur: bool, gamma: bool) -> Item> { let (image, attributes) = image_frame.into_parts(); // TODO: Scale the radius through the footprint so it acts as the document-space length its Pixels unit promises, with a @@ -120,17 +132,19 @@ fn blur( /// Applies a median filter to reduce noise while preserving edges. #[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize)] -fn median_filter( +fn median_filter( _: impl Ctx, /// The image to be filtered. - image_frame: Item>, + #[implementations(Raster, Raster)] + image_frame: Item, /// The radius of the filter kernel. Larger values remove more noise but may blur fine details. #[unit(" px")] #[range] #[hard(0..)] #[soft(..50)] radius: Item, -) -> Item> { + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { let radius = *radius.element(); let (image, attributes) = image_frame.into_parts(); @@ -141,10 +155,49 @@ fn median_filter( // Minimum filter radius image } else { - Raster::new_cpu(median_filter_algorithm(image.into_data(), radius as u32)) + T::median_filter(image, radius as u32, pipeline.element())? }; - Item::from_parts(filtered_image, attributes) + Ok(Item::from_parts(filtered_image, attributes)) +} + +/// A raster the Median Filter node processes, on the CPU or the GPU. +pub trait MedianFilterRaster: Sized { + fn median_filter(image: Self, radius: u32, pipeline: &WgpuPipelineCache) -> Result; +} + +impl MedianFilterRaster for Raster { + fn median_filter(image: Self, radius: u32, _: &WgpuPipelineCache) -> Result { + Ok(Raster::new_cpu(median_filter_algorithm(image.into_data(), radius))) + } +} + +impl MedianFilterRaster for Raster { + fn median_filter(image: Self, radius: u32, pipeline: &WgpuPipelineCache) -> Result { + let size = UVec2::new(image.texture.width(), image.texture.height()); + let kernel = MedianWindow { + radius: radius.min(size.max_element()), + }; + let input = RasterKernelInput { + raster: &image, + output_to_texel: DAffine2::IDENTITY, + }; + run_raster_kernel(&kernel, vec![input], size, pipeline) + } +} + +gpu_params! { + #[derive(Clone)] + struct MedianWindow { + radius: u32, + } +} + +impl RasterKernel for MedianWindow { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/median_filter.wgsl")) + } } // 1D gaussian kernel @@ -296,6 +349,183 @@ where y_axis } +/// A raster the Blur node processes, on the CPU or the GPU. +pub trait BlurRaster: Sized { + fn blur(image: Item, radius: f64, box_blur: bool, gamma: bool, pipeline: &WgpuPipelineCache) -> Result, NodeError>; +} + +impl BlurRaster for Raster { + fn blur(image: Item, radius: f64, box_blur: bool, gamma: bool, _: &WgpuPipelineCache) -> Result, NodeError> { + Ok(blur_cpu(image, radius, box_blur, gamma)) + } +} + +/// The farthest texel from the center a blur pass weighs, beyond which the image is first halved until the kernel fits. +const MAX_BLUR_REACH: i32 = 64; + +/// The radius of a blur over texels `scale` image pixels wide, and the farthest texel from the center it weighs, as the CPU kernels size them at a `scale` of 1. +/// +/// A box covers its whole texels and a fraction of the texel at each end, which keeps the window as wide as the CPU's whole pixel window once halved, +/// and a Gaussian leaves out the blur that halving and doubling the image add. +fn blur_extent(radius: f64, box_blur: bool, scale: u32) -> (f32, i32) { + let scale = scale as f64; + if box_blur { + let radius = ((radius as i32) as f64 + 0.5) / scale - 0.5; + (radius as f32, radius.ceil() as i32) + } else { + let radius = (radius * radius - (scale * scale - 1.) / 3.).max(0.).sqrt() / scale; + (radius as f32, (3. * radius).ceil() as i32) + } +} + +fn blur_input(raster: &Raster) -> Vec> { + vec![RasterKernelInput { + raster, + output_to_texel: DAffine2::IDENTITY, + }] +} + +impl BlurRaster for Raster { + /// Each pass sums premultiplied colors into a [`FILTERABLE_FORMAT`] intermediate, and a kernel reaching past [`MAX_BLUR_REACH`] runs on the image halved + /// until it fits, weighing each halved texel by how much of the image it covers, then is scaled back up bilinearly, approximating the CPU's blur. + fn blur(image: Item, radius: f64, box_blur: bool, gamma: bool, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + if radius < 0.1 { + return Ok(image); + } + let (raster, attributes) = image.into_parts(); + let space = if gamma { BlurSpace::Gamma } else { BlurSpace::Linear }; + let size = UVec2::new(raster.texture.width(), raster.texture.height()); + let full_size = size.as_ivec2().to_array(); + + let mut levels = 0; + while blur_extent(radius, box_blur, 1 << levels).1 > MAX_BLUR_REACH { + levels += 1; + } + let level_size = |level: u32| size.map(|side| side.div_ceil(1 << level)); + + let mut source = raster; + for level in 1..=levels { + let kernel = BlurHalve { + space: if level == 1 { space } else { BlurSpace::Premultiplied }, + scale: 1 << (level - 1), + full_size, + }; + source = run_raster_kernel_with_format(&kernel, blur_input(&source), level_size(level), FILTERABLE_FORMAT, pipeline)?; + } + + let (radius, reach) = blur_extent(radius, box_blur, 1 << levels); + let pass = BlurPass { + radius, + reach, + box_blur, + space: if levels == 0 { space } else { BlurSpace::Premultiplied }, + scale: 1 << levels, + full_size, + }; + let horizontal = run_raster_kernel_with_format(&BlurHorizontal(pass.clone()), blur_input(&source), level_size(levels), FILTERABLE_FORMAT, pipeline)?; + if levels == 0 { + let blurred = run_raster_kernel(&BlurVertical(pass), blur_input(&horizontal), size, pipeline)?; + return Ok(Item::from_parts(blurred, attributes)); + } + let vertical = run_raster_kernel_with_format(&BlurVertical(pass), blur_input(&horizontal), level_size(levels), FILTERABLE_FORMAT, pipeline)?; + + let upsample = BlurUpsample { scale: (1 << levels) as f32, space }; + let blurred = run_raster_kernel(&upsample, blur_input(&vertical), size, pipeline)?; + Ok(Item::from_parts(blurred, attributes)) + } +} + +/// The colors a blur pass reads from its input or writes to its output, around the premultiplied colors it sums. +#[derive(Clone, Copy, IntoPrimitive)] +#[repr(u32)] +enum BlurSpace { + Premultiplied, + Linear, + Gamma, +} + +gpu_param_enum!(u32: BlurSpace); + +gpu_params! { + /// Averages each 2x2 block of texels `scale` image pixels wide, weighed by how much of the `full_size` image each covers. + #[derive(Clone)] + struct BlurHalve { + space: BlurSpace, + scale: i32, + full_size: [i32; 2], + } +} + +impl RasterKernel for BlurHalve { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/blur.wgsl"), include_str!("wgsl/blur_halve.wgsl"))) + } +} + +gpu_params! { + /// One axis of a blur over texels `scale` image pixels wide, each weighed by how much of the `full_size` image it covers. + #[derive(Clone)] + struct BlurPass { + radius: f32, + reach: i32, + box_blur: bool, + space: BlurSpace, + scale: i32, + full_size: [i32; 2], + } +} + +struct BlurHorizontal(BlurPass); + +impl ToGpuParams for BlurHorizontal { + type Params = BlurPass; + fn to_params(&self) -> BlurPass { + self.0.clone() + } +} + +impl RasterKernel for BlurHorizontal { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/blur.wgsl"), include_str!("wgsl/blur_horizontal.wgsl"))) + } +} + +/// Reads its input in pairs of texels sampled bilinearly between them, so the input must be in [`FILTERABLE_FORMAT`]. +struct BlurVertical(BlurPass); + +impl ToGpuParams for BlurVertical { + type Params = BlurPass; + fn to_params(&self) -> BlurPass { + self.0.clone() + } +} + +impl RasterKernel for BlurVertical { + const INPUTS: usize = 1; + const FILTERED: bool = true; + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/blur.wgsl"), include_str!("wgsl/blur_vertical.wgsl"))) + } +} + +gpu_params! { + #[derive(Clone)] + struct BlurUpsample { + scale: f32, + space: BlurSpace, + } +} + +impl RasterKernel for BlurUpsample { + const INPUTS: usize = 1; + const FILTERED: bool = true; + fn wgsl() -> Wgsl { + Wgsl::function(concat!(include_str!("wgsl/blur.wgsl"), include_str!("wgsl/blur_upsample.wgsl"))) + } +} + fn median_filter_algorithm(original_buffer: Image, radius: u32) -> Image { let (width, height) = original_buffer.dimensions(); let mut output = Image::new(width, height, Color::TRANSPARENT); diff --git a/node-graph/nodes/raster/src/lib.rs b/node-graph/nodes/raster/src/lib.rs index c6eadef89d4..25c476ad407 100644 --- a/node-graph/nodes/raster/src/lib.rs +++ b/node-graph/nodes/raster/src/lib.rs @@ -1,4 +1,3 @@ -pub mod adjust; pub mod adjustments; pub mod blending_nodes; pub mod color_lookup_table; @@ -6,4 +5,5 @@ pub mod dehaze; pub mod filter; pub mod image_color_palette; pub mod parallel_rows; +pub mod raster_kernel; pub mod std_nodes; diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs new file mode 100644 index 00000000000..d9ce7897e74 --- /dev/null +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -0,0 +1,566 @@ +use core_types::ATTR_TRANSFORM; +use core_types::color::Color; +use core_types::list::Item; +use core_types::{Ctx, NodeError}; +use glam::{DAffine2, DMat2, DVec2, UVec2}; +use raster_types::{CPU, GPU, Raster}; +use std::any::{TypeId, type_name}; +use vector_types::Gradient; +pub use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelLookup; +use wgpu_executor::shader_runtime::raster_kernel_runtime::{RasterKernelArgs, RasterKernelInput, RasterKernelRuntime}; +use wgpu_executor::{RASTER_FORMAT, WgpuExecutor, WgpuPipelineCache, WgpuTextureFormat}; + +/// The WGSL of a [`MapColor`] or [`RasterKernel`], which sees the shared prelude and the `Params` struct with its `params()` accessor. +pub enum Wgsl { + /// The body of the kernel's function, with every parameter bound as a local. + Body(String), + /// A source defining the kernel's function and any helpers it calls. + Function(String), +} + +impl Wgsl { + pub fn body(body: impl Into) -> Self { + Self::Body(body.into()) + } + + pub fn function(source: impl Into) -> Self { + Self::Function(source.into()) + } +} + +/// A parameter struct passed to a [`MapColor`] or [`RasterKernel`] shader, declared with [`gpu_params!`]. +pub trait GpuParams { + fn write(&self, slots: &mut Vec); + /// The WGSL declarations of the struct and its nested structs, aliased as `Params`, and the `params()` accessor, or nothing for a struct without fields. + fn wgsl() -> String; + /// The WGSL statements binding every field of `params()` as a local. + fn wgsl_locals() -> String; + /// The WGSL declaring the texture and accessors of each lookup field, bound in order from `binding`. + fn wgsl_lookups(_binding: usize) -> String { + String::new() + } + /// The tables of the lookup fields, in order. + fn lookups(&self) -> Vec> { + Vec::new() + } +} + +/// A field type of a [`gpu_params!`] struct, occupying `SLOTS` 32-bit uniform slots. +pub trait GpuParamField { + const SLOTS: usize; + /// Whether the type is a lookup table, bound as its own texture rather than packed into slots, which only a top-level field may be. + const LOOKUP: bool = false; + fn wgsl_type() -> String; + fn write(&self, slots: &mut Vec); + fn wgsl_read(slot: usize) -> String; + /// Appends the WGSL struct declarations this type needs, each only once. + fn wgsl_declarations(_declarations: &mut Vec) {} + /// The WGSL declaring a lookup table's texture at `binding` and its accessors named after `field`. + fn wgsl_lookup(_field: &str, _binding: usize) -> String { + String::new() + } + fn lookup<'a>(&'a self, _lookups: &mut Vec>) {} +} + +fn wgsl_slot(slot: usize) -> String { + format!("uniform_params[{}][{}]", slot / 4, slot % 4) +} + +impl GpuParamField for f32 { + const SLOTS: usize = 1; + fn wgsl_type() -> String { + "f32".into() + } + fn write(&self, slots: &mut Vec) { + slots.push(self.to_bits()); + } + fn wgsl_read(slot: usize) -> String { + format!("bitcast({})", wgsl_slot(slot)) + } +} + +impl GpuParamField for u32 { + const SLOTS: usize = 1; + fn wgsl_type() -> String { + "u32".into() + } + fn write(&self, slots: &mut Vec) { + slots.push(*self); + } + fn wgsl_read(slot: usize) -> String { + wgsl_slot(slot) + } +} + +impl GpuParamField for i32 { + const SLOTS: usize = 1; + fn wgsl_type() -> String { + "i32".into() + } + fn write(&self, slots: &mut Vec) { + slots.push(*self as u32); + } + fn wgsl_read(slot: usize) -> String { + format!("bitcast({})", wgsl_slot(slot)) + } +} + +impl GpuParamField for bool { + const SLOTS: usize = 1; + fn wgsl_type() -> String { + "bool".into() + } + fn write(&self, slots: &mut Vec) { + slots.push(*self as u32); + } + fn wgsl_read(slot: usize) -> String { + format!("{} != 0u", wgsl_slot(slot)) + } +} + +impl GpuParamField for Color { + const SLOTS: usize = 4; + fn wgsl_type() -> String { + "vec4".into() + } + fn write(&self, slots: &mut Vec) { + slots.extend(self.to_vec4().to_array().map(f32::to_bits)); + } + fn wgsl_read(slot: usize) -> String { + let channels = (slot..slot + 4).map(::wgsl_read).collect::>(); + format!("vec4({})", channels.join(", ")) + } +} + +impl GpuParamField for [T; N] { + const SLOTS: usize = N * T::SLOTS; + fn wgsl_type() -> String { + format!("array<{}, {N}>", T::wgsl_type()) + } + fn write(&self, slots: &mut Vec) { + self.iter().for_each(|element| element.write(slots)); + } + fn wgsl_read(slot: usize) -> String { + let elements = (0..N).map(|index| T::wgsl_read(slot + index * T::SLOTS)).collect::>(); + format!("{}({})", Self::wgsl_type(), elements.join(", ")) + } + fn wgsl_declarations(declarations: &mut Vec) { + T::wgsl_declarations(declarations); + } +} + +/// A lookup field of entries spread evenly across 0 to 1, read in WGSL by `fn (t: f32) -> vec4`, which clamps `t` and interpolates linearly between the nearest two entries. +/// +/// Holds 1 to 4096 entries, the texture width every device supports. +#[derive(Debug, Clone, PartialEq)] +pub struct Lut1d(pub Vec); + +impl GpuParamField for Lut1d { + const SLOTS: usize = 0; + const LOOKUP: bool = true; + fn wgsl_type() -> String { + String::new() + } + fn write(&self, _: &mut Vec) {} + fn wgsl_read(_: usize) -> String { + String::new() + } + fn wgsl_lookup(field: &str, binding: usize) -> String { + include_str!("wgsl/lut_1d.wgsl").replace("LUT", field).replace("BINDING", &binding.to_string()) + } + fn lookup<'a>(&'a self, lookups: &mut Vec>) { + assert!((1..=4096).contains(&self.0.len()), "A 1D lookup table holds 1 to 4096 entries"); + lookups.push(RasterKernelLookup::Lut1d(&self.0)); + } +} + +/// A lookup field of a `size` cubed grid of entries, red fastest, then green, then blue, read in WGSL by `fn _load(index: vec3) -> vec4`, +/// which clamps the index to the grid, and `fn _size() -> i32`. +#[derive(Debug, Clone, PartialEq)] +pub struct Lut3d { + pub size: u32, + pub data: Vec, +} + +impl GpuParamField for Lut3d { + const SLOTS: usize = 0; + const LOOKUP: bool = true; + fn wgsl_type() -> String { + String::new() + } + fn write(&self, _: &mut Vec) {} + fn wgsl_read(_: usize) -> String { + String::new() + } + fn wgsl_lookup(field: &str, binding: usize) -> String { + include_str!("wgsl/lut_3d.wgsl").replace("LUT", field).replace("BINDING", &binding.to_string()) + } + fn lookup<'a>(&'a self, lookups: &mut Vec>) { + assert_eq!(self.data.len(), self.size.pow(3) as usize, "A 3D lookup table holds `size` cubed entries"); + lookups.push(RasterKernelLookup::Lut3d { size: self.size, data: &self.data }); + } +} + +/// Declares a struct usable as [`GpuParams`] and as a nested [`GpuParamField`], generating its packing and WGSL unpacking. +/// +/// The WGSL struct takes the Rust struct's name, so it must not collide with other WGSL names in the kernel. +/// Lookup fields such as [`Lut1d`] and [`Lut3d`] stay out of the WGSL struct, read instead through accessors named after the field. +#[macro_export] +macro_rules! gpu_params { + ($(#[$attribute:meta])* $vis:vis struct $name:ident { $($(#[$field_attribute:meta])* $field_vis:vis $field:ident: $ty:ty),* $(,)? }) => { + $(#[$attribute])* + $vis struct $name { + $($(#[$field_attribute])* $field_vis $field: $ty),* + } + + impl $crate::raster_kernel::GpuParamField for $name { + const SLOTS: usize = 0 $(+ <$ty as $crate::raster_kernel::GpuParamField>::SLOTS)*; + fn wgsl_type() -> String { + stringify!($name).into() + } + + #[allow(unused_variables)] + fn write(&self, slots: &mut Vec) { + $(<$ty as $crate::raster_kernel::GpuParamField>::write(&self.$field, slots);)* + } + + #[allow(unused_mut, unused_variables, unused_assignments)] + fn wgsl_read(slot: usize) -> String { + let mut slot = slot; + let reads: Vec<(bool, String)> = vec![$({ + let read = <$ty as $crate::raster_kernel::GpuParamField>::wgsl_read(slot); + slot += <$ty as $crate::raster_kernel::GpuParamField>::SLOTS; + (<$ty as $crate::raster_kernel::GpuParamField>::LOOKUP, read) + }),*]; + let reads: Vec = reads.into_iter().filter(|(lookup, _)| !lookup).map(|(_, read)| read).collect(); + format!("{}({})", stringify!($name), reads.join(", ")) + } + + #[allow(unused_variables)] + fn wgsl_declarations(declarations: &mut Vec) { + $(<$ty as $crate::raster_kernel::GpuParamField>::wgsl_declarations(declarations);)* + let fields: Vec<(bool, String)> = vec![$(( + <$ty as $crate::raster_kernel::GpuParamField>::LOOKUP, + format!("{}: {}", stringify!($field), <$ty as $crate::raster_kernel::GpuParamField>::wgsl_type()) + )),*]; + let fields: Vec = fields.into_iter().filter(|(lookup, _)| !lookup).map(|(_, field)| field).collect(); + if fields.is_empty() { + return; + } + let declaration = format!("struct {} {{\n\t{},\n}}\n", stringify!($name), fields.join(",\n\t")); + if !declarations.contains(&declaration) { + declarations.push(declaration); + } + } + } + + impl $crate::raster_kernel::GpuParams for $name { + fn write(&self, slots: &mut Vec) { + ::write(self, slots); + } + + fn wgsl() -> String { + let mut declarations = Vec::new(); + ::wgsl_declarations(&mut declarations); + if declarations.is_empty() { + return String::new(); + } + format!( + "{}\nalias Params = {};\n\nfn params() -> Params {{\n\treturn {};\n}}\n", + declarations.join("\n"), + stringify!($name), + ::wgsl_read(0) + ) + } + + fn wgsl_locals() -> String { + let fields: &[(bool, &str)] = &[$((<$ty as $crate::raster_kernel::GpuParamField>::LOOKUP, stringify!($field))),*]; + let fields: Vec<&str> = fields.iter().filter(|(lookup, _)| !lookup).map(|(_, field)| *field).collect(); + if fields.is_empty() { + return String::new(); + } + let locals = fields.iter().map(|field| format!("\tlet {field} = parameters.{field};\n")).collect::(); + format!("\tlet parameters = params();\n{locals}") + } + + #[allow(unused_mut, unused_variables, unused_assignments)] + fn wgsl_lookups(binding: usize) -> String { + let mut binding = binding; + let mut source = String::new(); + $(if <$ty as $crate::raster_kernel::GpuParamField>::LOOKUP { + source += &<$ty as $crate::raster_kernel::GpuParamField>::wgsl_lookup(stringify!($field), binding); + binding += 1; + })* + source + } + + #[allow(unused_mut)] + fn lookups(&self) -> Vec<$crate::raster_kernel::RasterKernelLookup<'_>> { + let mut lookups = Vec::new(); + $(<$ty as $crate::raster_kernel::GpuParamField>::lookup(&self.$field, &mut lookups);)* + lookups + } + } + }; +} + +/// Implements [`GpuParamField`] for enums converting into the `u32` or `i32` primitive `$repr`, such as through `IntoPrimitive`. +#[macro_export] +macro_rules! gpu_param_enum { + ($repr:ident: $($ty:ty),* $(,)?) => { + $( + impl $crate::raster_kernel::GpuParamField for $ty { + const SLOTS: usize = 1; + fn wgsl_type() -> String { + <$repr as $crate::raster_kernel::GpuParamField>::wgsl_type() + } + + fn write(&self, slots: &mut Vec) { + <$repr as $crate::raster_kernel::GpuParamField>::write(&$repr::from(*self), slots); + } + + fn wgsl_read(slot: usize) -> String { + <$repr as $crate::raster_kernel::GpuParamField>::wgsl_read(slot) + } + } + )* + }; +} + +/// The [`GpuParams`] a kernel passes to its shader, which is the kernel itself unless it implements this to flatten richer data. +pub trait ToGpuParams { + type Params: GpuParams; + fn to_params(&self) -> Self::Params; +} + +impl ToGpuParams for T { + type Params = T; + fn to_params(&self) -> T { + self.clone() + } +} + +/// A per-pixel color mapping, run by `map_cpu` on the CPU and by its WGSL on the GPU, which must agree. +/// +/// Its WGSL function is `fn map_color(color: vec4) -> vec4`. +pub trait MapColor: ToGpuParams + 'static { + fn map_cpu(&self, color: Color) -> Color; + fn wgsl() -> Wgsl; +} + +/// A GPU-only kernel computing each output pixel from `INPUTS` input rasters, which a type implements instead of [`MapColor`], not alongside it. +/// +/// Its WGSL function is `fn kernel(coord: vec2) -> vec4`, which may call `output_size()` and, for each input `i`, +/// `input_i_size()`, `input_i_load(coord)` reading the input's own texel clamped to its edge, +/// and `input_i_at(coord)` sampling the input bilinearly where the output pixel's center lies, transparent beyond the input. +pub trait RasterKernel: ToGpuParams + 'static { + const INPUTS: usize; + /// Whether the inputs are bound filterable, adding `input_i_sample(texel)` sampling the input bilinearly at texel coordinates, whose texel centers lie at +0.5, clamped to its edge. + /// Every input must then be in a filterable format such as [`FILTERABLE_FORMAT`], which [`RASTER_FORMAT`] isn't. + const FILTERED: bool = false; + fn wgsl() -> Wgsl; +} + +/// The format of intermediate rasters a [`RasterKernel::FILTERED`] kernel reads, half-float precision but filterable on WebGL2 unlike [`RASTER_FORMAT`]. +pub const FILTERABLE_FORMAT: WgpuTextureFormat = WgpuTextureFormat::Rgba16Float; + +fn shader_module(signature: &str, wgsl: Wgsl, inputs: usize) -> String { + let function = match wgsl { + Wgsl::Body(body) => format!("{signature} {{\n{}{body}\n}}", Params::wgsl_locals()), + Wgsl::Function(source) => source, + }; + format!("{}\n{}\n{}\n{function}", include_str!("wgsl/prelude.wgsl"), Params::wgsl(), Params::wgsl_lookups(2 + inputs)) +} + +fn map_color_module() -> String { + let module = shader_module::("fn map_color(color: vec4) -> vec4", K::wgsl(), 1); + format!("{module}\nfn kernel(coord: vec2) -> vec4 {{\n\treturn map_color(input_0_load(coord));\n}}\n") +} + +fn raster_kernel_module() -> String { + shader_module::("fn kernel(coord: vec2) -> vec4", K::wgsl(), K::INPUTS) +} + +fn run( + kernel: &K, + wgsl: fn() -> String, + filtered: bool, + inputs: Vec>, + output_size: UVec2, + output_format: WgpuTextureFormat, + pipeline: &WgpuPipelineCache, +) -> Result, NodeError> { + let gpu_params = kernel.to_params(); + let mut params = Vec::new(); + gpu_params.write(&mut params); + let args = RasterKernelArgs { + kernel: TypeId::of::(), + label: type_name::(), + wgsl, + params, + inputs, + lookups: gpu_params.lookups(), + filtered, + output_size, + output_format, + }; + pipeline + .try_run::(&args) + .ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster")) +} + +/// Runs `kernel` on its inputs, each placed by the affine from output pixel coordinates to its texel coordinates, into a raster of `output_size`. +pub fn run_raster_kernel(kernel: &K, inputs: Vec>, output_size: UVec2, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + run_raster_kernel_with_format(kernel, inputs, output_size, RASTER_FORMAT, pipeline) +} + +/// Runs `kernel` like [`run_raster_kernel`] into a texture of `output_format` instead of [`RASTER_FORMAT`], such as a [`FILTERABLE_FORMAT`] intermediate a [`RasterKernel::FILTERED`] kernel can read. +pub fn run_raster_kernel_with_format( + kernel: &K, + inputs: Vec>, + output_size: UVec2, + output_format: WgpuTextureFormat, + pipeline: &WgpuPipelineCache, +) -> Result, NodeError> { + assert_eq!(inputs.len(), K::INPUTS, "{} takes {} inputs", type_name::(), K::INPUTS); + run(kernel, raster_kernel_module::, K::FILTERED, inputs, output_size, output_format, pipeline) +} + +pub trait Adjust: Sized { + fn adjust(self, kernel: &K, pipeline: &WgpuPipelineCache) -> Result; +} + +impl Adjust for Color { + fn adjust(self, kernel: &K, _: &WgpuPipelineCache) -> Result { + Ok(kernel.map_cpu(self)) + } +} + +impl Adjust for Gradient { + fn adjust(self, kernel: &K, _: &WgpuPipelineCache) -> Result { + Ok(self.map_colors(|color| kernel.map_cpu(*color))) + } +} + +impl Adjust for Raster { + fn adjust(mut self, kernel: &K, _: &WgpuPipelineCache) -> Result { + for color in self.data_mut().data.iter_mut() { + *color = kernel.map_cpu(*color); + } + Ok(self) + } +} + +impl Adjust for Raster { + fn adjust(self, kernel: &K, pipeline: &WgpuPipelineCache) -> Result { + let size = UVec2::new(self.texture.width(), self.texture.height()); + let input = RasterKernelInput { + raster: &self, + output_to_texel: DAffine2::IDENTITY, + }; + run(kernel, map_color_module::, false, vec![input], size, RASTER_FORMAT, pipeline) + } +} + +impl Adjust for Item { + fn adjust(self, kernel: &K, pipeline: &WgpuPipelineCache) -> Result { + let (element, attributes) = self.into_parts(); + Ok(Item::from_parts(element.adjust(kernel, pipeline)?, attributes)) + } +} + +#[node_macro::node(category(""), inject_scope)] +fn raster_kernel_pipeline( + _: impl Ctx, + #[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::TryWgpuExecutorNode"))] executor: Item>, + #[data] pipeline: WgpuPipelineCache, +) -> Item { + if let Some(executor) = executor.into_element() { + executor.pipeline_init::(pipeline); + } + Item::new_from_element(pipeline.clone()) +} + +/// The most pixels along each side of a [`union_frame`], which coarsens its grid to fit, within the texture size most WebGL2 devices support. +pub const MAX_FRAME_SIDE: u32 = 8192; + +/// The output raster of a kernel over placed inputs. +pub struct KernelFrame { + pub size: UVec2, + /// The output item's transform, mapping the unit square onto the frame in document space. + pub transform: DAffine2, + /// For each input, the affine from output pixel coordinates to its texel coordinates. + pub output_to_texel: Vec, +} + +impl KernelFrame { + /// The frame of `size` pixels placed by `transform`, over `inputs` given as each raster's size and its invertible transform. + pub fn new(size: UVec2, transform: DAffine2, inputs: &[(UVec2, DAffine2)]) -> Self { + let output_to_document = transform * DAffine2::from_scale(1. / size.as_dvec2()); + let output_to_texel = inputs + .iter() + .map(|(input_size, input_transform)| DAffine2::from_scale(input_size.as_dvec2()) * input_transform.inverse() * output_to_document) + .collect(); + Self { size, transform, output_to_texel } + } + + /// The kernel inputs placing `rasters`, in the order of the frame's inputs. + pub fn kernel_inputs<'a>(&self, rasters: &[&'a Raster]) -> Vec> { + rasters + .iter() + .zip(&self.output_to_texel) + .map(|(raster, output_to_texel)| RasterKernelInput { + raster, + output_to_texel: *output_to_texel, + }) + .collect() + } +} + +/// A raster item's size in pixels and its `ATTR_TRANSFORM`, as [`union_frame`] and [`KernelFrame::new`] take each input. +pub fn raster_placement(raster: &Item>) -> (UVec2, DAffine2) { + let size = UVec2::new(raster.element().texture.width(), raster.element().texture.height()); + (size, raster.attribute_cloned_or_default(ATTR_TRANSFORM)) +} + +/// The frame covering the union of `inputs`, each a raster's size and transform, on the finest of their pixel grids. +/// +/// The grid's axes follow the first input's, with the smallest pixel step along each that any input has, anchored at the first input's origin. +/// Each side is capped at [`MAX_FRAME_SIDE`] pixels. `None` without inputs or with an empty or non-invertible one. +pub fn union_frame(inputs: &[(UVec2, DAffine2)]) -> Option { + let placeable = |(size, transform): &(UVec2, DAffine2)| size.x > 0 && size.y > 0 && transform.matrix2.determinant() != 0.; + let first = inputs.first()?.1; + if !inputs.iter().all(placeable) { + return None; + } + + let axes = DMat2::from_cols(first.matrix2.x_axis.normalize(), first.matrix2.y_axis.normalize()); + let to_axes = axes.inverse(); + + let corners = inputs + .iter() + .flat_map(|(_, transform)| [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|corner| to_axes * transform.transform_point2(corner))); + let (min, max) = corners.fold((DVec2::INFINITY, DVec2::NEG_INFINITY), |(min, max), corner| (min.min(corner), max.max(corner))); + + let step = inputs + .iter() + .map(|(size, transform)| { + let to_texels = DMat2::from_diagonal(size.as_dvec2()) * transform.matrix2.inverse() * axes; + 1. / DVec2::new(to_texels.x_axis.length(), to_texels.y_axis.length()) + }) + .fold(DVec2::INFINITY, DVec2::min); + + let anchor = to_axes * first.translation; + let start = anchor + ((min - anchor) / step + 1e-6).floor() * step; + let end = anchor + ((max - anchor) / step - 1e-6).ceil() * step; + let cells = ((end - start) / step).round().max(DVec2::ONE); + + let max_side = DVec2::splat(MAX_FRAME_SIDE as f64); + let oversized = cells.cmpgt(max_side); + let start = DVec2::select(oversized, min, start); + let step = DVec2::select(oversized, (max - min) / max_side, step); + let cells = cells.min(max_side); + + let transform = DAffine2::from_mat2_translation(axes * DMat2::from_diagonal(cells * step), axes * start); + Some(KernelFrame::new(cells.as_uvec2(), transform, inputs)) +} diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index d0dca74b6a7..2dccc5a67cb 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -1,5 +1,7 @@ use crate::adjustments::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, NoiseType}; +use crate::gpu_params; use crate::parallel_rows::fill_rows; +use crate::raster_kernel::{KernelFrame, RasterKernel, Wgsl, raster_kernel_pipeline, raster_placement, run_raster_kernel, union_frame}; use core_types::ATTR_TRANSFORM; use core_types::color::Color; use core_types::color::{Alpha, AlphaMut, Channel, LinearChannel, Luminance, RGBMut}; @@ -7,6 +9,7 @@ use core_types::context::{Ctx, ExtractFootprint}; use core_types::list::Item; use core_types::math::bbox::Bbox; use core_types::transform::Transform; +use core_types::{Interrupt, NodeError}; use dyn_any::DynAny; use fastnoise_lite; use glam::{DAffine2, DVec2, UVec2, Vec2}; @@ -14,10 +17,11 @@ use graphene_resource::Resource; use rand::prelude::*; use rand_chacha::ChaCha8Rng; use raster_types::{Bitmap, BitmapMut}; -use raster_types::{CPU, Raster}; +use raster_types::{CPU, GPU, Raster}; use raster_types::{Image, ImageError}; use std::fmt::Debug; use std::hash::Hash; +use wgpu_executor::WgpuPipelineCache; #[derive(Debug, DynAny)] pub enum Error { @@ -42,14 +46,27 @@ pub struct SizeMismatchError { } #[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize)] -fn channels_to_image( +fn channels_to_image( _: impl Ctx, _primary: (), - #[expose] red: Item>, - #[expose] green: Item>, - #[expose] blue: Item>, - #[expose] alpha: Item>, -) -> Result>, SizeMismatchError> { + #[expose] + #[implementations(Raster, Raster)] + red: Item, + #[expose] + #[implementations(Raster, Raster)] + green: Item, + #[expose] + #[implementations(Raster, Raster)] + blue: Item, + #[expose] + #[implementations(Raster, Raster)] + alpha: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + Ok(T::channels_to_image([red, green, blue, alpha], pipeline.element())?) +} + +fn channels_to_image_cpu(red: Item>, green: Item>, blue: Item>, alpha: Item>) -> Result>, SizeMismatchError> { // An unconnected channel arrives as the default zero-sized raster, which counts as absent let present = |channel: Item>| (channel.element().width > 0 && channel.element().height > 0).then_some(channel); let (red, green, blue, alpha) = (present(red), present(green), present(blue), present(alpha)); @@ -104,14 +121,21 @@ fn channels_to_image( } #[node_macro::node(category("Raster"), icon("NodeMask"), memoize)] -fn mask( +fn mask( _: impl Ctx, /// The image to be masked. - image: Item>, + #[implementations(Raster, Raster)] + image: Item, /// The stencil to be used for masking. #[expose] - stencil: Item>, -) -> Item> { + #[implementations(Raster, Raster)] + stencil: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, Interrupt> { + Ok(T::mask(image, stencil, pipeline.element())?) +} + +fn mask_cpu(image: Item>, stencil: Item>) -> Item> { // An absent stencil arrives as the default empty raster, leaving the image unmasked if stencil.element().width == 0 || stencil.element().height == 0 { return image; @@ -150,9 +174,16 @@ fn mask( } #[node_macro::node(category(""))] -fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Result>, (Item>, ImageError)> { - let bounds = *bounds.element(); +fn extend_image_to_bounds( + _: impl Ctx, + #[implementations(Raster, Raster)] image: Item, + bounds: Item, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, (Item, NodeError)> { + T::extend_image_to_bounds(image, *bounds.element(), pipeline.element()) +} +fn extend_image_to_bounds_cpu(image: Item>, bounds: DAffine2) -> Result>, (Item>, ImageError)> { let image_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); @@ -209,6 +240,170 @@ fn empty_image(transform: DAffine2, color: Color) -> Result>, I Ok(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)) } +/// A raster the Channels to Image, Mask, and Extend Image to Bounds nodes process, on the CPU or the GPU. +pub trait PlacedRaster: Sized { + fn channels_to_image(channels: [Item; 4], pipeline: &WgpuPipelineCache) -> Result, NodeError>; + fn mask(image: Item, stencil: Item, pipeline: &WgpuPipelineCache) -> Result, NodeError>; + fn extend_image_to_bounds(image: Item, bounds: DAffine2, pipeline: &WgpuPipelineCache) -> Result, (Item, NodeError)>; +} + +impl PlacedRaster for Raster { + fn channels_to_image([red, green, blue, alpha]: [Item; 4], _: &WgpuPipelineCache) -> Result, NodeError> { + channels_to_image_cpu(red, green, blue, alpha).map_err(NodeError::new) + } + + fn mask(image: Item, stencil: Item, _: &WgpuPipelineCache) -> Result, NodeError> { + Ok(mask_cpu(image, stencil)) + } + + fn extend_image_to_bounds(image: Item, bounds: DAffine2, _: &WgpuPipelineCache) -> Result, (Item, NodeError)> { + extend_image_to_bounds_cpu(image, bounds).map_err(|(image, error)| (image, NodeError::new(error))) + } +} + +impl PlacedRaster for Raster { + /// Each channel is placed by its transform into their [`union_frame`], which takes the red channel's other attributes, and gives its luminance weighted by its alpha. + fn channels_to_image(channels: [Item; 4], pipeline: &WgpuPipelineCache) -> Result, NodeError> { + let frame = union_frame(&channels.each_ref().map(raster_placement)).ok_or_else(|| NodeError::new("Every channel must be placed by an invertible transform"))?; + let raster = run_raster_kernel(&CombineChannels {}, frame.kernel_inputs(&channels.each_ref().map(Item::element)), frame.size, pipeline)?; + + let [red, ..] = channels; + let (_, mut attributes) = red.into_parts(); + attributes.insert(ATTR_TRANSFORM, frame.transform); + Ok(Item::from_parts(raster, attributes)) + } + + /// The stencil is sampled bilinearly where each image pixel's center lies, extending its edge pixels beyond it. + fn mask(image: Item, stencil: Item, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + let (image_size, image_transform) = raster_placement(&image); + let (stencil_size, stencil_transform) = raster_placement(&stencil); + if image_transform.matrix2.determinant() == 0. || stencil_transform.matrix2.determinant() == 0. { + return Ok(image); + } + + let frame = KernelFrame::new(image_size, image_transform, &[(image_size, image_transform), (stencil_size, stencil_transform)]); + let to_stencil = frame.output_to_texel[1]; + let kernel = MaskStencil { + stencil_axes: to_stencil.matrix2.to_cols_array().map(|value| value as f32), + stencil_translation: to_stencil.translation.to_array().map(|value| value as f32), + }; + let raster = run_raster_kernel(&kernel, frame.kernel_inputs(&[image.element(), stencil.element()]), frame.size, pipeline)?; + + let (_, attributes) = image.into_parts(); + Ok(Item::from_parts(raster, attributes)) + } + + fn extend_image_to_bounds(image: Item, bounds: DAffine2, pipeline: &WgpuPipelineCache) -> Result, (Item, NodeError)> { + let (image_size, image_transform) = raster_placement(&image); + let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); + let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); + if image_transform.matrix2.determinant() == 0. || (image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end)) { + return Ok(image); + } + + let orig_image_scale = image_size.as_dvec2(); + let layer_to_image_space = DAffine2::from_scale(orig_image_scale) * image_transform.inverse(); + let bounds_in_image_space = Bbox::unit().affine_transform(layer_to_image_space * bounds).to_axis_aligned_bbox(); + let new_start = bounds_in_image_space.start.floor().min(DVec2::ZERO); + let new_scale = bounds_in_image_space.end.ceil().max(orig_image_scale) - new_start; + let transform = image_transform * DAffine2::from_scale(1. / orig_image_scale) * DAffine2::from_translation(new_start) * DAffine2::from_scale(new_scale); + + let frame = KernelFrame::new(new_scale.as_uvec2(), transform, &[(image_size, image_transform)]); + let offset = new_start.as_ivec2(); + let kernel = ExtendToBounds { + offset_x: offset.x, + offset_y: offset.y, + }; + let raster = match run_raster_kernel(&kernel, frame.kernel_inputs(&[image.element()]), frame.size, pipeline) { + Ok(raster) => raster, + Err(error) => return Err((image, error)), + }; + + let (_, mut attributes) = image.into_parts(); + attributes.insert(ATTR_TRANSFORM, transform); + Ok(Item::from_parts(raster, attributes)) + } +} + +gpu_params! { + #[derive(Clone)] + struct CombineChannels {} +} + +impl RasterKernel for CombineChannels { + const INPUTS: usize = 4; + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let red = input_0_at(coord); + let green = input_1_at(coord); + let blue = input_2_at(coord); + let alpha = input_3_at(coord); + return vec4(luminance_rec_709(red) * red.a, luminance_rec_709(green) * green.a, luminance_rec_709(blue) * blue.a, luminance_rec_709(alpha) * alpha.a); + "#, + ) + } +} + +gpu_params! { + #[derive(Clone)] + struct MaskStencil { + stencil_axes: [f32; 4], + stencil_translation: [f32; 2], + } +} + +impl RasterKernel for MaskStencil { + const INPUTS: usize = 2; + fn wgsl() -> Wgsl { + Wgsl::function( + r#" +fn stencil_luminance(texel: vec2) -> f32 { + return luminance_rec_709(input_1_load(texel)); +} + +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let axes = parameters.stencil_axes; + let translation = parameters.stencil_translation; + let center = vec2(coord) + 0.5; + let texel = vec2(axes[0], axes[1]) * center.x + vec2(axes[2], axes[3]) * center.y + vec2(translation[0], translation[1]) - 0.5; + let base = floor(texel); + let t = texel - base; + let corner = vec2(base); + let top = mix(stencil_luminance(corner), stencil_luminance(corner + vec2(1, 0)), t.x); + let bottom = mix(stencil_luminance(corner + vec2(0, 1)), stencil_luminance(corner + vec2(1, 1)), t.x); + let color = input_0_load(coord); + return vec4(color.rgb, color.a * mix(top, bottom, t.y)); +} +"#, + ) + } +} + +gpu_params! { + #[derive(Clone)] + struct ExtendToBounds { + offset_x: i32, + offset_y: i32, + } +} + +impl RasterKernel for ExtendToBounds { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let texel = coord + vec2(offset_x, offset_y); + if any(texel < vec2(0)) || any(texel >= input_0_size()) { + return vec4(0.); + } + return input_0_load(texel); + "#, + ) + } +} + /// Displays an image from a file. /// /// Reads PNG, JPG, GIF, WEBP, TIFF, BMP, TGA, ICO, HDR, and EXR files. Light brighter than white, as HDR and EXR files can hold, is clipped to white. diff --git a/node-graph/nodes/raster/src/wgsl/black_and_white.wgsl b/node-graph/nodes/raster/src/wgsl/black_and_white.wgsl new file mode 100644 index 00000000000..0da17b26b90 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/black_and_white.wgsl @@ -0,0 +1,28 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + + let gray_base = min(min(channels.r, channels.g), channels.b); + let part = channels.rgb - gray_base; + + var additional: f32; + if part.r == 0. { + let cyan_part = min(part.g, part.b); + additional = cyan_part * parameters.cyans + (part.g - cyan_part) * parameters.greens + (part.b - cyan_part) * parameters.blues; + } else if part.g == 0. { + let magenta_part = min(part.r, part.b); + additional = magenta_part * parameters.magentas + (part.r - magenta_part) * parameters.reds + (part.b - magenta_part) * parameters.blues; + } else { + let yellow_part = min(part.r, part.g); + additional = yellow_part * parameters.yellows + (part.r - yellow_part) * parameters.reds + (part.g - yellow_part) * parameters.greens; + } + + let luminance = clamp(gray_base + additional, 0., 1.); + if !parameters.use_tint { + return from_gamma_srgb_channels(vec4(vec3(luminance), channels.a)); + } + + let tint = to_gamma_srgb_channels(parameters.tint).rgb; + let tinted = set_luminosity(tint, luma_rec_601_fixed_point(tint), luminance); + return from_gamma_srgb_channels(vec4(tinted, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/blending.wgsl b/node-graph/nodes/raster/src/wgsl/blending.wgsl new file mode 100644 index 00000000000..c41d117da6c --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blending.wgsl @@ -0,0 +1,333 @@ +const BLEND_MODE_NORMAL: i32 = 0; +const BLEND_MODE_DARKEN: i32 = 1; +const BLEND_MODE_MULTIPLY: i32 = 2; +const BLEND_MODE_COLOR_BURN: i32 = 3; +const BLEND_MODE_LINEAR_BURN: i32 = 4; +const BLEND_MODE_DARKER_COLOR: i32 = 5; +const BLEND_MODE_LIGHTEN: i32 = 6; +const BLEND_MODE_SCREEN: i32 = 7; +const BLEND_MODE_COLOR_DODGE: i32 = 8; +const BLEND_MODE_LINEAR_DODGE: i32 = 9; +const BLEND_MODE_LIGHTER_COLOR: i32 = 10; +const BLEND_MODE_OVERLAY: i32 = 11; +const BLEND_MODE_SOFT_LIGHT: i32 = 12; +const BLEND_MODE_HARD_LIGHT: i32 = 13; +const BLEND_MODE_VIVID_LIGHT: i32 = 14; +const BLEND_MODE_LINEAR_LIGHT: i32 = 15; +const BLEND_MODE_PIN_LIGHT: i32 = 16; +const BLEND_MODE_HARD_MIX: i32 = 17; +const BLEND_MODE_DIFFERENCE: i32 = 18; +const BLEND_MODE_EXCLUSION: i32 = 19; +const BLEND_MODE_SUBTRACT: i32 = 20; +const BLEND_MODE_DIVIDE: i32 = 21; +const BLEND_MODE_HUE: i32 = 22; +const BLEND_MODE_SATURATION: i32 = 23; +const BLEND_MODE_COLOR: i32 = 24; +const BLEND_MODE_LUMINOSITY: i32 = 25; +const BLEND_MODE_ERASE: i32 = 26; +const BLEND_MODE_RESTORE: i32 = 27; +const BLEND_MODE_MULTIPLY_ALPHA: i32 = 28; + +fn blend_normal(c_b: f32, c_s: f32) -> f32 { + return c_s; +} + +fn blend_multiply(c_b: f32, c_s: f32) -> f32 { + return c_s * c_b; +} + +fn blend_darken(c_b: f32, c_s: f32) -> f32 { + return min(c_s, c_b); +} + +fn blend_color_burn(c_b: f32, c_s: f32) -> f32 { + if c_b == 1. { + return 1.; + } else if c_s == 0. { + return 0.; + } + return 1. - min((1. - c_b) / c_s, 1.); +} + +fn blend_linear_burn(c_b: f32, c_s: f32) -> f32 { + return c_b + c_s - 1.; +} + +fn blend_darker_color(color: vec4, other: vec4) -> vec4 { + let darker = select(other, color, average_rgb_channels(color) <= average_rgb_channels(other)); + + return vec4(darker.rgb, other.a); +} + +fn blend_screen(c_b: f32, c_s: f32) -> f32 { + return 1. - (1. - c_s) * (1. - c_b); +} + +fn blend_lighten(c_b: f32, c_s: f32) -> f32 { + return max(c_s, c_b); +} + +fn blend_color_dodge(c_b: f32, c_s: f32) -> f32 { + if c_s == 1. { + return 1.; + } + return min(c_b / (1. - c_s), 1.); +} + +fn blend_linear_dodge(c_b: f32, c_s: f32) -> f32 { + return c_b + c_s; +} + +fn blend_lighter_color(color: vec4, other: vec4) -> vec4 { + let lighter = select(other, color, average_rgb_channels(color) >= average_rgb_channels(other)); + + return vec4(lighter.rgb, other.a); +} + +fn blend_softlight(c_b: f32, c_s: f32) -> f32 { + if c_s <= 0.5 { + return c_b - (1. - 2. * c_s) * c_b * (1. - c_b); + } + var d = 0.; + if c_b <= 0.25 { + d = ((16. * c_b - 12.) * c_b + 4.) * c_b; + } else { + d = sqrt(c_b); + } + return c_b + (2. * c_s - 1.) * (d - c_b); +} + +fn blend_hardlight(c_b: f32, c_s: f32) -> f32 { + if c_s <= 0.5 { + return blend_multiply(2. * c_s, c_b); + } + return blend_screen(2. * c_s - 1., c_b); +} + +fn blend_overlay(c_b: f32, c_s: f32) -> f32 { + return blend_hardlight(c_s, c_b); +} + +fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 { + if c_s <= 0.5 { + return blend_color_burn(2. * c_s, c_b); + } + return blend_color_dodge(2. * c_s - 1., c_b); +} + +fn blend_linear_light(c_b: f32, c_s: f32) -> f32 { + if c_s <= 0.5 { + return blend_linear_burn(2. * c_s, c_b); + } + return blend_linear_dodge(2. * c_s - 1., c_b); +} + +fn blend_pin_light(c_b: f32, c_s: f32) -> f32 { + if c_s <= 0.5 { + return blend_darken(2. * c_s, c_b); + } + return blend_lighten(2. * c_s - 1., c_b); +} + +fn blend_hard_mix(c_b: f32, c_s: f32) -> f32 { + return select(1., 0., blend_linear_light(c_b, c_s) < 0.5); +} + +fn blend_difference(c_b: f32, c_s: f32) -> f32 { + return abs(c_b - c_s); +} + +fn blend_exclusion(c_b: f32, c_s: f32) -> f32 { + return c_b + c_s - 2. * c_b * c_s; +} + +fn blend_subtract(c_b: f32, c_s: f32) -> f32 { + return c_b - c_s; +} + +fn blend_divide(c_b: f32, c_s: f32) -> f32 { + if c_b == 0. { + return 1.; + } + return c_b / c_s; +} + +fn blend_channel(blend_mode: i32, c_b: f32, c_s: f32) -> f32 { + switch blend_mode { + case BLEND_MODE_DARKEN: { + return blend_darken(c_b, c_s); + } + case BLEND_MODE_MULTIPLY: { + return blend_multiply(c_b, c_s); + } + case BLEND_MODE_COLOR_BURN: { + return blend_color_burn(c_b, c_s); + } + case BLEND_MODE_LINEAR_BURN: { + return blend_linear_burn(c_b, c_s); + } + case BLEND_MODE_LIGHTEN: { + return blend_lighten(c_b, c_s); + } + case BLEND_MODE_SCREEN: { + return blend_screen(c_b, c_s); + } + case BLEND_MODE_COLOR_DODGE: { + return blend_color_dodge(c_b, c_s); + } + case BLEND_MODE_LINEAR_DODGE: { + return blend_linear_dodge(c_b, c_s); + } + case BLEND_MODE_OVERLAY: { + return blend_overlay(c_b, c_s); + } + case BLEND_MODE_SOFT_LIGHT: { + return blend_softlight(c_b, c_s); + } + case BLEND_MODE_HARD_LIGHT: { + return blend_hardlight(c_b, c_s); + } + case BLEND_MODE_VIVID_LIGHT: { + return blend_vivid_light(c_b, c_s); + } + case BLEND_MODE_LINEAR_LIGHT: { + return blend_linear_light(c_b, c_s); + } + case BLEND_MODE_PIN_LIGHT: { + return blend_pin_light(c_b, c_s); + } + case BLEND_MODE_HARD_MIX: { + return blend_hard_mix(c_b, c_s); + } + case BLEND_MODE_DIFFERENCE: { + return blend_difference(c_b, c_s); + } + case BLEND_MODE_EXCLUSION: { + return blend_exclusion(c_b, c_s); + } + case BLEND_MODE_SUBTRACT: { + return blend_subtract(c_b, c_s); + } + case BLEND_MODE_DIVIDE: { + return blend_divide(c_b, c_s); + } + default: { + return blend_normal(c_b, c_s); + } + } +} + +fn blend_rgb(color: vec4, other: vec4, blend_mode: i32) -> vec4 { + let red = blend_channel(blend_mode, color.r, other.r); + let green = blend_channel(blend_mode, color.g, other.g); + let blue = blend_channel(blend_mode, color.b, other.b); + + return vec4(clamp(vec3(red, green, blue), vec3(0.), vec3(1.)), other.a); +} + +fn gamma_rgb_in_range(color: vec4) -> vec3 { + return clamp(to_gamma_srgb_channels(color).rgb, vec3(0.), vec3(1.)); +} + +fn blend_hue(color: vec4, c_s: vec4) -> vec4 { + let backdrop = gamma_rgb_in_range(color); + let saturated = set_saturation(gamma_rgb_in_range(c_s), channel_range(backdrop)); + let blended = set_luminosity(saturated, luma_rec_601_rounded(saturated), luma_rec_601_rounded(backdrop)); + + return from_gamma_srgb_channels(vec4(blended, c_s.a)); +} + +fn blend_saturation(color: vec4, c_s: vec4) -> vec4 { + let backdrop = gamma_rgb_in_range(color); + let saturated = set_saturation(backdrop, channel_range(gamma_rgb_in_range(c_s))); + let blended = set_luminosity(saturated, luma_rec_601_rounded(saturated), luma_rec_601_rounded(backdrop)); + + return from_gamma_srgb_channels(vec4(blended, c_s.a)); +} + +fn blend_color(color: vec4, c_s: vec4) -> vec4 { + let backdrop = gamma_rgb_in_range(color); + let source = gamma_rgb_in_range(c_s); + let blended = set_luminosity(source, luma_rec_601_rounded(source), luma_rec_601_rounded(backdrop)); + + return from_gamma_srgb_channels(vec4(blended, c_s.a)); +} + +fn blend_luminosity(color: vec4, c_s: vec4) -> vec4 { + let backdrop = gamma_rgb_in_range(color); + let source = gamma_rgb_in_range(c_s); + let blended = set_luminosity(backdrop, luma_rec_601_rounded(backdrop), luma_rec_601_rounded(source)); + + return from_gamma_srgb_channels(vec4(blended, c_s.a)); +} + +fn alpha_blend(color: vec4, other: vec4) -> vec4 { + let under_weight = color.a * (1. - other.a); + let alpha = other.a + under_weight; + if alpha == 0. { + return vec4(0.); + } + return vec4((other.rgb * other.a + color.rgb * under_weight) / alpha, alpha); +} + +fn alpha_add(color: vec4, other: vec4) -> vec4 { + return vec4(color.rgb, clamp(color.a + other.a, 0., 1.)); +} + +fn alpha_subtract(color: vec4, other: vec4) -> vec4 { + return vec4(color.rgb, clamp(color.a - other.a, 0., 1.)); +} + +fn alpha_multiply(color: vec4, other: vec4) -> vec4 { + return vec4(color.rgb, clamp(color.a * other.a, 0., 1.)); +} + +fn blend_colors(foreground: vec4, background: vec4, blend_mode: i32, opacity: f32) -> vec4 { + var applied = vec4(0.); + switch blend_mode { + case BLEND_MODE_ERASE: { + applied = alpha_subtract(background, foreground); + } + case BLEND_MODE_RESTORE: { + applied = alpha_add(background, foreground); + } + case BLEND_MODE_MULTIPLY_ALPHA: { + applied = alpha_multiply(background, foreground); + } + default: { + let target_color = apply_blend_mode(foreground, background, blend_mode); + return alpha_blend(background, vec4(target_color.rgb, target_color.a * opacity)); + } + } + + return vec4(background.rgb, background.a + (applied.a - background.a) * opacity); +} + +fn apply_blend_mode(foreground: vec4, background: vec4, blend_mode: i32) -> vec4 { + switch blend_mode { + case BLEND_MODE_DARKER_COLOR: { + return blend_darker_color(background, foreground); + } + case BLEND_MODE_LIGHTER_COLOR: { + return blend_lighter_color(background, foreground); + } + case BLEND_MODE_HUE: { + return blend_hue(background, foreground); + } + case BLEND_MODE_SATURATION: { + return blend_saturation(background, foreground); + } + case BLEND_MODE_COLOR: { + return blend_color(background, foreground); + } + case BLEND_MODE_LUMINOSITY: { + return blend_luminosity(background, foreground); + } + case BLEND_MODE_ERASE, BLEND_MODE_RESTORE, BLEND_MODE_MULTIPLY_ALPHA: { + return foreground; + } + default: { + return blend_rgb(background, foreground, blend_mode); + } + } +} diff --git a/node-graph/nodes/raster/src/wgsl/blur.wgsl b/node-graph/nodes/raster/src/wgsl/blur.wgsl new file mode 100644 index 00000000000..efe3fc2d431 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blur.wgsl @@ -0,0 +1,41 @@ +const BLUR_LINEAR: u32 = 1u; +const BLUR_GAMMA: u32 = 2u; + +fn blur_weight(offset: i32, radius: f32, box_blur: bool) -> f32 { + let distance = f32(offset); + if box_blur { + return clamp(radius + 1. - abs(distance), 0., 1.); + } + return exp(-(distance * distance) / (2. * radius * radius)); +} + +fn blur_coverage(texel: vec2, full_size: array, scale: i32) -> vec2 { + let covered = clamp(vec2(full_size[0], full_size[1]) - texel * scale, vec2(0), vec2(scale)); + return vec2(covered) / f32(scale); +} + +fn blur_premultiply(color: vec4, space: u32) -> vec4 { + if space == BLUR_LINEAR { + return vec4(color.rgb * color.a, color.a); + } + if space == BLUR_GAMMA { + return vec4(linear_to_srgb(color.rgb) * color.a, color.a); + } + return color; +} + +fn blur_unpremultiply(color: vec4, space: u32) -> vec4 { + if space == BLUR_LINEAR { + if color.a == 0. { + return color; + } + return vec4(color.rgb * (1. / color.a), color.a); + } + if space == BLUR_GAMMA { + if color.a <= 0. { + return vec4(0.); + } + return vec4(srgb_to_linear(color.rgb * (1. / color.a)), color.a); + } + return color; +} diff --git a/node-graph/nodes/raster/src/wgsl/blur_halve.wgsl b/node-graph/nodes/raster/src/wgsl/blur_halve.wgsl new file mode 100644 index 00000000000..8be9efb5165 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blur_halve.wgsl @@ -0,0 +1,13 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + var sum = vec4(0.); + var weight_sum = 0.; + for (var corner = 0; corner < 4; corner++) { + let texel = coord * 2 + vec2(corner % 2, corner / 2); + let coverage = blur_coverage(texel, parameters.full_size, parameters.scale); + let weight = coverage.x * coverage.y; + sum += blur_premultiply(input_0_load(texel), parameters.space) * weight; + weight_sum += weight; + } + return sum / weight_sum; +} diff --git a/node-graph/nodes/raster/src/wgsl/blur_horizontal.wgsl b/node-graph/nodes/raster/src/wgsl/blur_horizontal.wgsl new file mode 100644 index 00000000000..9346c2404c5 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blur_horizontal.wgsl @@ -0,0 +1,15 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let first = -min(parameters.reach, coord.x); + let last = min(parameters.reach, input_0_size().x - 1 - coord.x); + + var sum = vec4(0.); + var weight_sum = 0.; + for (var offset = first; offset <= last; offset++) { + let texel = vec2(coord.x + offset, coord.y); + let weight = blur_weight(offset, parameters.radius, parameters.box_blur) * blur_coverage(texel, parameters.full_size, parameters.scale).x; + sum += blur_premultiply(input_0_load(texel), parameters.space) * weight; + weight_sum += weight; + } + return sum / weight_sum; +} diff --git a/node-graph/nodes/raster/src/wgsl/blur_upsample.wgsl b/node-graph/nodes/raster/src/wgsl/blur_upsample.wgsl new file mode 100644 index 00000000000..33d86ba43fe --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blur_upsample.wgsl @@ -0,0 +1,4 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + return blur_unpremultiply(input_0_sample((vec2(coord) + 0.5) / parameters.scale), parameters.space); +} diff --git a/node-graph/nodes/raster/src/wgsl/blur_vertical.wgsl b/node-graph/nodes/raster/src/wgsl/blur_vertical.wgsl new file mode 100644 index 00000000000..887ba19b7ea --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/blur_vertical.wgsl @@ -0,0 +1,37 @@ +struct BlurSum { + color: vec4, + weight: f32, +} + +fn blur_tap_weight(coord: vec2, offset: i32) -> f32 { + let parameters = params(); + let coverage = blur_coverage(coord + vec2(0, offset), parameters.full_size, parameters.scale).y; + return blur_weight(abs(offset), parameters.radius, parameters.box_blur) * coverage; +} + +fn blur_side(coord: vec2, direction: i32, taps: i32) -> BlurSum { + var sum = BlurSum(vec4(0.), 0.); + for (var near = 1; near <= taps; near += 2) { + let near_weight = blur_tap_weight(coord, direction * near); + if near + 1 > taps { + sum.color += input_0_load(coord + vec2(0, direction * near)) * near_weight; + sum.weight += near_weight; + break; + } + let far_weight = blur_tap_weight(coord, direction * (near + 1)); + let weight = near_weight + far_weight; + let offset = f32(near) + far_weight / weight; + sum.color += input_0_sample(vec2(coord) + 0.5 + vec2(0., f32(direction) * offset)) * weight; + sum.weight += weight; + } + return sum; +} + +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let above = blur_side(coord, -1, min(parameters.reach, coord.y)); + let below = blur_side(coord, 1, min(parameters.reach, input_0_size().y - 1 - coord.y)); + let center_weight = blur_tap_weight(coord, 0); + let sum = input_0_load(coord) * center_weight + above.color + below.color; + return blur_unpremultiply(sum / (center_weight + above.weight + below.weight), parameters.space); +} diff --git a/node-graph/nodes/raster/src/wgsl/brightness_contrast.wgsl b/node-graph/nodes/raster/src/wgsl/brightness_contrast.wgsl new file mode 100644 index 00000000000..b72374aa976 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/brightness_contrast.wgsl @@ -0,0 +1,97 @@ +fn apply_levels(value: f32, input_shadows: f32, input_highlights: f32, inverse_gamma: f32, output_minimum: f32, output_maximum: f32) -> f32 { + let highlights_minus_shadows = clamp(input_highlights - input_shadows, 1.1920929e-7, 1.); + let clamped = min(max(value - input_shadows, 0.) / highlights_minus_shadows, 1.); + let curved = pow(clamped, inverse_gamma); + + return curved * (output_maximum - output_minimum) + output_minimum; +} + +fn brightness_contrast_classic(value: f32, brightness: f32, contrast: f32, pivot: f32) -> f32 { + if contrast >= 1. { + return select(0., 1., value + brightness >= pivot - 1. / 65536.); + } + + var result: f32; + if contrast > 0. { + let input_shadows = pivot * contrast - brightness; + result = apply_levels(value, input_shadows, input_shadows + 1. - contrast, 1., 0., 1.); + } else { + let output_minimum = brightness - pivot * contrast; + result = apply_levels(value, 0., 1., 1., output_minimum, output_minimum + 1. + contrast); + } + + return clamp(result, 0., 1.); +} + +fn brightness_curve_hermite(curve: BrightnessCurve, t: f32) -> vec2 { + let length = 1. - curve.knee; + let start_tangent = length * curve.slope; + let end_tangent = length * curve.end_slope; + let t2 = t * t; + let t3 = t2 * t; + + let value = (2. * t3 - 3. * t2 + 1.) * 0.5 + (t3 - 2. * t2 + t) * start_tangent + (-2. * t3 + 3. * t2) + (t3 - t2) * end_tangent; + let derivative = ((6. * t2 - 6. * t) * 0.5 + (3. * t2 - 4. * t + 1.) * start_tangent + (-6. * t2 + 6. * t) + (3. * t2 - 2. * t) * end_tangent) / length; + + return vec2(value, derivative); +} + +fn brightness_curve_forward(curve: BrightnessCurve, x: f32) -> f32 { + if x < curve.knee { + return curve.slope * x; + } + + let t = min((x - curve.knee) / (1. - curve.knee), 1.); + return min(brightness_curve_hermite(curve, t).x, 1.); +} + +fn brightness_curve_inverse(curve: BrightnessCurve, y: f32) -> f32 { + if y <= 0.5 { + return y / curve.slope; + } + + var low = 0.; + var high = 1.; + var t = (y - 0.5) * 2.; + for (var i = 0; i < 8; i++) { + let hermite = brightness_curve_hermite(curve, t); + let error = hermite.x - y; + if error > 0. { + high = t; + } else { + low = t; + } + + let step = t - error / (hermite.y * (1. - curve.knee)); + t = select((low + high) * 0.5, step, step >= low && step <= high); + } + + return curve.knee + t * (1. - curve.knee); +} + +fn adjust(c: f32, parameters: Params) -> f32 { + if parameters.use_classic { + return brightness_contrast_classic(c, parameters.brightness / 255., parameters.contrast, parameters.classic_pivot); + } + + var brightened: f32; + if parameters.brightness >= 0. { + brightened = brightness_curve_forward(parameters.second_curve, brightness_curve_forward(parameters.first_curve, c)); + } else { + brightened = brightness_curve_inverse(parameters.first_curve, brightness_curve_inverse(parameters.second_curve, c)); + } + + let contrasted = brightened + 0.76 * parameters.contrast * (2. * brightened - 1.) * min(brightened, 1. - brightened); + return clamp(contrasted, 0., 1.); +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let adjusted = vec3( + adjust(channels.r, parameters), + adjust(channels.g, parameters), + adjust(channels.b, parameters), + ); + return from_gamma_srgb_channels(vec4(adjusted, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/channel_mixer.wgsl b/node-graph/nodes/raster/src/wgsl/channel_mixer.wgsl new file mode 100644 index 00000000000..7275c228c18 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/channel_mixer.wgsl @@ -0,0 +1,22 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let r = channels.r; + let g = channels.g; + let b = channels.b; + + var mixed = vec3(0.); + if parameters.monochrome { + let gray = clamp(r * parameters.monochrome_r + g * parameters.monochrome_g + b * parameters.monochrome_b + parameters.monochrome_c, 0., 1.); + + mixed = vec3(gray); + } else { + let red = clamp(r * parameters.red_r + g * parameters.red_g + b * parameters.red_b + parameters.red_c, 0., 1.); + let green = clamp(r * parameters.green_r + g * parameters.green_g + b * parameters.green_b + parameters.green_c, 0., 1.); + let blue = clamp(r * parameters.blue_r + g * parameters.blue_g + b * parameters.blue_b + parameters.blue_c, 0., 1.); + + mixed = vec3(red, green, blue); + } + + return from_gamma_srgb_channels(vec4(mixed, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/color_balance.wgsl b/node-graph/nodes/raster/src/wgsl/color_balance.wgsl new file mode 100644 index 00000000000..486ed56994d --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/color_balance.wgsl @@ -0,0 +1,5 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + return from_gamma_srgb_channels(vec4(levels_curve_apply(parameters.red, channels.r), levels_curve_apply(parameters.green, channels.g), levels_curve_apply(parameters.blue, channels.b), channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/color_lookup.wgsl b/node-graph/nodes/raster/src/wgsl/color_lookup.wgsl new file mode 100644 index 00000000000..93e79a5090f --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/color_lookup.wgsl @@ -0,0 +1,50 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let domain_min = vec3(parameters.domain_min[0], parameters.domain_min[1], parameters.domain_min[2]); + let domain_max = vec3(parameters.domain_max[0], parameters.domain_max[1], parameters.domain_max[2]); + let channels = to_gamma_srgb_channels(color); + let span = max(domain_max - domain_min, vec3(1.1920929e-7)); + let normalized = clamp((channels.rgb - domain_min) / span, vec3(0.), vec3(1.)); + + let last = table_size() - 1; + let scaled = normalized * f32(last); + let lower = min(vec3(floor(scaled)), vec3(last)); + let upper = min(lower + 1, vec3(last)); + let f = scaled - vec3(lower); + + var first: vec3; + var second: vec3; + var t: vec3; + if f.r >= f.g && f.g >= f.b { + first = vec3(upper.r, lower.g, lower.b); + second = vec3(upper.r, upper.g, lower.b); + t = vec3(f.r, f.g, f.b); + } else if f.r >= f.b && f.b >= f.g { + first = vec3(upper.r, lower.g, lower.b); + second = vec3(upper.r, lower.g, upper.b); + t = vec3(f.r, f.b, f.g); + } else if f.b >= f.r && f.r >= f.g { + first = vec3(lower.r, lower.g, upper.b); + second = vec3(upper.r, lower.g, upper.b); + t = vec3(f.b, f.r, f.g); + } else if f.g >= f.r && f.r >= f.b { + first = vec3(lower.r, upper.g, lower.b); + second = vec3(upper.r, upper.g, lower.b); + t = vec3(f.g, f.r, f.b); + } else if f.g >= f.b && f.b >= f.r { + first = vec3(lower.r, upper.g, lower.b); + second = vec3(lower.r, upper.g, upper.b); + t = vec3(f.g, f.b, f.r); + } else { + first = vec3(lower.r, lower.g, upper.b); + second = vec3(lower.r, upper.g, upper.b); + t = vec3(f.b, f.g, f.r); + } + + let c000 = table_load(lower).rgb; + let c111 = table_load(upper).rgb; + let first_entry = table_load(first).rgb; + let second_entry = table_load(second).rgb; + let result = c000 + (t.x * (first_entry - c000) + t.y * (second_entry - first_entry) + t.z * (c111 - second_entry)); + return from_gamma_srgb_channels(vec4(result, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/color_overlay.wgsl b/node-graph/nodes/raster/src/wgsl/color_overlay.wgsl new file mode 100644 index 00000000000..e3abd24f52f --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/color_overlay.wgsl @@ -0,0 +1,6 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let overlay = apply_blend_mode(parameters.color, color, parameters.blend_mode); + + return vec4(color.rgb + (overlay.rgb - color.rgb) * parameters.opacity, color.a); +} diff --git a/node-graph/nodes/raster/src/wgsl/desaturate.wgsl b/node-graph/nodes/raster/src/wgsl/desaturate.wgsl new file mode 100644 index 00000000000..f94e9f312c7 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/desaturate.wgsl @@ -0,0 +1,43 @@ +const LUMINANCE_REC_709: u32 = 0u; +const LUMA_REC_709: u32 = 1u; +const LUMA_REC_601: u32 = 2u; +const LIGHTNESS_OK_LAB: u32 = 3u; +const CHANNELS_AVERAGE: u32 = 4u; +const CHANNELS_MINIMUM: u32 = 5u; +const CHANNELS_MAXIMUM: u32 = 6u; +const LIGHTNESS_HSL: u32 = 7u; + +fn map_color(color: vec4) -> vec4 { + var luminance: f32; + switch params().method { + case LUMA_REC_709: { + let channels = to_gamma_srgb_channels(color); + luminance = srgb_to_linear(vec3(0.2126 * channels.r + 0.7152 * channels.g + 0.0722 * channels.b)).x; + } + case LUMA_REC_601: { + let channels = to_gamma_srgb_channels(color); + luminance = srgb_to_linear(vec3(0.299 * channels.r + 0.587 * channels.g + 0.114 * channels.b)).x; + } + case LIGHTNESS_OK_LAB: { + let lightness = lightness_oklab(color); + luminance = lightness * lightness * lightness; + } + case CHANNELS_AVERAGE: { + luminance = average_rgb_channels(color); + } + case CHANNELS_MINIMUM: { + luminance = minimum_rgb_channels(color); + } + case CHANNELS_MAXIMUM: { + luminance = maximum_rgb_channels(color); + } + case LIGHTNESS_HSL: { + let extremes = linear_to_srgb(vec3(maximum_rgb_channels(color), minimum_rgb_channels(color), 0.)); + luminance = srgb_to_linear(vec3((extremes.x + extremes.y) / 2.)).x; + } + case LUMINANCE_REC_709, default: { + luminance = luminance_rec_709(color); + } + } + return vec4(vec3(luminance), color.a); +} diff --git a/node-graph/nodes/raster/src/wgsl/exposure.wgsl b/node-graph/nodes/raster/src/wgsl/exposure.wgsl new file mode 100644 index 00000000000..727b65d8ee2 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/exposure.wgsl @@ -0,0 +1,17 @@ +const TOE_END: f32 = 0.05568117; + +fn decode(value: vec3) -> vec3 { + return select(pow(value, vec3(2.2)), value / 32., value < vec3(TOE_END)); +} + +fn encode(linear: vec3) -> vec3 { + return select(pow(linear, vec3(1. / 2.2)), linear * 32., linear < vec3(TOE_END / 32.)); +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let linear = decode(channels.rgb) * exp2(parameters.exposure) + parameters.offset; + let adjusted = encode(min(pow(max(linear, vec3(0.)), vec3(1. / parameters.gamma_correction)), vec3(1.))); + return from_gamma_srgb_channels(vec4(adjusted, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/gradient_map.wgsl b/node-graph/nodes/raster/src/wgsl/gradient_map.wgsl new file mode 100644 index 00000000000..cb6bd0f6b82 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/gradient_map.wgsl @@ -0,0 +1,23 @@ +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let intensity = 0.3 * channels.r + 0.59 * channels.g + 0.11 * channels.b; + var t = select(intensity, 1. - intensity, parameters.reverse); + switch parameters.spread { + case 1u: { + let cycle = t - 2. * floor(t / 2.); + t = select(cycle, 2. - cycle, cycle > 1.); + } + case 2u: { + t -= floor(t); + } + case 3u: { + if t < 0. || t > 1. { + return vec4(0., 0., 0., color.a); + } + } + default: {} + } + let entry = select(gradient(t), gradient_stepped(t), parameters.stepped); + return vec4(entry.rgb, color.a); +} diff --git a/node-graph/nodes/raster/src/wgsl/hue.wgsl b/node-graph/nodes/raster/src/wgsl/hue.wgsl new file mode 100644 index 00000000000..ae1a5fb9f13 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/hue.wgsl @@ -0,0 +1,21 @@ +fn hexagon_hue_degrees(rgb: vec3) -> f32 { + let maximum = max(max(rgb.r, rgb.g), rgb.b); + let chroma = maximum - min(min(rgb.r, rgb.g), rgb.b); + if chroma <= 0. { + return 0.; + } + + var sector: f32; + if maximum == rgb.r { + sector = wrap_positive((rgb.g - rgb.b) / chroma, 6.); + } else if maximum == rgb.g { + sector = (rgb.b - rgb.r) / chroma + 2.; + } else { + sector = (rgb.r - rgb.g) / chroma + 4.; + } + return sector * 60.; +} + +fn wrap_positive(value: f32, modulus: f32) -> f32 { + return value - floor(value / modulus) * modulus; +} diff --git a/node-graph/nodes/raster/src/wgsl/hue_saturation.wgsl b/node-graph/nodes/raster/src/wgsl/hue_saturation.wgsl new file mode 100644 index 00000000000..089f0dea162 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/hue_saturation.wgsl @@ -0,0 +1,138 @@ +struct HueSaturationRangeEffect { + hue_shift: f32, + saturation_factor: f32, + fully_saturate: bool, + rgb: vec3, +} + +fn gamma_rgb_to_hsl(rgb: vec3) -> vec3 { + let maximum = max(max(rgb.r, rgb.g), rgb.b); + let minimum = min(min(rgb.r, rgb.g), rgb.b); + let chroma = maximum - minimum; + let lightness = (maximum + minimum) / 2.; + if chroma <= 0. { + return vec3(0., 0., lightness); + } + + let saturation = chroma / max(1. - abs(2. * lightness - 1.), 1e-6); + return vec3(hexagon_hue_degrees(rgb), min(saturation, 1.), lightness); +} + +fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> vec3 { + let chroma = (1. - abs(2. * lightness - 1.)) * saturation; + let sector = wrap_positive(hue, 360.) / 60.; + let x = chroma * (1. - abs(wrap_positive(sector, 2.) - 1.)); + var rgb: vec3; + if sector < 1. { + rgb = vec3(chroma, x, 0.); + } else if sector < 2. { + rgb = vec3(x, chroma, 0.); + } else if sector < 3. { + rgb = vec3(0., chroma, x); + } else if sector < 4. { + rgb = vec3(0., x, chroma); + } else if sector < 5. { + rgb = vec3(x, 0., chroma); + } else { + rgb = vec3(chroma, 0., x); + } + let m = lightness - chroma / 2.; + + return clamp(rgb + m, vec3(0.), vec3(1.)); +} + +fn hue_distance(from_hue: f32, to_hue: f32) -> f32 { + return wrap_positive(to_hue - from_hue, 360.); +} + +fn hue_saturation_range_settings_weight(range: HueSaturationRangeSettings, hue: f32) -> f32 { + if hue_distance(range.range_start, hue) <= hue_distance(range.range_start, range.range_end) { + return 1.; + } + let start_falloff = hue_distance(range.falloff_start, range.range_start); + let end_falloff = hue_distance(range.range_end, range.falloff_end); + if hue_distance(range.falloff_start, hue) < start_falloff { + return hue_distance(range.falloff_start, hue) / start_falloff; + } + if hue_distance(range.range_end, hue) < end_falloff { + return 1. - hue_distance(range.range_end, hue) / end_falloff; + } + return 0.; +} + +fn hue_saturation_range_effect_apply(effect: HueSaturationRangeEffect, range: HueSaturationRangeSettings, original_hue: f32, original_saturation: f32) -> HueSaturationRangeEffect { + var range_weight = 0.; + if original_saturation > 0. { + range_weight = hue_saturation_range_settings_weight(range, original_hue); + } + if range_weight <= 0. { + return effect; + } + + var result = effect; + result.hue_shift += range.settings.hue * range_weight; + if range.settings.saturation >= 1. { + result.fully_saturate = true; + } else { + result.saturation_factor *= 1. + (range.settings.saturation_gain - 1.) * range_weight; + } + result.rgb = lightness_toward_max_or_min(result.rgb, range.settings.lightness * range_weight); + return result; +} + +fn lightness_toward_white_or_black(value: f32, amount: f32) -> f32 { + if amount >= 0. { + return value + (1. - value) * amount; + } + return value * (1. + amount); +} + +fn lightness_toward_max_or_min(rgb: vec3, amount: f32) -> vec3 { + let maximum = max(max(rgb.r, rgb.g), rgb.b); + let minimum = min(min(rgb.r, rgb.g), rgb.b); + let toward = select(minimum, maximum, amount >= 0.); + return rgb + (toward - rgb) * abs(amount); +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let master = parameters.master; + let channels = to_gamma_srgb_channels(color); + + if parameters.colorize { + let colorize_settings = parameters.colorize_settings; + let lightness = lightness_toward_white_or_black(gamma_rgb_to_hsl(channels.rgb).z, colorize_settings.lightness); + return from_gamma_srgb_channels(vec4(hsl_to_gamma_rgb(colorize_settings.hue, colorize_settings.saturation, lightness), channels.a)); + } + + let original = gamma_rgb_to_hsl(channels.rgb); + var effect = HueSaturationRangeEffect(master.hue, 1., false, channels.rgb); + effect = hue_saturation_range_effect_apply(effect, parameters.reds, original.x, original.y); + effect = hue_saturation_range_effect_apply(effect, parameters.yellows, original.x, original.y); + effect = hue_saturation_range_effect_apply(effect, parameters.greens, original.x, original.y); + effect = hue_saturation_range_effect_apply(effect, parameters.cyans, original.x, original.y); + effect = hue_saturation_range_effect_apply(effect, parameters.blues, original.x, original.y); + effect = hue_saturation_range_effect_apply(effect, parameters.magentas, original.x, original.y); + var saturation_factor = effect.saturation_factor; + var fully_saturate = effect.fully_saturate; + if master.saturation >= 1. { + fully_saturate = true; + } else { + saturation_factor *= master.saturation_gain; + } + + let rgb = vec3( + lightness_toward_white_or_black(effect.rgb.r, master.lightness), + lightness_toward_white_or_black(effect.rgb.g, master.lightness), + lightness_toward_white_or_black(effect.rgb.b, master.lightness), + ); + let hsl = gamma_rgb_to_hsl(rgb); + var saturation = min(hsl.y * saturation_factor, 1.); + if hsl.y <= 0. { + saturation = 0.; + } else if fully_saturate { + saturation = 1.; + } + + return from_gamma_srgb_channels(vec4(hsl_to_gamma_rgb(hsl.x + effect.hue_shift, saturation, hsl.z), channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/levels.wgsl b/node-graph/nodes/raster/src/wgsl/levels.wgsl new file mode 100644 index 00000000000..c3e8e454698 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/levels.wgsl @@ -0,0 +1,15 @@ +fn levels_stage_apply(stage: LevelsStage, value: f32) -> f32 { + return levels_curve_apply(stage.curve, value) * (stage.output_maximum - stage.output_minimum) + stage.output_minimum; +} + +fn levels_chain_apply(chain: LevelsChain, value: f32) -> f32 { + let first = levels_stage_apply(chain.first, value); + return select(first, levels_stage_apply(chain.second, first), chain.two_stages); +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + + return from_gamma_srgb_channels(vec4(levels_chain_apply(parameters.red, channels.r), levels_chain_apply(parameters.green, channels.g), levels_chain_apply(parameters.blue, channels.b), levels_stage_apply(parameters.alpha, channels.a))); +} diff --git a/node-graph/nodes/raster/src/wgsl/levels_curve.wgsl b/node-graph/nodes/raster/src/wgsl/levels_curve.wgsl new file mode 100644 index 00000000000..ba8faed3874 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/levels_curve.wgsl @@ -0,0 +1,14 @@ +fn levels_curve_apply(curve: LevelsCurve, value: f32) -> f32 { + let t = clamp((value * 255. - curve.black) * 255. / (curve.white - curve.black), 0., 255.); + var y: f32; + if t < curve.toe_end { + let u = t / curve.toe_end; + let hermite_start = u * u * u - 2. * u * u + u; + let hermite_end_value = 3. * u * u - 2. * u * u * u; + let hermite_end_slope = u * u * u - u * u; + y = hermite_start * curve.toe_end * curve.toe_slope_start + hermite_end_value * curve.toe_value + hermite_end_slope * curve.toe_end * curve.toe_slope_end; + } else { + y = 255. * pow(t / 255., curve.exponent); + } + return y / 255.; +} diff --git a/node-graph/nodes/raster/src/wgsl/luma.wgsl b/node-graph/nodes/raster/src/wgsl/luma.wgsl new file mode 100644 index 00000000000..ae6d3e6c278 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/luma.wgsl @@ -0,0 +1,3 @@ +fn luma_rec_601_fixed_point(rgb: vec3) -> f32 { + return (4915. * rgb.r + 9667. * rgb.g + 1802. * rgb.b) / 16384.; +} diff --git a/node-graph/nodes/raster/src/wgsl/lut_1d.wgsl b/node-graph/nodes/raster/src/wgsl/lut_1d.wgsl new file mode 100644 index 00000000000..c88ab28bda1 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/lut_1d.wgsl @@ -0,0 +1,16 @@ +@group(0) @binding(BINDING) var LUT_texture: texture_2d; + +fn LUT(t: f32) -> vec4 { + let last = i32(textureDimensions(LUT_texture).x) - 1; + let scaled = clamp(t, 0., 1.) * f32(last); + let lower = min(i32(floor(scaled)), last); + let upper = min(lower + 1, last); + let lower_entry = textureLoad(LUT_texture, vec2(lower, 0), 0); + let upper_entry = textureLoad(LUT_texture, vec2(upper, 0), 0); + return lower_entry + (scaled - f32(lower)) * (upper_entry - lower_entry); +} + +fn LUT_stepped(t: f32) -> vec4 { + let last = i32(textureDimensions(LUT_texture).x) - 1; + return textureLoad(LUT_texture, vec2(min(i32(floor(clamp(t, 0., 1.) * f32(last))), last), 0), 0); +} diff --git a/node-graph/nodes/raster/src/wgsl/lut_3d.wgsl b/node-graph/nodes/raster/src/wgsl/lut_3d.wgsl new file mode 100644 index 00000000000..20bad2ffba5 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/lut_3d.wgsl @@ -0,0 +1,9 @@ +@group(0) @binding(BINDING) var LUT_texture: texture_3d; + +fn LUT_size() -> i32 { + return i32(textureDimensions(LUT_texture).x); +} + +fn LUT_load(index: vec3) -> vec4 { + return textureLoad(LUT_texture, clamp(index, vec3(0), vec3(LUT_size() - 1)), 0); +} diff --git a/node-graph/nodes/raster/src/wgsl/median_filter.wgsl b/node-graph/nodes/raster/src/wgsl/median_filter.wgsl new file mode 100644 index 00000000000..b49e4bb7a50 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/median_filter.wgsl @@ -0,0 +1,54 @@ +fn total_order_key(value: vec4) -> vec4 { + let bits = bitcast>(value); + return select(~bits, bits ^ vec4(0x80000000u), bits < vec4(0x80000000u)); +} + +fn from_total_order_key(key: vec4) -> vec4 { + return bitcast>(select(~key, key ^ vec4(0x80000000u), key >= vec4(0x80000000u))); +} + +fn kernel(coord: vec2) -> vec4 { + let radius = i32(params().radius); + let start = max(coord - radius, vec2(0)); + let end = min(coord + radius, input_0_size() - 1); + let samples = end - start + 1; + let middle = vec4(u32(samples.x * samples.y) / 2u); + var median = vec4(0u); + + if radius <= 1 { + for (var candidate_y = start.y; candidate_y <= end.y; candidate_y++) { + for (var candidate_x = start.x; candidate_x <= end.x; candidate_x++) { + let candidate = total_order_key(input_0_load(vec2(candidate_x, candidate_y))); + var below = vec4(0u); + var at_most = vec4(0u); + for (var y = start.y; y <= end.y; y++) { + for (var x = start.x; x <= end.x; x++) { + let key = total_order_key(input_0_load(vec2(x, y))); + below += select(vec4(0u), vec4(1u), key < candidate); + at_most += select(vec4(0u), vec4(1u), key <= candidate); + } + } + median = select(median, select(median, candidate, middle < at_most), below <= middle); + } + } + return from_total_order_key(median); + } + + for (var digit_index = 0u; digit_index < 16u; digit_index++) { + let shift = 30u - 2u * digit_index; + var below_one = vec4(0u); + var below_two = vec4(0u); + var below_three = vec4(0u); + for (var y = start.y; y <= end.y; y++) { + for (var x = start.x; x <= end.x; x++) { + let key = total_order_key(input_0_load(vec2(x, y))); + below_one += select(vec4(0u), vec4(1u), key < median + (1u << shift)); + below_two += select(vec4(0u), vec4(1u), key < median + (2u << shift)); + below_three += select(vec4(0u), vec4(1u), key < median + (3u << shift)); + } + } + let digit = select(vec4(0u), vec4(1u), below_one <= middle) + select(vec4(0u), vec4(1u), below_two <= middle) + select(vec4(0u), vec4(1u), below_three <= middle); + median += digit << vec4(shift); + } + return from_total_order_key(median); +} diff --git a/node-graph/nodes/raster/src/wgsl/photo_filter.wgsl b/node-graph/nodes/raster/src/wgsl/photo_filter.wgsl new file mode 100644 index 00000000000..78da5d128bc --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/photo_filter.wgsl @@ -0,0 +1,21 @@ +const SRGB_TO_XYZ_D50 = mat3x3(vec3(0.43607, 0.38515, 0.14307), vec3(0.22249, 0.71687, 0.06061), vec3(0.01392, 0.09708, 0.71410)); +const XYZ_D50_TO_SRGB = mat3x3(vec3(3.134096, -1.6174, -0.490638), vec3(-0.978793, 1.916295, 0.033454), vec3(0.071971, -0.228987, 1.40538)); + +fn multiply_matrix(matrix: mat3x3, vector: vec3) -> vec3 { + return vector * matrix; +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let xyz = multiply_matrix(SRGB_TO_XYZ_D50, srgb_to_linear(channels.rgb)); + let filtered = multiply_matrix(XYZ_D50_TO_SRGB, xyz * vec3(parameters.factor[0], parameters.factor[1], parameters.factor[2])); + var rgb = linear_to_srgb(clamp(filtered, vec3(0.), vec3(1.))); + + if parameters.preserve_luminosity { + let luma_in = luma_rec_601_fixed_point(clamp(channels.rgb, vec3(0.), vec3(1.))); + rgb = set_luminosity(rgb, luma_rec_601_fixed_point(rgb), luma_in); + } + + return from_gamma_srgb_channels(vec4(rgb, channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/prelude.wgsl b/node-graph/nodes/raster/src/wgsl/prelude.wgsl new file mode 100644 index 00000000000..413e28bfe03 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/prelude.wgsl @@ -0,0 +1,86 @@ +fn srgb_to_linear(channel: vec3) -> vec3 { + return select(pow((channel + 0.055) / 1.055, vec3(2.4)), channel / 12.92, channel <= vec3(0.04045)); +} + +fn linear_to_srgb(channel: vec3) -> vec3 { + return select(1.055 * pow(channel, vec3(1. / 2.4)) - 0.055, channel * 12.92, channel <= vec3(0.0031308)); +} + +fn to_gamma_srgb_channels(color: vec4) -> vec4 { + return vec4(linear_to_srgb(color.rgb), color.a); +} + +fn from_gamma_srgb_channels(channels: vec4) -> vec4 { + return vec4(srgb_to_linear(channels.rgb), channels.a); +} + +fn cbrt(value: f32) -> f32 { + return sign(value) * pow(abs(value), 1. / 3.); +} + +fn average_rgb_channels(color: vec4) -> f32 { + return (color.r + color.g + color.b) / 3.; +} + +fn minimum_rgb_channels(color: vec4) -> f32 { + return min(min(color.r, color.g), color.b); +} + +fn maximum_rgb_channels(color: vec4) -> f32 { + return max(max(color.r, color.g), color.b); +} + +fn luminance_rec_709(color: vec4) -> f32 { + return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; +} + +fn lightness_oklab(color: vec4) -> f32 { + let long = 0.41222147 * color.r + 0.53633254 * color.g + 0.05144599 * color.b; + let medium = 0.2119035 * color.r + 0.6806995 * color.g + 0.10739696 * color.b; + let short = 0.08830246 * color.r + 0.28171884 * color.g + 0.6299787 * color.b; + return 0.21045426 * cbrt(long) + 0.7936178 * cbrt(medium) - 0.004072047 * cbrt(short); +} + +fn apply_gamma_exponent(color: vec4, exponent: f32) -> vec4 { + let inverse = 1. / max(exponent, 0.0001); + return vec4(pow(color.rgb, vec3(inverse)), color.a); +} + +fn pull_toward_luminosity(channels: vec3, luminosity: f32, scale: f32) -> vec3 { + return luminosity + (channels - luminosity) * scale; +} + +fn set_luminosity(rgb: vec3, luma: f32, luminosity: f32) -> vec3 { + let shift = luminosity - luma; + var channels = rgb + shift; + + let low = min(min(channels.r, channels.g), channels.b); + if low < 0. { + channels = pull_toward_luminosity(channels, luminosity, luminosity / (luminosity - low)); + } + let high = max(max(channels.r, channels.g), channels.b); + if high > 1. { + channels = pull_toward_luminosity(channels, luminosity, (1. - luminosity) / (high - luminosity)); + } + + return clamp(channels, vec3(0.), vec3(1.)); +} + +fn luma_rec_601_rounded(channels: vec3) -> f32 { + return 0.3 * channels.r + 0.59 * channels.g + 0.11 * channels.b; +} + +fn channel_range(channels: vec3) -> f32 { + return max(max(channels.r, channels.g), channels.b) - min(min(channels.r, channels.g), channels.b); +} + +fn set_saturation(channels: vec3, saturation: f32) -> vec3 { + let low = min(min(channels.r, channels.g), channels.b); + let range = channel_range(channels); + if range <= 0. { + return vec3(0.); + } + + let scale = saturation / range; + return (channels - low) * scale; +} diff --git a/node-graph/nodes/raster/src/wgsl/selective_color.wgsl b/node-graph/nodes/raster/src/wgsl/selective_color.wgsl new file mode 100644 index 00000000000..249f1dc5cad --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/selective_color.wgsl @@ -0,0 +1,91 @@ +const RELATIVE: u32 = 0u; + +const REDS: u32 = 0u; +const YELLOWS: u32 = 1u; +const GREENS: u32 = 2u; +const CYANS: u32 = 3u; +const BLUES: u32 = 4u; +const MAGENTAS: u32 = 5u; +const WHITES: u32 = 6u; +const NEUTRALS: u32 = 7u; +const BLACKS: u32 = 8u; + +fn pixel_color_range(choice: u32, rgb: vec3, max_channel: f32, min_channel: f32) -> bool { + switch choice { + case REDS: { + return max_channel == rgb.r; + } + case YELLOWS: { + return min_channel == rgb.b; + } + case GREENS: { + return max_channel == rgb.g; + } + case CYANS: { + return min_channel == rgb.r; + } + case BLUES: { + return max_channel == rgb.b; + } + case MAGENTAS: { + return min_channel == rgb.g; + } + case WHITES: { + return all(rgb > vec3(0.5)); + } + case BLACKS: { + return all(rgb < vec3(0.5)); + } + default: { + return true; + } + } +} + +fn color_parameter_group_scale_factor(choice: u32, max_channel: f32, med_channel: f32, min_channel: f32) -> f32 { + switch choice { + case REDS, GREENS, BLUES: { + return max_channel - med_channel; + } + case CYANS, MAGENTAS, YELLOWS: { + return med_channel - min_channel; + } + case WHITES: { + return min_channel * 2. - 1.; + } + case NEUTRALS: { + return 1. - (abs(max_channel - 0.5) + abs(min_channel - 0.5)); + } + default: { + return 1. - max_channel * 2.; + } + } +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let channels = to_gamma_srgb_channels(color); + let rgb = channels.rgb; + + let max_channel = max(max(rgb.r, rgb.g), rgb.b); + let min_channel = min(min(rgb.r, rgb.g), rgb.b); + let med_channel = rgb.r + rgb.g + rgb.b - min_channel - max_channel; + + let slope = select(vec3(-1.), rgb - 1., parameters.mode == RELATIVE); + + var groups = parameters.groups; + var sum = vec3(0.); + for (var index = 0u; index < 9u; index++) { + let group = groups[index]; + let cmyk = vec4(group.cmyk[0], group.cmyk[1], group.cmyk[2], group.cmyk[3]); + if all(cmyk == vec4(0.)) || !pixel_color_range(group.color_parameter_group, rgb, max_channel, min_channel) { + continue; + } + + let inks = vec3(group.inks[0], group.inks[1], group.inks[2]); + let scale_factor = color_parameter_group_scale_factor(group.color_parameter_group, max_channel, med_channel, min_channel); + sum += clamp(inks * slope, -rgb, -rgb + 1.) * scale_factor; + } + + return from_gamma_srgb_channels(vec4(clamp(sum + rgb, vec3(0.), vec3(1.)), channels.a)); +} diff --git a/node-graph/nodes/raster/src/wgsl/vibrance.wgsl b/node-graph/nodes/raster/src/wgsl/vibrance.wgsl new file mode 100644 index 00000000000..6c00b97d33b --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/vibrance.wgsl @@ -0,0 +1,49 @@ +const VIBRANCE_PROTECTION_LOSS: f32 = 0.4857; + +fn scale_about_maximum(channel: vec3, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> vec3 { + return clamp(brightness_factor * (maximum + chroma_factor * (channel - maximum)), vec3(0.), vec3(1.)); +} + +fn vibrance_factors(rgb: vec3, amount: f32) -> vec2 { + let maximum = max(max(rgb.r, rgb.g), rgb.b); + let minimum = min(min(rgb.r, rgb.g), rgb.b); + if maximum <= 0. { + return vec2(1., 1.); + } + let ratio = minimum / maximum; + let saturation = 1. - ratio; + let q = ratio * saturation; + let toe = 1. - min(16. * maximum, 1.); + let rolloff = 1. - toe * toe; + let brightness = (2. * q - q * q) * (1. - maximum) * rolloff; + + if amount < 0. { + let reduction = -amount; + let chroma_factor = (1. - reduction / 4.) * (1. - reduction * (1. - rolloff * saturation * (1. + saturation) / 2.)); + return vec2(chroma_factor, 1. - reduction * brightness); + } + + let protection = skin_tone_window(hexagon_hue_degrees(rgb)) * (1. - saturation * saturation); + let protected_amount = amount * (1. - protection * (1. - amount)); + let boost = (5. / 6.) * (1. - VIBRANCE_PROTECTION_LOSS * protection) * protected_amount * ratio * (1. - minimum) * rolloff; + return vec2(1. / (1. - boost), 1. + protected_amount * brightness / 4.); +} + +fn skin_tone_window(hue: f32) -> f32 { + if hue < 45. { + return min((45. - hue) / 15., 1.); + } else if hue >= 300. { + return (hue - 300.) / 60.; + } + return 0.; +} + +fn map_color(color: vec4) -> vec4 { + let parameters = params(); + let maximum = max(max(color.r, color.g), color.b); + let factors = vibrance_factors(color.rgb, parameters.vibrance); + let after_vibrance = scale_about_maximum(color.rgb, maximum, factors.x, factors.y); + + let gray = 0.288040 * after_vibrance.r + 0.711874 * after_vibrance.g + 0.000086 * after_vibrance.b; + return vec4(clamp(gray + (after_vibrance - gray) * parameters.saturation_scale, vec3(0.), vec3(1.)), color.a); +} From 73aeeee4d68d1814b741b1acb91462baeb762d0c Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 26 Oct 2026 11:52:45 +0000 Subject: [PATCH 02/14] Run Dehaze and Image Color Palette on GPU rasters --- .../messages/portfolio/document_migration.rs | 6 +- .../libraries/wgpu-executor/src/pipeline.rs | 5 + node-graph/nodes/raster/src/dehaze.rs | 178 +++++++++++++++++- .../nodes/raster/src/image_color_palette.rs | 29 ++- node-graph/nodes/raster/src/raster_kernel.rs | 23 +++ .../wgsl/dehaze_coefficients_horizontal.wgsl | 11 ++ .../nodes/raster/src/wgsl/dehaze_erode.wgsl | 15 ++ .../src/wgsl/dehaze_guide_horizontal.wgsl | 14 ++ .../src/wgsl/dehaze_guide_vertical.wgsl | 16 ++ .../nodes/raster/src/wgsl/dehaze_recover.wgsl | 18 ++ 10 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 node-graph/nodes/raster/src/wgsl/dehaze_coefficients_horizontal.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/dehaze_erode.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/dehaze_guide_horizontal.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/dehaze_guide_vertical.wgsl create mode 100644 node-graph/nodes/raster/src/wgsl/dehaze_recover.wgsl diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 0a9781f8042..716284c2c92 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -3548,11 +3548,13 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], graphene_std::raster_nodes::std_nodes::extend_image_to_bounds::IDENTIFIER, graphene_std::raster_nodes::filter::median_filter::IDENTIFIER, graphene_std::raster_nodes::filter::blur::IDENTIFIER, + graphene_std::raster_nodes::image_color_palette::image_color_palette::IDENTIFIER, + graphene_std::raster_nodes::dehaze::dehaze::IDENTIFIER, ] }; - if raster_kernel_nodes.iter().any(|identifier| reference == DefinitionIdentifier::ProtoNode(identifier.clone())) && node.inputs.last() != Some(&raster_kernel_pipeline) { + if raster_kernel_nodes.iter().any(|identifier| reference == DefinitionIdentifier::ProtoNode(identifier.clone())) && !node.inputs.contains(&raster_kernel_pipeline) { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - let value_inputs = node_template.inputs.len() - 1; + let value_inputs = node_template.inputs.iter().position(|input| *input == raster_kernel_pipeline)?; let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; for (index, input) in old_inputs.into_iter().take(value_inputs).enumerate() { diff --git a/node-graph/libraries/wgpu-executor/src/pipeline.rs b/node-graph/libraries/wgpu-executor/src/pipeline.rs index 329903cd511..0b74b78c2a8 100644 --- a/node-graph/libraries/wgpu-executor/src/pipeline.rs +++ b/node-graph/libraries/wgpu-executor/src/pipeline.rs @@ -33,6 +33,11 @@ impl PipelineCache { Ok(pipeline.run(executor, args)) } + /// The executor the cache was initialized with, or `None` when no GPU is available. + pub fn executor(&self) -> Option<&WgpuExecutor> { + self.executor.get() + } + /// Runs the pipeline, or returns `None` when it was never initialized because no GPU is available. pub fn try_run(&self, args: &P::Args<'_>) -> Option { self.executor.get()?; diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 5ec82cc4e3f..eac7b4cb2e6 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -1,22 +1,39 @@ +use crate::gpu_params; +use crate::raster_kernel::{RasterKernel, Wgsl, raster_kernel_pipeline, run_raster_kernel}; +use core_types::NodeError; +use core_types::color::Color; use core_types::context::Ctx; use core_types::list::Item; +use core_types::ops::ConvertAsync; +use core_types::transform::Footprint; +use glam::{DAffine2, UVec2}; use image::{DynamicImage, GenericImage, GenericImageView, GrayImage, ImageBuffer, Luma, Rgba, RgbaImage}; use ndarray::{Array2, ArrayBase, Dim, OwnedRepr}; use raster_types::Image; -use raster_types::{CPU, Raster}; +use raster_types::{CPU, GPU, Raster}; use std::cmp::{max, min}; +use wgpu_executor::WgpuPipelineCache; +use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; -#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize)] -fn dehaze( +#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize, placeholder = pending_dehaze)] +async fn dehaze( _: impl Ctx, - image_frame: Item>, + #[implementations(Raster, Raster)] image_frame: Item, #[unit("%")] #[range] #[hard(0..100)] strength: Item, -) -> Item> { + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, NodeError> { let strength = *strength.element(); + T::dehaze(image_frame, strength, pipeline.element()).await +} + +fn pending_dehaze(image_frame: &Item, _strength: &Item, _pipeline: &Item) -> Item { + image_frame.clone() +} +fn dehaze_cpu(image_frame: Item>, strength: f64) -> Item> { let (image, attributes) = image_frame.into_parts(); let (width, height) = (image.width, image.height); @@ -36,6 +53,157 @@ fn dehaze( Item::from_parts(Raster::new_cpu(dehazed_image), attributes) } +/// A raster the Dehaze node processes, on the CPU or the GPU. +pub trait DehazeRaster: Sized { + fn dehaze(image: Item, strength: f64, pipeline: &WgpuPipelineCache) -> impl Future, NodeError>> + Send; +} + +impl DehazeRaster for Raster { + async fn dehaze(image: Item, strength: f64, _: &WgpuPipelineCache) -> Result, NodeError> { + Ok(dehaze_cpu(image, strength)) + } +} + +fn dehaze_input(raster: &Raster) -> RasterKernelInput<'_> { + RasterKernelInput { + raster, + output_to_texel: DAffine2::IDENTITY, + } +} + +impl DehazeRaster for Raster { + /// Runs the CPU's algorithm in float without its 8-bit quantization, downloading the dark channel to choose the atmospheric light exactly as the CPU does. + /// The guided filter's intermediates stay in [`wgpu_executor::RASTER_FORMAT`] since its variances lose their precision at half-float. + async fn dehaze(image: Item, strength: f64, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + let executor = pipeline.executor().cloned().ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster"))?; + let (raster, attributes) = image.into_parts(); + let size = UVec2::new(raster.texture.width(), raster.texture.height()); + + let radius = (PATCH_SIZE / 2) as i32; + let eroded = run_raster_kernel(&DehazeErode { radius, vertical: false }, vec![dehaze_input(&raster)], size, pipeline)?; + let dark_channel = run_raster_kernel(&DehazeErode { radius, vertical: true }, vec![dehaze_input(&eroded)], size, pipeline)?; + let atmospheric_light = estimate_atmospheric_light_gpu(&dark_channel.clone().convert(Footprint::DEFAULT, executor).await); + + let radius = RADIUS as i32; + let guide = DehazeGuideHorizontal { + radius, + strength: (strength / 100.) as f32, + }; + let guide = run_raster_kernel(&guide, vec![dehaze_input(&dark_channel)], size, pipeline)?; + let coefficients = DehazeGuideVertical { radius, epsilon: EPSILON as f32 }; + let coefficients = run_raster_kernel(&coefficients, vec![dehaze_input(&guide)], size, pipeline)?; + let coefficients = run_raster_kernel(&DehazeCoefficientsHorizontal { radius }, vec![dehaze_input(&coefficients)], size, pipeline)?; + let recover = DehazeRecover { + radius, + minimum_transmission: TX, + atmospheric_light, + }; + let dehazed = run_raster_kernel(&recover, vec![dehaze_input(&coefficients), dehaze_input(&raster)], size, pipeline)?; + + Ok(Item::from_parts(dehazed, attributes)) + } +} + +/// The average color of the pixels with the brightest dark channel, chosen like [`estimate_atmospheric_light`], from each pixel's color with its dark channel as alpha. +fn estimate_atmospheric_light_gpu(dark_channel: &Raster) -> Color { + let pixels = &dark_channel.data; + let num_pixels = ((TOP_PERCENT / 100.) * pixels.len() as f64).ceil() as usize; + + let mut order: Vec = (0..pixels.len()).collect(); + order.select_nth_unstable_by(num_pixels - 1, |&a, &b| pixels[b].a().total_cmp(&pixels[a].a()).then(a.cmp(&b))); + + let mut atm_sum = [0., 0., 0.]; + for &index in &order[..num_pixels] { + let pixel = pixels[index]; + atm_sum[0] += pixel.r() as f64; + atm_sum[1] += pixel.g() as f64; + atm_sum[2] += pixel.b() as f64; + } + + let num_pixels = num_pixels as f64; + Color::from_rgbaf32_unchecked((atm_sum[0] / num_pixels) as f32, (atm_sum[1] / num_pixels) as f32, (atm_sum[2] / num_pixels) as f32, 1.) +} + +gpu_params! { + /// One axis of the dark channel's erosion, the horizontal pass eroding each pixel's darkest channel and the vertical pass the horizontal pass's alpha, + /// both writing the pixel's color clamped to 0 to 1 with the eroded value as alpha. + #[derive(Clone)] + struct DehazeErode { + radius: i32, + vertical: bool, + } +} + +impl RasterKernel for DehazeErode { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/dehaze_erode.wgsl")) + } +} + +gpu_params! { + /// Horizontal means of the guidance luma, the transmission, the luma squared, and the luma times the transmission. + #[derive(Clone)] + struct DehazeGuideHorizontal { + radius: i32, + strength: f32, + } +} + +impl RasterKernel for DehazeGuideHorizontal { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/dehaze_guide_horizontal.wgsl")) + } +} + +gpu_params! { + /// Completes the guided filter's means vertically into its linear coefficients. + #[derive(Clone)] + struct DehazeGuideVertical { + radius: i32, + epsilon: f32, + } +} + +impl RasterKernel for DehazeGuideVertical { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/dehaze_guide_vertical.wgsl")) + } +} + +gpu_params! { + #[derive(Clone)] + struct DehazeCoefficientsHorizontal { + radius: i32, + } +} + +impl RasterKernel for DehazeCoefficientsHorizontal { + const INPUTS: usize = 1; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/dehaze_coefficients_horizontal.wgsl")) + } +} + +gpu_params! { + /// Completes the coefficients' means vertically into the refined transmission, recovering the image from its haze. + #[derive(Clone)] + struct DehazeRecover { + radius: i32, + minimum_transmission: f32, + atmospheric_light: Color, + } +} + +impl RasterKernel for DehazeRecover { + const INPUTS: usize = 2; + fn wgsl() -> Wgsl { + Wgsl::function(include_str!("wgsl/dehaze_recover.wgsl")) + } +} + // There is no real point in modifying these values because they do not change the final result all that much. // The authors of the paper recommended using these values to get a reasonable balance of performance and quality. // TODO: The patch size and guided filter radius are whole source pixels, so the effect's scale follows the image's resolution. diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index 30b4e271bff..38efd621e72 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -1,10 +1,13 @@ +use crate::raster_kernel::{ReadPixels, raster_kernel_pipeline}; use color::{ColorSpace, Oklab}; +use core_types::NodeError; use core_types::color::Color; use core_types::context::Ctx; use core_types::list::{Item, List}; -use raster_types::{CPU, Raster}; +use raster_types::{CPU, GPU, Image, Raster}; use std::collections::BinaryHeap; use std::ops::{AddAssign, SubAssign}; +use wgpu_executor::WgpuPipelineCache; /// Histogram resolution along each OkLab axis. const CELLS_PER_AXIS: usize = 32; @@ -14,21 +17,31 @@ const TABLE_SIDE: usize = CELLS_PER_AXIS + 1; const OKLAB_GAMUT_RANGES: [(f32, f32); 3] = [(0., 1.), (-0.24, 0.28), (-0.32, 0.2)]; /// Finds the colors that best represent the distinct color ranges in an image, ignoring transparent areas. -#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), memoize, footprint_invariant)] -fn image_color_palette( +#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), memoize, footprint_invariant, placeholder = pending_palette)] +async fn image_color_palette( _: impl Ctx, /// The image to find colors in. - image: Item>, + #[implementations(Raster, Raster)] + image: Item, /// How many colors to find. Raising it adds a color at the end and may adjust one existing color, leaving the rest as they were. #[default(4)] #[hard(1..)] count: Item, -) -> List { - let count = *count.element() as usize; + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result, NodeError> { + let image = image.into_element().read_pixels(pipeline.element()).await?; + Ok(palette_colors(image.data(), *count.element() as usize)) +} + +fn pending_palette(_image: &Item, _count: &Item, _pipeline: &Item) -> List { + List::new() +} +/// The colors that best represent the distinct color ranges in `image`, ignoring transparent areas. +fn palette_colors(image: &Image, count: usize) -> List { // O(n) binning of the image's n pixels into an OkLab histogram, weighted by alpha so transparent pixels are ignored let mut cumulative = vec![Moments::default(); TABLE_SIDE.pow(3)]; - for pixel in image.element().data.iter() { + for pixel in image.data.iter() { let weight = (pixel.a().clamp(0., 1.) * f32::from(u16::MAX)).round() as i64; if weight == 0 { continue; @@ -235,7 +248,7 @@ mod test { data, }); - let palette = image_color_palette((), Item::new_from_element(image), Item::new_from_element(count)); + let palette = palette_colors(image.data(), count as usize); palette.iter_element_values().copied().collect() } diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs index d9ce7897e74..441f3e816b4 100644 --- a/node-graph/nodes/raster/src/raster_kernel.rs +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -1,6 +1,8 @@ use core_types::ATTR_TRANSFORM; use core_types::color::Color; use core_types::list::Item; +use core_types::ops::ConvertAsync; +use core_types::transform::Footprint; use core_types::{Ctx, NodeError}; use glam::{DAffine2, DMat2, DVec2, UVec2}; use raster_types::{CPU, GPU, Raster}; @@ -564,3 +566,24 @@ pub fn union_frame(inputs: &[(UVec2, DAffine2)]) -> Option { let transform = DAffine2::from_mat2_translation(axes * DMat2::from_diagonal(cells * step), axes * start); Some(KernelFrame::new(cells.as_uvec2(), transform, inputs)) } + +/// A raster whose pixels can be read on the CPU, downloading them when they're on the GPU. +pub trait ReadPixels: Sized { + fn read_pixels(self, pipeline: &WgpuPipelineCache) -> impl Future, NodeError>> + Send; +} + +impl ReadPixels for Raster { + async fn read_pixels(self, _: &WgpuPipelineCache) -> Result, NodeError> { + Ok(self) + } +} + +impl ReadPixels for Raster { + fn read_pixels(self, pipeline: &WgpuPipelineCache) -> impl Future, NodeError>> + Send { + let executor = pipeline.executor().cloned(); + async move { + let executor = executor.ok_or_else(|| NodeError::new("A GPU is required to read a GPU raster"))?; + Ok(self.convert(Footprint::DEFAULT, executor).await) + } + } +} diff --git a/node-graph/nodes/raster/src/wgsl/dehaze_coefficients_horizontal.wgsl b/node-graph/nodes/raster/src/wgsl/dehaze_coefficients_horizontal.wgsl new file mode 100644 index 00000000000..bd92446ff90 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/dehaze_coefficients_horizontal.wgsl @@ -0,0 +1,11 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let first = max(coord.x - parameters.radius, 0); + let last = min(coord.x + parameters.radius, input_0_size().x - 1); + + var sum = vec4(0.); + for (var x = first; x <= last; x++) { + sum += input_0_load(vec2(x, coord.y)); + } + return sum / f32(last - first + 1); +} diff --git a/node-graph/nodes/raster/src/wgsl/dehaze_erode.wgsl b/node-graph/nodes/raster/src/wgsl/dehaze_erode.wgsl new file mode 100644 index 00000000000..0cf6aec06e3 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/dehaze_erode.wgsl @@ -0,0 +1,15 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let axis = select(vec2(1, 0), vec2(0, 1), parameters.vertical); + let position = select(coord.x, coord.y, parameters.vertical); + let size = select(input_0_size().x, input_0_size().y, parameters.vertical); + let first = max(position - parameters.radius, 0) - position; + let last = min(position + parameters.radius, size - 1) - position; + + var dark = 1.; + for (var offset = first; offset <= last; offset++) { + let texel = input_0_load(coord + axis * offset); + dark = min(dark, select(minimum_rgb_channels(clamp(texel, vec4(0.), vec4(1.))), texel.a, parameters.vertical)); + } + return vec4(clamp(input_0_load(coord).rgb, vec3(0.), vec3(1.)), dark); +} diff --git a/node-graph/nodes/raster/src/wgsl/dehaze_guide_horizontal.wgsl b/node-graph/nodes/raster/src/wgsl/dehaze_guide_horizontal.wgsl new file mode 100644 index 00000000000..7e6d0af066b --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/dehaze_guide_horizontal.wgsl @@ -0,0 +1,14 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let first = max(coord.x - parameters.radius, 0); + let last = min(coord.x + parameters.radius, input_0_size().x - 1); + + var sum = vec4(0.); + for (var x = first; x <= last; x++) { + let texel = input_0_load(vec2(x, coord.y)); + let guidance = luminance_rec_709(texel); + let transmission = 1. - parameters.strength * texel.a; + sum += vec4(guidance, transmission, guidance * guidance, guidance * transmission); + } + return sum / f32(last - first + 1); +} diff --git a/node-graph/nodes/raster/src/wgsl/dehaze_guide_vertical.wgsl b/node-graph/nodes/raster/src/wgsl/dehaze_guide_vertical.wgsl new file mode 100644 index 00000000000..f7f471e204a --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/dehaze_guide_vertical.wgsl @@ -0,0 +1,16 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let first = max(coord.y - parameters.radius, 0); + let last = min(coord.y + parameters.radius, input_0_size().y - 1); + + var sum = vec4(0.); + for (var y = first; y <= last; y++) { + sum += input_0_load(vec2(coord.x, y)); + } + let mean = sum / f32(last - first + 1); + + let variance = mean.z - mean.x * mean.x; + let covariance = mean.w - mean.x * mean.y; + let a = covariance / (variance + parameters.epsilon); + return vec4(a, mean.y - a * mean.x, 0., 0.); +} diff --git a/node-graph/nodes/raster/src/wgsl/dehaze_recover.wgsl b/node-graph/nodes/raster/src/wgsl/dehaze_recover.wgsl new file mode 100644 index 00000000000..426f70e9ef6 --- /dev/null +++ b/node-graph/nodes/raster/src/wgsl/dehaze_recover.wgsl @@ -0,0 +1,18 @@ +fn kernel(coord: vec2) -> vec4 { + let parameters = params(); + let first = max(coord.y - parameters.radius, 0); + let last = min(coord.y + parameters.radius, input_0_size().y - 1); + + var sum = vec2(0.); + for (var y = first; y <= last; y++) { + sum += input_0_load(vec2(coord.x, y)).xy; + } + let coefficients = sum / f32(last - first + 1); + + let color = input_1_load(coord); + let hazy = clamp(color.rgb, vec3(0.), vec3(1.)); + let refined = coefficients.x * luminance_rec_709(vec4(hazy, 1.)) + coefficients.y; + let transmission = max(clamp(refined, 0., 1.), parameters.minimum_transmission); + let light = parameters.atmospheric_light.rgb; + return vec4(clamp((hazy - light) / transmission + light, vec3(0.), vec3(1.)), color.a); +} From 49ecb3e6954ff8a6668fe9942f92edd62d7efd6a Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 26 Oct 2026 16:08:05 +0000 Subject: [PATCH 03/14] Give GPU rasters an empty default --- .../data_panel/data_panel_message_handler.rs | 10 +++- node-graph/graph-craft/src/document/value.rs | 4 +- .../raster-types/src/raster_types.rs | 20 ++++--- .../libraries/rendering/src/renderer.rs | 8 +-- .../shader_runtime/raster_kernel_runtime.rs | 27 ++++++++-- .../wgpu-executor/src/texture_conversion.rs | 54 ++++++++----------- node-graph/nodes/raster/src/dehaze.rs | 10 ++-- node-graph/nodes/raster/src/filter.rs | 14 +++-- node-graph/nodes/raster/src/raster_kernel.rs | 27 ++++++---- node-graph/nodes/raster/src/std_nodes.rs | 17 +++++- 10 files changed, 122 insertions(+), 69 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 56385570746..c6df966d1ad 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -18,7 +18,7 @@ use graphene_std::raster::{ AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; -use graphene_std::raster_types::{CPU, GPU, Raster}; +use graphene_std::raster_types::{CPU, GPU, Raster, Storage}; use graphene_std::text::TextAlign; use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; @@ -944,9 +944,15 @@ impl TableItemLayout for Raster { "Raster" } fn identifier(&self) -> String { - format!("Raster ({} x {})", self.data().width(), self.data().height()) + let size = self.size(); + format!("Raster ({} x {})", size.x, size.y) } fn value_page(&self, _data: &mut LayoutData) -> Vec { + if self.is_empty() { + let widgets = vec![TextLabel::new("Image has no area").widget_instance()]; + return vec![LayoutGroup::row(widgets)]; + } + let widgets = vec![TextLabel::new("This raster data is a texture on the GPU. It currently cannot be displayed here.").widget_instance()]; vec![LayoutGroup::row(widgets)] } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 6205d307ad5..289acbdc241 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -13,7 +13,7 @@ pub use glam::{DAffine2, DVec2, IVec2, UVec2}; use graphene_animation::AnimationCurve; use graphene_application_io::resource::ResourceHash; use graphene_application_io::resource::ResourceId; -use graphic_types::raster_types::{CPU, Image, Raster}; +use graphic_types::raster_types::{CPU, GPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; use graphic_types::vector_types::vector::style::{Gradient, GradientRamp}; @@ -36,6 +36,7 @@ macro_rules! for_each_item_type_default { $action!(Vector); $action!(f64); $action!(Raster); + $action!(Raster); $action!(Graphic); $action!(Color); $action!(Gradient); @@ -52,6 +53,7 @@ macro_rules! for_each_list_type_default { $action!(Graphic); $action!(Artboard); $action!(Raster); + $action!(Raster); $action!(Vector); $action!(String); $action!(Color); diff --git a/node-graph/libraries/raster-types/src/raster_types.rs b/node-graph/libraries/raster-types/src/raster_types.rs index bf5bbc5295c..dd9480641e9 100644 --- a/node-graph/libraries/raster-types/src/raster_types.rs +++ b/node-graph/libraries/raster-types/src/raster_types.rs @@ -285,6 +285,7 @@ pub use gpu::{Texture, TextureWeakRef}; mod gpu { use super::*; use crate::raster_types::__private::Sealed; + use glam::UVec2; use std::sync::Arc; #[derive(Clone, Debug, PartialEq, Eq, Hash, DynAny)] @@ -349,9 +350,9 @@ mod gpu { } } - #[derive(Clone, Debug, PartialEq, Hash)] + #[derive(Clone, Debug, Default, PartialEq, Hash)] pub struct GPU { - pub texture: Texture, + texture: Option, } impl core_types::CacheHash for GPU { @@ -365,17 +366,22 @@ mod gpu { impl Storage for Raster { fn is_empty(&self) -> bool { - self.texture.width() == 0 || self.texture.height() == 0 + let size = self.size(); + size.x == 0 || size.y == 0 } } impl Raster { pub fn new_gpu(texture: impl Into) -> Self { - Self::new(GPU { texture: texture.into() }) + Self::new(GPU { texture: Some(texture.into()) }) } - pub fn data(&self) -> &wgpu::Texture { - &self.texture + pub fn texture(&self) -> Option<&Texture> { + self.texture.as_ref() + } + + pub fn size(&self) -> UVec2 { + self.texture.as_ref().map_or(UVec2::ZERO, |texture| UVec2::new(texture.width(), texture.height())) } } } @@ -385,7 +391,7 @@ mod gpu { use super::*; use crate::raster_types::__private::Sealed; - #[derive(Clone, Debug, PartialEq, Hash, core_types::CacheHash)] + #[derive(Clone, Debug, Default, PartialEq, Hash, core_types::CacheHash)] pub struct GPU; impl Sealed for Raster {} diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 737521e01ed..91370e6748e 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2570,7 +2570,7 @@ impl Render for List> { /// Draws one item of GPU raster content into the Vello scene. fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut VelloSceneBuilder, transform: DAffine2, render_params: &RenderParams) { - let Some(raster) = item.element() else { return }; + let Some(texture) = item.element().and_then(|raster| raster.texture()) else { return }; let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); @@ -2610,12 +2610,12 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V return; } - let width = raster.data().width(); - let height = raster.data().height(); + let width = texture.width(); + let height = texture.height(); let sampler = peniko::ImageSampler::new().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_texture(raster.texture.clone(), sampler, kurbo::Affine::new(image_transform.to_cols_array())); + scene.draw_texture(texture.clone(), sampler, kurbo::Affine::new(image_transform.to_cols_array())); if layer { scene.pop_layer() diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs index 8cc834b3e07..2ba825be71b 100644 --- a/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs @@ -69,6 +69,7 @@ impl RasterKernelLookup<'_> { /// With `filtered`, it may also call `input_i_sample(texel)`, sampling the input bilinearly at texel coordinates, whose texel centers lie at +0.5, clamped to its edge, /// which requires every input to be in a filterable format such as `Rgba16Float` rather than [`crate::RASTER_FORMAT`]. /// It declares the texture of each of the `lookups` itself, the `k`th at binding `2 + inputs + k`. +/// An empty input reads as transparent with a size of zero, and an empty `output_size` gives the empty raster without running the kernel. /// Every run of the same `kernel` must have the same number of inputs, the same kinds of lookups, and the same `filtered`. pub struct RasterKernelArgs<'a> { pub kernel: TypeId, @@ -94,6 +95,7 @@ pub struct RasterKernelRuntime { pipelines: Mutex>, lookups: Mutex>, sampler: wgpu::Sampler, + empty_input: wgpu::Texture, } impl RasterKernelRuntime { @@ -147,14 +149,33 @@ impl WgpuPipeline for RasterKernelRuntime { min_filter: wgpu::FilterMode::Linear, ..Default::default() }); + let empty_input = executor.context().device.create_texture(&wgpu::TextureDescriptor { + label: Some("raster_kernel_empty_input"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); Self { pipelines: Mutex::default(), lookups: Mutex::default(), sampler, + empty_input, } } fn run(&self, executor: &WgpuExecutor, args: &RasterKernelArgs<'_>) -> Raster { + if args.output_size.x == 0 || args.output_size.y == 0 { + return Raster::default(); + } + let device = &executor.context().device; let param_slots = slots(args.params.len()); @@ -177,7 +198,7 @@ impl WgpuPipeline for RasterKernelRuntime { let input_views = args .inputs .iter() - .map(|input| input.raster.texture.create_view(&wgpu::TextureViewDescriptor::default())) + .map(|input| input.raster.texture().map_or(&self.empty_input, |texture| texture).create_view(&wgpu::TextureViewDescriptor::default())) .collect::>(); let lookup_views = args .lookups @@ -328,9 +349,9 @@ fn frame_data(args: &RasterKernelArgs<'_>) -> Vec { let mut data = vec![args.output_size.x as f32, args.output_size.y as f32, 0., 0.]; for input in &args.inputs { let affine = input.output_to_texel; - let texture = &input.raster.texture; + let size = input.raster.size(); data.extend([affine.matrix2.x_axis.x, affine.matrix2.x_axis.y, affine.matrix2.y_axis.x, affine.matrix2.y_axis.y].map(|value| value as f32)); - data.extend([affine.translation.x as f32, affine.translation.y as f32, texture.width() as f32, texture.height() as f32]); + data.extend([affine.translation.x as f32, affine.translation.y as f32, size.x as f32, size.y as f32]); } data } diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index b05f723a689..e8c60ca135e 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -5,31 +5,17 @@ use core_types::list::{Item, List}; use core_types::ops::{Convert, ConvertAsync}; use core_types::transform::Footprint; use raster_types::Image; -use raster_types::{CPU, GPU, Raster, Texture}; +use raster_types::{CPU, GPU, Raster, Storage}; use wgpu::Extent3d; /// Uploads CPU image data to a GPU texture -fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raster) -> Texture { - let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), crate::RASTER_FORMAT); - - if image.width == 0 || image.height == 0 { - queue.write_texture( - texture.as_image_copy(), - bytemuck::bytes_of(&Color::TRANSPARENT), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(16), - rows_per_image: Some(1), - }, - Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - return texture; +fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raster) -> Raster { + if image.is_empty() { + return Raster::default(); } + let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), crate::RASTER_FORMAT); + queue.write_texture( texture.as_image_copy(), bytemuck::cast_slice(image.data.as_slice()), @@ -44,7 +30,7 @@ fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raste depth_or_array_layers: 1, }, ); - texture + Raster::new_gpu(texture) } /// Converts a Raster texture to Raster by downloading the underlying texture data. @@ -63,8 +49,8 @@ struct RasterGpuToRasterCpuConverter { _source: raster_types::Texture, } impl RasterGpuToRasterCpuConverter { - fn new(executor: &WgpuExecutor, encoder: &mut wgpu::CommandEncoder, data_gpu: Raster) -> Self { - let texture = data_gpu.data(); + fn new(executor: &WgpuExecutor, encoder: &mut wgpu::CommandEncoder, data_gpu: Raster) -> Option { + let texture = data_gpu.texture()?; let width = texture.width(); let height = texture.height(); let format = texture.format(); @@ -103,7 +89,7 @@ impl RasterGpuToRasterCpuConverter { }, ); - Self { + Some(Self { buffer, width, height, @@ -111,8 +97,8 @@ impl RasterGpuToRasterCpuConverter { unpadded_bytes_per_row, padded_bytes_per_row, // Keep source texture alive - _source: data_gpu.texture.clone(), - } + _source: texture.clone(), + }) } async fn convert(self, device: &wgpu::Device) -> Result, wgpu::BufferAsyncError> { @@ -232,9 +218,7 @@ impl Convert>, WgpuExecutor> for List> { .into_iter() .map(|row| { let (image, attributes) = row.into_parts(); - let texture = upload_to_texture(&executor, &queue, &image); - - Item::from_parts(Raster::new_gpu(texture), attributes) + Item::from_parts(upload_to_texture(&executor, &queue, &image), attributes) }) .collect(); @@ -247,10 +231,10 @@ impl Convert>, WgpuExecutor> for List> { impl Convert, WgpuExecutor> for Raster { fn convert(self, _: Footprint, executor: WgpuExecutor) -> Raster { let queue = executor.context().queue.lock(); - let texture = upload_to_texture(&executor, &queue, &self); + let raster = upload_to_texture(&executor, &queue, &self); queue.submit([]); - Raster::new_gpu(texture) + raster } } @@ -284,7 +268,12 @@ impl ConvertAsync>, WgpuExecutor> for List> { let mut map_futures = Vec::new(); for converter in converters { - map_futures.push(converter.convert(device)); + map_futures.push(async move { + match converter { + Some(converter) => converter.convert(device).await, + None => Ok(Raster::default()), + } + }); } let map_results = futures::future::try_join_all(map_futures) @@ -317,6 +306,7 @@ impl ConvertAsync, WgpuExecutor> for Raster { queue.submit([encoder.finish()]); + let Some(converter) = converter else { return Raster::default() }; converter.convert(device).await.expect("Failed to download texture data") } } diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index eac7b4cb2e6..286762acae0 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -6,11 +6,11 @@ use core_types::context::Ctx; use core_types::list::Item; use core_types::ops::ConvertAsync; use core_types::transform::Footprint; -use glam::{DAffine2, UVec2}; +use glam::DAffine2; use image::{DynamicImage, GenericImage, GenericImageView, GrayImage, ImageBuffer, Luma, Rgba, RgbaImage}; use ndarray::{Array2, ArrayBase, Dim, OwnedRepr}; use raster_types::Image; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, GPU, Raster, Storage}; use std::cmp::{max, min}; use wgpu_executor::WgpuPipelineCache; use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; @@ -75,9 +75,13 @@ impl DehazeRaster for Raster { /// Runs the CPU's algorithm in float without its 8-bit quantization, downloading the dark channel to choose the atmospheric light exactly as the CPU does. /// The guided filter's intermediates stay in [`wgpu_executor::RASTER_FORMAT`] since its variances lose their precision at half-float. async fn dehaze(image: Item, strength: f64, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + if image.element().is_empty() { + return Ok(image); + } + let executor = pipeline.executor().cloned().ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster"))?; let (raster, attributes) = image.into_parts(); - let size = UVec2::new(raster.texture.width(), raster.texture.height()); + let size = raster.size(); let radius = (PATCH_SIZE / 2) as i32; let eroded = run_raster_kernel(&DehazeErode { radius, vertical: false }, vec![dehaze_input(&raster)], size, pipeline)?; diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index b1fdc637bf4..b1e5798199b 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -6,11 +6,11 @@ use core_types::color::{Alpha, Color, Pixel, RGB}; use core_types::context::Ctx; use core_types::list::Item; use core_types::{Interrupt, NodeError}; -use glam::{DAffine2, UVec2}; +use glam::DAffine2; use num_enum::IntoPrimitive; use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, GPU, Raster, Storage}; use wgpu_executor::WgpuPipelineCache; use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; @@ -174,7 +174,11 @@ impl MedianFilterRaster for Raster { impl MedianFilterRaster for Raster { fn median_filter(image: Self, radius: u32, pipeline: &WgpuPipelineCache) -> Result { - let size = UVec2::new(image.texture.width(), image.texture.height()); + if image.is_empty() { + return Ok(image); + } + + let size = image.size(); let kernel = MedianWindow { radius: radius.min(size.max_element()), }; @@ -389,12 +393,12 @@ impl BlurRaster for Raster { /// Each pass sums premultiplied colors into a [`FILTERABLE_FORMAT`] intermediate, and a kernel reaching past [`MAX_BLUR_REACH`] runs on the image halved /// until it fits, weighing each halved texel by how much of the image it covers, then is scaled back up bilinearly, approximating the CPU's blur. fn blur(image: Item, radius: f64, box_blur: bool, gamma: bool, pipeline: &WgpuPipelineCache) -> Result, NodeError> { - if radius < 0.1 { + if radius < 0.1 || image.element().is_empty() { return Ok(image); } let (raster, attributes) = image.into_parts(); let space = if gamma { BlurSpace::Gamma } else { BlurSpace::Linear }; - let size = UVec2::new(raster.texture.width(), raster.texture.height()); + let size = raster.size(); let full_size = size.as_ivec2().to_array(); let mut levels = 0; diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs index 441f3e816b4..599c66314f3 100644 --- a/node-graph/nodes/raster/src/raster_kernel.rs +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -5,7 +5,7 @@ use core_types::ops::ConvertAsync; use core_types::transform::Footprint; use core_types::{Ctx, NodeError}; use glam::{DAffine2, DMat2, DVec2, UVec2}; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, GPU, Raster, Storage}; use std::any::{TypeId, type_name}; use vector_types::Gradient; pub use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelLookup; @@ -455,7 +455,11 @@ impl Adjust for Raster { impl Adjust for Raster { fn adjust(self, kernel: &K, pipeline: &WgpuPipelineCache) -> Result { - let size = UVec2::new(self.texture.width(), self.texture.height()); + if self.is_empty() { + return Ok(self); + } + + let size = self.size(); let input = RasterKernelInput { raster: &self, output_to_texel: DAffine2::IDENTITY, @@ -521,30 +525,29 @@ impl KernelFrame { /// A raster item's size in pixels and its `ATTR_TRANSFORM`, as [`union_frame`] and [`KernelFrame::new`] take each input. pub fn raster_placement(raster: &Item>) -> (UVec2, DAffine2) { - let size = UVec2::new(raster.element().texture.width(), raster.element().texture.height()); - (size, raster.attribute_cloned_or_default(ATTR_TRANSFORM)) + (raster.element().size(), raster.attribute_cloned_or_default(ATTR_TRANSFORM)) } /// The frame covering the union of `inputs`, each a raster's size and transform, on the finest of their pixel grids. /// /// The grid's axes follow the first input's, with the smallest pixel step along each that any input has, anchored at the first input's origin. -/// Each side is capped at [`MAX_FRAME_SIDE`] pixels. `None` without inputs or with an empty or non-invertible one. +/// Each side is capped at [`MAX_FRAME_SIDE`] pixels. Empty inputs are ignored. `None` without a non-empty input or with a non-invertible one. pub fn union_frame(inputs: &[(UVec2, DAffine2)]) -> Option { - let placeable = |(size, transform): &(UVec2, DAffine2)| size.x > 0 && size.y > 0 && transform.matrix2.determinant() != 0.; - let first = inputs.first()?.1; - if !inputs.iter().all(placeable) { + let placed = inputs.iter().copied().filter(|(size, _)| size.x > 0 && size.y > 0).collect::>(); + let first = placed.first()?.1; + if placed.iter().any(|(_, transform)| transform.matrix2.determinant() == 0.) { return None; } let axes = DMat2::from_cols(first.matrix2.x_axis.normalize(), first.matrix2.y_axis.normalize()); let to_axes = axes.inverse(); - let corners = inputs + let corners = placed .iter() .flat_map(|(_, transform)| [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|corner| to_axes * transform.transform_point2(corner))); let (min, max) = corners.fold((DVec2::INFINITY, DVec2::NEG_INFINITY), |(min, max), corner| (min.min(corner), max.max(corner))); - let step = inputs + let step = placed .iter() .map(|(size, transform)| { let to_texels = DMat2::from_diagonal(size.as_dvec2()) * transform.matrix2.inverse() * axes; @@ -582,6 +585,10 @@ impl ReadPixels for Raster { fn read_pixels(self, pipeline: &WgpuPipelineCache) -> impl Future, NodeError>> + Send { let executor = pipeline.executor().cloned(); async move { + if self.is_empty() { + return Ok(Raster::default()); + } + let executor = executor.ok_or_else(|| NodeError::new("A GPU is required to read a GPU raster"))?; Ok(self.convert(Footprint::DEFAULT, executor).await) } diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 2dccc5a67cb..17b64f5c8dc 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -17,7 +17,7 @@ use graphene_resource::Resource; use rand::prelude::*; use rand_chacha::ChaCha8Rng; use raster_types::{Bitmap, BitmapMut}; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, GPU, Raster, Storage}; use raster_types::{Image, ImageError}; use std::fmt::Debug; use std::hash::Hash; @@ -263,7 +263,12 @@ impl PlacedRaster for Raster { impl PlacedRaster for Raster { /// Each channel is placed by its transform into their [`union_frame`], which takes the red channel's other attributes, and gives its luminance weighted by its alpha. + /// An empty channel gives zero, or full opacity for the alpha channel. fn channels_to_image(channels: [Item; 4], pipeline: &WgpuPipelineCache) -> Result, NodeError> { + if channels.iter().all(|channel| channel.element().is_empty()) { + return Ok(Item::default()); + } + let frame = union_frame(&channels.each_ref().map(raster_placement)).ok_or_else(|| NodeError::new("Every channel must be placed by an invertible transform"))?; let raster = run_raster_kernel(&CombineChannels {}, frame.kernel_inputs(&channels.each_ref().map(Item::element)), frame.size, pipeline)?; @@ -275,6 +280,10 @@ impl PlacedRaster for Raster { /// The stencil is sampled bilinearly where each image pixel's center lies, extending its edge pixels beyond it. fn mask(image: Item, stencil: Item, pipeline: &WgpuPipelineCache) -> Result, NodeError> { + if image.element().is_empty() || stencil.element().is_empty() { + return Ok(image); + } + let (image_size, image_transform) = raster_placement(&image); let (stencil_size, stencil_transform) = raster_placement(&stencil); if image_transform.matrix2.determinant() == 0. || stencil_transform.matrix2.determinant() == 0. { @@ -294,6 +303,10 @@ impl PlacedRaster for Raster { } fn extend_image_to_bounds(image: Item, bounds: DAffine2, pipeline: &WgpuPipelineCache) -> Result, (Item, NodeError)> { + if image.element().is_empty() { + return Ok(image); + } + let (image_size, image_transform) = raster_placement(&image); let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); @@ -338,7 +351,7 @@ impl RasterKernel for CombineChannels { let red = input_0_at(coord); let green = input_1_at(coord); let blue = input_2_at(coord); - let alpha = input_3_at(coord); + let alpha = select(input_3_at(coord), vec4(1.), any(input_3_size() == vec2(0))); return vec4(luminance_rec_709(red) * red.a, luminance_rec_709(green) * green.a, luminance_rec_709(blue) * blue.a, luminance_rec_709(alpha) * alpha.a); "#, ) From 7d6718dc6785a52cd6869015da8d77770ec6b4fb Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 26 Oct 2026 16:08:17 +0000 Subject: [PATCH 04/14] Embed GPU rasters in SVG output by downloading them first --- .../interpreted-executor/src/node_registry.rs | 2 + node-graph/interpreted-executor/src/util.rs | 30 ++++-- .../libraries/rendering/src/renderer.rs | 101 +++++++++++++++++- .../nodes/gstd/src/platform_application_io.rs | 7 ++ node-graph/nodes/gstd/src/render_node.rs | 100 ++++++++++------- 5 files changed, 191 insertions(+), 49 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 2171cf4abf2..af6f2701551 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -92,6 +92,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), + async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item>, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), // ========== // MEMO NODES @@ -144,6 +145,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), ]; // The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged. diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 420ba4a0848..ab26f3b17d9 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -1,6 +1,6 @@ use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::TaggedValue; -use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeInput, NodeNetwork}; +use graph_craft::document::{DocumentNode, DocumentNodeImplementation, DocumentNodeMetadata, NodeInput, NodeNetwork}; use graph_craft::generic; use graph_craft::{concrete, item}; use graphene_std::Context; @@ -27,7 +27,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc, + /// Downloaded images of GPU raster textures, which SVG output embeds in place of the textures it can't reference. GPU rasters without one are not drawn. + #[cache_hash(skip)] + pub gpu_raster_downloads: Arc>>, } impl RenderParams { @@ -775,6 +778,9 @@ pub trait Render: BoundingBox { false } + /// Recursively collects the textures of the GPU rasters this content draws, including those painting its appearances, so SVG output can download them first. + fn collect_gpu_textures(&self, _textures: &mut Vec) {} + fn new_ids_from_hash(&mut self, _reference: Option) {} } @@ -1042,6 +1048,20 @@ fn add_graphic_item_outline_targets(item: ItemRef<'_, Graphic>, outlines: &mut V outlines.extend(new_outlines); } +/// Collects the textures of the GPU rasters painting an appearance's coverages. +fn collect_appearance_gpu_textures(appearance: Option<&Appearance>, textures: &mut Vec) { + for paint in appearance.into_iter().flat_map(Appearance::covers_with_paints).filter_map(|(_, paint)| paint) { + paint.collect_gpu_textures(textures); + } +} + +/// Collects the textures of the GPU rasters painting the appearances of a list's items. +fn collect_list_appearance_gpu_textures(list: &List, textures: &mut Vec) { + for appearance in list.iter_attribute_values::(ATTR_APPEARANCE).into_iter().flatten() { + collect_appearance_gpu_textures(Some(appearance), textures); + } +} + impl Render for Graphic { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { match self { @@ -1049,14 +1069,14 @@ impl Render for Graphic { Graphic::Graphic(item) => render_graphic_item_svg(ItemRef::Item(item), false, &mut None, render, render_params), Graphic::Vector(item) => render_vector_item_svg(ItemRef::Item(item), false, &mut None, render, render_params), Graphic::RasterCPU(item) => render_raster_cpu_item_svg(ItemRef::Item(item), render, render_params), - Graphic::RasterGPU(_) => (), + Graphic::RasterGPU(item) => render_raster_gpu_item_svg(ItemRef::Item(item), render, render_params), Graphic::Color(item) => render_color_item_svg(ItemRef::Item(item), render, render_params), Graphic::Gradient(item) => render_gradient_item_svg(ItemRef::Item(item), render, render_params), Graphic::Text(item) => render_text_item_svg(ItemRef::Item(item), render, render_params), Graphic::GraphicList(list) => list.render_svg(render, render_params), Graphic::VectorList(list) => list.render_svg(render, render_params), Graphic::RasterCPUList(list) => list.render_svg(render, render_params), - Graphic::RasterGPUList(_) => (), + Graphic::RasterGPUList(list) => list.render_svg(render, render_params), Graphic::ColorList(list) => list.render_svg(render, render_params), Graphic::GradientList(list) => list.render_svg(render, render_params), Graphic::TextList(list) => list.render_svg(render, render_params), @@ -1092,6 +1112,31 @@ impl Render for Graphic { } } + fn collect_gpu_textures(&self, textures: &mut Vec) { + match self { + Graphic::Graphic(item) => { + item.element().collect_gpu_textures(textures); + collect_appearance_gpu_textures(item.attribute(ATTR_APPEARANCE), textures); + } + Graphic::Vector(item) => collect_appearance_gpu_textures(item.attribute(ATTR_APPEARANCE), textures), + Graphic::RasterGPU(item) => collect_raster_gpu_texture(item.element(), textures), + Graphic::GraphicList(list) => list.collect_gpu_textures(textures), + Graphic::VectorList(list) => list.collect_gpu_textures(textures), + Graphic::RasterGPUList(list) => list.collect_gpu_textures(textures), + Graphic::None(_) + | Graphic::NoneList(_) + | Graphic::RasterCPU(_) + | Graphic::Color(_) + | Graphic::Gradient(_) + | Graphic::Text(_) + | Graphic::RasterCPUList(_) + | Graphic::ColorList(_) + | Graphic::GradientList(_) + | Graphic::TextList(_) + | Graphic::StrokeList(_) => {} + } + } + fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option, inherited_appearance: Option<&Appearance>) { if let Some(element_id) = element_id { // The footprint always lands; the transform (and for vectors the source layer) comes from the first item when one exists @@ -1388,6 +1433,12 @@ impl Render for List { } } + fn collect_gpu_textures(&self, textures: &mut Vec) { + for artboard in self.iter_element_values() { + artboard.as_graphic_list().collect_gpu_textures(textures); + } + } + fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index); @@ -1439,6 +1490,13 @@ impl Render for List { self.iter_element_values().any(|element| element.contains_artboard()) } + fn collect_gpu_textures(&self, textures: &mut Vec) { + for element in self.iter_element_values() { + element.collect_gpu_textures(textures); + } + collect_list_appearance_gpu_textures(self, textures); + } + fn new_ids_from_hash(&mut self, _reference: Option) { let (elements, layers) = self.element_and_attribute_slices_mut::(ATTR_EDITOR_LAYER_PATH); for (element, layer) in elements.iter_mut().zip(layers.iter()) { @@ -2242,6 +2300,10 @@ impl Render for List { } } + fn collect_gpu_textures(&self, textures: &mut Vec) { + collect_list_appearance_gpu_textures(self, textures); + } + fn new_ids_from_hash(&mut self, reference: Option) { for index in 0..self.len() { edit_list_row_geometry(self, index, |vector, paths| vector.vector_new_ids_from_hash(paths, reference.map(|id| id.0).unwrap_or_default())); @@ -2357,7 +2419,19 @@ fn canvas_image_id(buffer_id: u64, halvings: u32) -> u64 { /// Emits one item of CPU raster content as SVG, as a canvas placeholder or an embedded base64 image. fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRender, render_params: &RenderParams) { let Some(image) = item.element() else { return }; + render_raster_image_svg(item, image, render, render_params); +} + +/// Emits one item of GPU raster content as SVG from its texture's downloaded image, exactly as CPU raster content. +fn render_raster_gpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(image) = item.element().and_then(Raster::texture).and_then(|texture| render_params.gpu_raster_downloads.get(texture)) else { + return; + }; + render_raster_image_svg(item, image, render, render_params); +} +/// Emits a raster image as SVG, placed and styled by the attributes of the item it's drawn for. +fn render_raster_image_svg(item: ItemRef<'_, T>, image: &Raster, render: &mut SvgRender, render_params: &RenderParams) { let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); @@ -2547,8 +2621,10 @@ impl Render for List> { } impl Render for List> { - fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) { - log::warn!("tried to render texture as an svg"); + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..self.len() { + render_raster_gpu_item_svg(ItemRef::ListItem(self, index), render, render_params); + } } fn render_to_vello(&self, scene: &mut VelloSceneBuilder, transform: DAffine2, render_params: &RenderParams) { @@ -2566,6 +2642,21 @@ impl Render for List> { add_unit_square_click_target(self.attribute_cloned_or_default(ATTR_TRANSFORM, index), click_targets); } } + + fn collect_gpu_textures(&self, textures: &mut Vec) { + for raster in self.iter_element_values() { + collect_raster_gpu_texture(raster, textures); + } + } +} + +/// Collects a GPU raster's texture unless it's empty or already collected. +fn collect_raster_gpu_texture(raster: &Raster, textures: &mut Vec) { + if let Some(texture) = raster.texture() + && !textures.contains(texture) + { + textures.push(texture.clone()); + } } /// Draws one item of GPU raster content into the Vello scene. diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index d2a85f7188e..8511c3fee8c 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -301,6 +301,13 @@ fn wgpu_executor(_: impl Ctx, #[scope(application_io::IDENTIFIER)] application_i application_io.element().gpu_executor().cloned().map(Item::new_from_element).ok_or(::wgpu_executor::NoGpuError) } +/// The GPU executor if there is one, for nodes that also work without it. +#[node_macro::node(category(""), inject_scope)] +fn try_wgpu_executor(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: Item>) -> Item> { + let executor = editor_api.into_element().application_io.as_ref().and_then(|application_io| application_io.gpu_executor()).cloned(); + Item::new_from_element(executor) +} + #[cfg(test)] mod tests { use super::*; diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 5b8bc811593..daf60e64875 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -1,26 +1,25 @@ use core_types::list::{Item, List}; +use core_types::ops::ConvertAsync; use core_types::transform::{Footprint, Transform}; use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs}; use core_types::{Color, Context, Ctx, ExtractFootprint, Interrupt, NodeError, OwnedContextImpl, WasmNotSend}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphene_application_io::{ExportFormat, RenderConfig}; -use graphic_types::raster_types::{CPU, Raster}; +use graphic_types::raster_types::{CPU, GPU, Raster, Texture}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::vello_scene_builder::VelloSceneBuilder; use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput}; +use std::collections::HashMap; use std::sync::Arc; use vector_types::Gradient; use wgpu_executor::WgpuExecutor; -#[derive(Clone, dyn_any::DynAny)] -pub enum RenderIntermediateType { - /// The content itself, drawn into a Vello scene at each render since the scene depends on the render's size and placement - Vello(Arc), - Svg(Arc), -} #[derive(Clone, dyn_any::DynAny)] pub struct RenderIntermediate { - pub(crate) ty: RenderIntermediateType, + /// The content itself, drawn at each render since a Vello scene depends on the render's size and placement and SVG output on the GPU raster downloads + pub(crate) content: Arc, + /// Images of the content's GPU raster textures, downloaded only for SVG output, which can't reference textures + pub(crate) gpu_raster_downloads: Arc>>, pub(crate) metadata: RenderMetadata, } @@ -38,36 +37,53 @@ fn render_intermediate( )] data: impl Node, Output = T>, ) -> Result, Interrupt> { - let render_params = ctx - .vararg(0) - .expect("Did not find var args") - .downcast_ref::() - .expect("Downcasting render params yielded invalid type"); - let ctx = OwnedContextImpl::from(ctx.clone()).into_context(); let data = data.eval(ctx)?; let footprint = Footprint::default(); let mut metadata = RenderMetadata::default(); data.collect_metadata(&mut metadata, footprint, None, None); - let intermediate = match &render_params.render_output_type { - RenderOutputTypeRequest::Vello => RenderIntermediate { - ty: RenderIntermediateType::Vello(Arc::new(data)), - metadata, - }, - RenderOutputTypeRequest::Svg => { - let mut render = SvgRender::new(); + let intermediate = RenderIntermediate { + content: Arc::new(data), + gpu_raster_downloads: Arc::default(), + metadata, + }; - data.render_svg(&mut render, render_params); + Ok(Item::new_from_element(intermediate)) +} - RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new(render.into())), - metadata, - } - } +/// Downloads the content's GPU raster textures for SVG output to embed, leaving them undrawn without a GPU executor. +#[node_macro::node(category(""), placeholder = pending_gpu_raster_downloads)] +async fn download_gpu_rasters( + ctx: impl Ctx + ExtractVarArgs, + #[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Item>, + mut data: Item, +) -> Item { + let render_output_type = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type") + .render_output_type; + let (RenderOutputTypeRequest::Svg, Some(executor)) = (render_output_type, executor.into_element()) else { + return data; }; - Ok(Item::new_from_element(intermediate)) + let mut textures = Vec::new(); + data.element().content.collect_gpu_textures(&mut textures); + if textures.is_empty() { + return data; + } + + let rasters: List> = textures.iter().map(|texture| Item::new_from_element(Raster::new_gpu(texture.clone()))).collect(); + let downloads = rasters.convert(Footprint::DEFAULT, executor).await; + + data.element_mut().gpu_raster_downloads = Arc::new(textures.into_iter().zip(downloads.into_iter().map(Item::into_element)).collect()); + data +} + +fn pending_gpu_raster_downloads(_executor: &Item>, data: &Item) -> Item { + data.clone() } #[node_macro::node(category(""))] @@ -82,18 +98,25 @@ fn render( .expect("Did not find var args") .downcast_ref::() .expect("Downcasting render params yielded invalid type"); - let mut render_params = render_params.clone(); - render_params.footprint = *footprint; - let RenderIntermediate { ty, mut metadata } = data.into_element(); + let RenderIntermediate { + content, + gpu_raster_downloads, + mut metadata, + } = data.into_element(); metadata.apply_transform(footprint.transform); - let data = match (render_params.render_output_type, ty) { - (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(data)) => { + let data = match render_params.render_output_type { + RenderOutputTypeRequest::Svg => { + let render_params = RenderParams { + gpu_raster_downloads, + ..render_params.clone() + }; let logical_transform = glam::DAffine2::from_scale(glam::DVec2::splat(1.0 / render_params.scale)) * footprint.transform; let logical_resolution = footprint.resolution.as_dvec2() / render_params.scale; - let mut render = SvgRender::from(data.as_ref()); + let mut render = SvgRender::new(); + content.render_svg(&mut render, &render_params); render.wrap_with_transform(logical_transform, Some(logical_resolution)); let output = SvgRenderOutput::from(render); @@ -104,10 +127,14 @@ fn render( image_data: output.image_data.into_iter().collect(), } } - (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { + RenderOutputTypeRequest::Vello => { + let render_params = RenderParams { + footprint: *footprint, + ..render_params.clone() + }; let executor = executor.eval(Context::default())?.into_element(); - let mut draw = |scene: &mut VelloSceneBuilder| data.render_to_vello(scene, Default::default(), &render_params); + let mut draw = |scene: &mut VelloSceneBuilder| content.render_to_vello(scene, Default::default(), &render_params); let texture = match executor.render_vello(footprint.resolution, footprint.transform, &mut draw) { Ok(texture) => texture, // Leave the output transparent, at a size the GPU can hold, rather than taking down the editor @@ -119,7 +146,6 @@ fn render( }; RenderOutputType::Texture(texture) } - _ => unreachable!("Render node did not receive its requested data type"), }; Ok(Item::new_from_element(RenderOutput { data, metadata })) From 2e57be5aa1462c491b7a7ac33c8604c5cc815668 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 00:28:45 +0000 Subject: [PATCH 05/14] Let list and render nodes carry GPU rasters --- node-graph/libraries/graphic-types/src/graphic.rs | 10 ++++++++++ node-graph/nodes/graphic/src/graphic.rs | 1 + node-graph/nodes/gstd/src/render_node.rs | 1 + 3 files changed, 12 insertions(+) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 2b2ea8b9b74..59de794e8e7 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -360,6 +360,16 @@ impl TryFromGraphic for Raster { } } +impl TryFromGraphic for Raster { + fn try_from_graphic(graphic: Graphic) -> Option> { + match graphic { + Graphic::RasterGPU(item) => Some(List::new_from_item(item)), + Graphic::RasterGPUList(list) => Some(list), + _ => None, + } + } +} + impl TryFromGraphic for Color { fn try_from_graphic(graphic: Graphic) -> Option> { match graphic { diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index a4d063ac7ee..aaca3056959 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -62,6 +62,7 @@ fn item_at_index( List, List, List>, + List>, List, List, List, diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index daf60e64875..227409b893e 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -31,6 +31,7 @@ fn render_intermediate( Context -> List, Context -> List, Context -> List>, + Context -> List>, Context -> List, Context -> List, Context -> List, From 181c8e461cb2639f2791a4653fda08083fa4b37d Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 26 Oct 2026 22:36:08 +0000 Subject: [PATCH 06/14] Fall back to half-float rasters where the GPU can't render full floats --- Cargo.lock | 1 + node-graph/libraries/wgpu-executor/Cargo.toml | 1 + node-graph/libraries/wgpu-executor/src/lib.rs | 25 ++++++++++- .../wgpu-executor/src/texture_conversion.rs | 42 ++++++++++++++----- .../wgpu-executor/src/vello_render.rs | 2 +- .../nodes/brush/src/basic_brush/convert.rs | 4 +- .../nodes/brush/src/basic_brush/pipeline.rs | 2 +- .../nodes/brush/src/basic_brush/render.rs | 2 +- node-graph/nodes/raster/src/dehaze.rs | 2 +- node-graph/nodes/raster/src/raster_kernel.rs | 10 +++-- 10 files changed, 70 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ab655ab390..620d68db4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8737,6 +8737,7 @@ dependencies = [ "futures", "glam", "graphene-application-io", + "half", "log", "node-macro", "raster-types", diff --git a/node-graph/libraries/wgpu-executor/Cargo.toml b/node-graph/libraries/wgpu-executor/Cargo.toml index e45a852e468..983102b2696 100644 --- a/node-graph/libraries/wgpu-executor/Cargo.toml +++ b/node-graph/libraries/wgpu-executor/Cargo.toml @@ -33,3 +33,4 @@ web-sys = { workspace = true } vello_gpu = { workspace = true } bytemuck = { workspace = true } thiserror = { workspace = true } +half = { workspace = true } diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index c49f941067e..d59efb53a49 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -37,8 +37,10 @@ const TEXTURE_CACHE_SIZE: u64 = 1024 * 1024 * 1024; // 1GB #[cfg(target_family = "wasm")] const TEXTURE_CACHE_SIZE: u64 = 512 * 1024 * 1024; // 512MB -/// The format of every raster texture, holding linear-light straight-alpha values. +/// The preferred format of raster textures, holding linear-light straight-alpha values. pub const RASTER_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; +/// The raster texture format on adapters that can't render to [`RASTER_FORMAT`], or when `GRAPHITE_RASTER_FORMAT=f16` forces it. +pub const FALLBACK_RASTER_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; #[derive(dyn_any::DynAny, Clone)] pub struct WgpuExecutor { @@ -49,12 +51,18 @@ impl WgpuExecutor { pub fn context(&self) -> &WgpuContext { &self.inner.context } + + /// The format of every raster texture this executor creates, either [`RASTER_FORMAT`] or [`FALLBACK_RASTER_FORMAT`]. + pub fn raster_format(&self) -> wgpu::TextureFormat { + self.inner.raster_format + } } #[derive(dyn_any::DynAny)] pub struct WgpuExecutorInner { context: WgpuContext, texture_cache: std::sync::Mutex, + raster_format: wgpu::TextureFormat, vello_renderer: VelloRenderer, /// The canvas the node graph renders documents on. document_canvas: Mutex, @@ -182,6 +190,7 @@ impl WgpuExecutor { } pub fn with_context(context: WgpuContext) -> Option { + let raster_format = choose_raster_format(&context.adapter); let vello_renderer = VelloRenderer::new(&context.device); let texture_cache = TextureCache::new(TEXTURE_CACHE_SIZE); @@ -190,6 +199,7 @@ impl WgpuExecutor { inner: Arc::new(WgpuExecutorInner { context, texture_cache: texture_cache.into(), + raster_format, vello_renderer, document_canvas: Mutex::default(), }), @@ -200,3 +210,16 @@ impl WgpuExecutor { #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("No GPU is available")] pub struct NoGpuError; + +fn choose_raster_format(adapter: &wgpu::Adapter) -> wgpu::TextureFormat { + if std::env::var("GRAPHITE_RASTER_FORMAT").is_ok_and(|format| format == "f16") { + return FALLBACK_RASTER_FORMAT; + } + + if adapter.get_texture_format_features(RASTER_FORMAT).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) { + RASTER_FORMAT + } else { + log::warn!("The GPU can't render to {RASTER_FORMAT:?}, so rasters fall back to {FALLBACK_RASTER_FORMAT:?}"); + FALLBACK_RASTER_FORMAT + } +} diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index e8c60ca135e..6a322f71990 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -4,6 +4,7 @@ use core_types::color::SRGBA8; use core_types::list::{Item, List}; use core_types::ops::{Convert, ConvertAsync}; use core_types::transform::Footprint; +use half::f16; use raster_types::Image; use raster_types::{CPU, GPU, Raster, Storage}; use wgpu::Extent3d; @@ -14,14 +15,21 @@ fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raste return Raster::default(); } - let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), crate::RASTER_FORMAT); - + let format = executor.raster_format(); + let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), format); + let half_floats: Vec<[f16; 4]>; + let (bytes, bytes_per_pixel) = if format == crate::FALLBACK_RASTER_FORMAT { + half_floats = image.data.iter().map(|color| color.to_vec4().to_array().map(f16::from_f32)).collect(); + (bytemuck::cast_slice(half_floats.as_slice()), 8) + } else { + (bytemuck::cast_slice(image.data.as_slice()), 16) + }; queue.write_texture( texture.as_image_copy(), - bytemuck::cast_slice(image.data.as_slice()), + bytes, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(16 * image.width), + bytes_per_row: Some(bytes_per_pixel * image.width), rows_per_image: Some(image.height), }, Extent3d { @@ -37,7 +45,7 @@ fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raste /// /// Assumptions: /// - 2D texture, mip level 0 -/// - 4 bytes-per-pixel RGBA8, or `RASTER_FORMAT` +/// - 4 bytes-per-pixel RGBA8, or `RASTER_FORMAT` or `FALLBACK_RASTER_FORMAT` /// - Texture has COPY_SRC usage struct RasterGpuToRasterCpuConverter { buffer: Buffer, @@ -54,7 +62,11 @@ impl RasterGpuToRasterCpuConverter { let width = texture.width(); let height = texture.height(); let format = texture.format(); - let bytes_per_pixel = if format == crate::RASTER_FORMAT { 16 } else { 4 }; + let bytes_per_pixel = match format { + crate::RASTER_FORMAT => 16, + crate::FALLBACK_RASTER_FORMAT => 8, + _ => 4, + }; let unpadded_bytes_per_row = width * bytes_per_pixel; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; @@ -142,11 +154,19 @@ impl RasterGpuToRasterCpuConverter { for row in 0..self.height as usize { let start = row * row_stride; let row_slice = &view[start..start + row_bytes]; - if self.format == crate::RASTER_FORMAT { - cpu_data.extend(bytemuck::pod_collect_to_vec::(row_slice)); - } else { - for &[red, green, blue, alpha] in row_slice.as_chunks::<4>().0 { - cpu_data.push(SRGBA8::new(red, green, blue, alpha).into()); + match self.format { + crate::RASTER_FORMAT => cpu_data.extend(bytemuck::pod_collect_to_vec::(row_slice)), + crate::FALLBACK_RASTER_FORMAT => { + let pixels = bytemuck::pod_collect_to_vec::(row_slice); + cpu_data.extend(pixels.iter().map(|pixel| { + let [red, green, blue, alpha] = pixel.map(f16::to_f32); + Color::from_rgbaf32_unchecked(red, green, blue, alpha) + })); + } + _ => { + for &[red, green, blue, alpha] in row_slice.as_chunks::<4>().0 { + cpu_data.push(SRGBA8::new(red, green, blue, alpha).into()); + } } } } diff --git a/node-graph/libraries/wgpu-executor/src/vello_render.rs b/node-graph/libraries/wgpu-executor/src/vello_render.rs index 5ed3aff53dd..6b84c11bcc1 100644 --- a/node-graph/libraries/wgpu-executor/src/vello_render.rs +++ b/node-graph/libraries/wgpu-executor/src/vello_render.rs @@ -361,7 +361,7 @@ impl ImageTextures { if let SceneImage::Texture(source) = image { let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - let loads_linear_light = source.format().is_srgb() || source.format() == crate::RASTER_FORMAT; + let loads_linear_light = source.format().is_srgb() || matches!(source.format(), crate::RASTER_FORMAT | crate::FALLBACK_RASTER_FORMAT); alpha_conversion.premultiply(device, encoder, &source_view, loads_linear_light, &view); } diff --git a/node-graph/nodes/brush/src/basic_brush/convert.rs b/node-graph/nodes/brush/src/basic_brush/convert.rs index 2f3d5b072a0..91ff4f58160 100644 --- a/node-graph/nodes/brush/src/basic_brush/convert.rs +++ b/node-graph/nodes/brush/src/basic_brush/convert.rs @@ -4,7 +4,7 @@ pub(super) struct Convert { } impl Convert { - pub(super) fn new(device: &wgpu::Device) -> Self { + pub(super) fn new(device: &wgpu::Device, raster_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::include_wgsl!("convert.wgsl")); let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("basic_brush_convert_bind_group_layout"), @@ -37,7 +37,7 @@ impl Convert { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), - targets: &[Some(wgpu_executor::RASTER_FORMAT.into())], + targets: &[Some(raster_format.into())], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, diff --git a/node-graph/nodes/brush/src/basic_brush/pipeline.rs b/node-graph/nodes/brush/src/basic_brush/pipeline.rs index 21af38a0970..cb8d36b30ae 100644 --- a/node-graph/nodes/brush/src/basic_brush/pipeline.rs +++ b/node-graph/nodes/brush/src/basic_brush/pipeline.rs @@ -80,7 +80,7 @@ impl WgpuPipeline for BasicBrushPipeline { Self { scatter: Scatter::new(device), resolve: Resolve::new(device), - convert: Convert::new(device), + convert: Convert::new(device, executor.raster_format()), kernels: KernelCache::default(), } } diff --git a/node-graph/nodes/brush/src/basic_brush/render.rs b/node-graph/nodes/brush/src/basic_brush/render.rs index ed6cedec23f..7802fe95e10 100644 --- a/node-graph/nodes/brush/src/basic_brush/render.rs +++ b/node-graph/nodes/brush/src/basic_brush/render.rs @@ -183,7 +183,7 @@ pub(super) fn render(pipeline: &BasicBrushPipeline, executor: &WgpuExecutor, fra 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_with_format(crop.size, wgpu_executor::RASTER_FORMAT); + let output = executor.request_texture_with_format(crop.size, executor.raster_format()); let mut recorder = Recorder::new(pipeline, executor, ®ion); if let Some(updated) = &updated { diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 286762acae0..b3228a0273e 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -73,7 +73,7 @@ fn dehaze_input(raster: &Raster) -> RasterKernelInput<'_> { impl DehazeRaster for Raster { /// Runs the CPU's algorithm in float without its 8-bit quantization, downloading the dark channel to choose the atmospheric light exactly as the CPU does. - /// The guided filter's intermediates stay in [`wgpu_executor::RASTER_FORMAT`] since its variances lose their precision at half-float. + /// The guided filter's intermediates stay in the executor's raster format rather than `FILTERABLE_FORMAT` since its variances lose their precision at half-float. async fn dehaze(image: Item, strength: f64, pipeline: &WgpuPipelineCache) -> Result, NodeError> { if image.element().is_empty() { return Ok(image); diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs index 599c66314f3..d653831062c 100644 --- a/node-graph/nodes/raster/src/raster_kernel.rs +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -411,12 +411,16 @@ fn run( .ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster")) } +fn raster_format(pipeline: &WgpuPipelineCache) -> WgpuTextureFormat { + pipeline.executor().map_or(RASTER_FORMAT, WgpuExecutor::raster_format) +} + /// Runs `kernel` on its inputs, each placed by the affine from output pixel coordinates to its texel coordinates, into a raster of `output_size`. pub fn run_raster_kernel(kernel: &K, inputs: Vec>, output_size: UVec2, pipeline: &WgpuPipelineCache) -> Result, NodeError> { - run_raster_kernel_with_format(kernel, inputs, output_size, RASTER_FORMAT, pipeline) + run_raster_kernel_with_format(kernel, inputs, output_size, raster_format(pipeline), pipeline) } -/// Runs `kernel` like [`run_raster_kernel`] into a texture of `output_format` instead of [`RASTER_FORMAT`], such as a [`FILTERABLE_FORMAT`] intermediate a [`RasterKernel::FILTERED`] kernel can read. +/// Runs `kernel` like [`run_raster_kernel`] into a texture of `output_format` instead of the executor's raster format, such as a [`FILTERABLE_FORMAT`] intermediate a [`RasterKernel::FILTERED`] kernel can read. pub fn run_raster_kernel_with_format( kernel: &K, inputs: Vec>, @@ -464,7 +468,7 @@ impl Adjust for Raster { raster: &self, output_to_texel: DAffine2::IDENTITY, }; - run(kernel, map_color_module::, false, vec![input], size, RASTER_FORMAT, pipeline) + run(kernel, map_color_module::, false, vec![input], size, raster_format(pipeline), pipeline) } } From 6684bd279f9bd5ad06f653e4723c1ee2496ce034 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Fri, 30 Oct 2026 11:29:27 +0000 Subject: [PATCH 07/14] Mark the Dehaze node as footprint invariant --- node-graph/nodes/raster/src/dehaze.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index b3228a0273e..9353599e1da 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -15,7 +15,7 @@ use std::cmp::{max, min}; use wgpu_executor::WgpuPipelineCache; use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; -#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize, placeholder = pending_dehaze)] +#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize, footprint_invariant, placeholder = pending_dehaze)] async fn dehaze( _: impl Ctx, #[implementations(Raster, Raster)] image_frame: Item, From 1642bf38ce8d50680cef1297cbe308e67d4a258d Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 00:33:14 +0000 Subject: [PATCH 08/14] Report a raster kernel's GPU failures and oversized outputs rather than saying a GPU is required --- .../libraries/wgpu-executor/src/pipeline.rs | 6 ------ node-graph/nodes/raster/src/raster_kernel.rs | 15 +++++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/node-graph/libraries/wgpu-executor/src/pipeline.rs b/node-graph/libraries/wgpu-executor/src/pipeline.rs index 0b74b78c2a8..4acfe038f87 100644 --- a/node-graph/libraries/wgpu-executor/src/pipeline.rs +++ b/node-graph/libraries/wgpu-executor/src/pipeline.rs @@ -37,12 +37,6 @@ impl PipelineCache { pub fn executor(&self) -> Option<&WgpuExecutor> { self.executor.get() } - - /// Runs the pipeline, or returns `None` when it was never initialized because no GPU is available. - pub fn try_run(&self, args: &P::Args<'_>) -> Option { - self.executor.get()?; - self.run::

(args).ok() - } } impl std::fmt::Debug for PipelineCache { diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs index d653831062c..bd231e18b7e 100644 --- a/node-graph/nodes/raster/src/raster_kernel.rs +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -5,7 +5,7 @@ use core_types::ops::ConvertAsync; use core_types::transform::Footprint; use core_types::{Ctx, NodeError}; use glam::{DAffine2, DMat2, DVec2, UVec2}; -use raster_types::{CPU, GPU, Raster, Storage}; +use raster_types::{CPU, GPU, ImageError, Raster, Storage}; use std::any::{TypeId, type_name}; use vector_types::Gradient; pub use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelLookup; @@ -406,9 +406,16 @@ fn run( output_size, output_format, }; - pipeline - .try_run::(&args) - .ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster")) + let executor = pipeline.executor().ok_or_else(|| NodeError::new("A GPU is required to process a GPU raster"))?; + let limit = executor.context().device.limits().max_texture_dimension_2d; + if output_size.max_element() > limit { + return Err(ImageError::TooLarge { + width: output_size.x, + height: output_size.y, + } + .into()); + } + Ok(pipeline.run::(&args)?) } fn raster_format(pipeline: &WgpuPipelineCache) -> WgpuTextureFormat { From ba4b4812e7f82c2a767c2373a7e23ae197816302 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 07:48:17 +0000 Subject: [PATCH 09/14] Add an Eager node that uploads a raster so the nodes after it run on the GPU --- frontend/src/icons.ts | 2 ++ node-graph/nodes/raster/src/std_nodes.rs | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 7bdee773a07..74de4074c4a 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -377,6 +377,7 @@ import NodeTransform from "/../branding/assets/16x16-nodes/node-transform.svg"; import NodeTriangulate from "/../branding/assets/16x16-nodes/node-triangulate.svg"; import NodeUngroup from "/../branding/assets/16x16-nodes/node-ungroup.svg"; import NodeUnweld from "/../branding/assets/16x16-nodes/node-unweld.svg"; +import NodeUploadTexture from "/../branding/assets/16x16-nodes/node-upload-texture.svg"; import NodeVaryFillColors from "/../branding/assets/16x16-nodes/node-vary-fill-colors.svg"; import NodeVaryStrokeColors from "/../branding/assets/16x16-nodes/node-vary-stroke-colors.svg"; import NodeVec2ToNumbers from "/../branding/assets/16x16-nodes/node-vec2-to-numbers.svg"; @@ -659,6 +660,7 @@ export const ICON_16X16_NODES = { NodeTriangulate: { svg: NodeTriangulate, size: 16 }, NodeUngroup: { svg: NodeUngroup, size: 16 }, NodeUnweld: { svg: NodeUnweld, size: 16 }, + NodeUploadTexture: { svg: NodeUploadTexture, size: 16 }, NodeVaryFillColors: { svg: NodeVaryFillColors, size: 16 }, NodeVaryRotations: { svg: NodeVaryRotations, size: 16 }, NodeVaryScales: { svg: NodeVaryScales, size: 16 }, diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 17b64f5c8dc..b1359567b36 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -8,7 +8,8 @@ use core_types::color::{Alpha, AlphaMut, Channel, LinearChannel, Luminance, RGBM use core_types::context::{Ctx, ExtractFootprint}; use core_types::list::Item; use core_types::math::bbox::Bbox; -use core_types::transform::Transform; +use core_types::ops::Convert; +use core_types::transform::{Footprint, Transform}; use core_types::{Interrupt, NodeError}; use dyn_any::DynAny; use fastnoise_lite; @@ -417,6 +418,24 @@ impl RasterKernel for ExtendToBounds { } } +/// Uploads an image to the GPU, so the nodes that come next run on the GPU rather than the CPU. +#[node_macro::node(category("Raster"), icon("NodeUploadTexture"), memoize)] +fn eager( + _: impl Ctx, + /// The image to upload. + image: Item>, + #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item, +) -> Result>, Interrupt> { + let executor = pipeline.element().executor().cloned().ok_or_else(|| NodeError::new("A GPU is required to upload a raster"))?; + + let (image, attributes) = image.into_parts(); + if image.is_empty() { + return Ok(Item::from_parts(Raster::default(), attributes)); + } + + Ok(Item::from_parts(image.convert(Footprint::DEFAULT, executor), attributes)) +} + /// Displays an image from a file. /// /// Reads PNG, JPG, GIF, WEBP, TIFF, BMP, TGA, ICO, HDR, and EXR files. Light brighter than white, as HDR and EXR files can hold, is clipped to white. From ccc32d6104b91219406e44141e54e8e9bbf4e15f Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 08:08:42 +0000 Subject: [PATCH 10/14] Test that CPU rasters stay on the CPU without a GPU, Eager opts into it, and a footprint-dependent raster warns at Dehaze --- editor/src/node_graph_executor.rs | 68 +++++++++ editor/src/test_utils.rs | 27 ++++ node-graph/nodes/raster/src/adjustments.rs | 9 ++ node-graph/nodes/raster/src/dehaze.rs | 9 ++ node-graph/nodes/raster/src/filter.rs | 16 +++ .../nodes/raster/src/image_color_palette.rs | 9 ++ node-graph/nodes/raster/src/raster_kernel.rs | 12 ++ node-graph/nodes/raster/src/std_nodes.rs | 62 ++++++++ node-graph/preprocessor/Cargo.toml | 2 +- node-graph/preprocessor/src/lib.rs | 133 ++++++++++++++++++ 10 files changed, 346 insertions(+), 1 deletion(-) diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index f5619d29f7c..397271f619b 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -1236,3 +1236,71 @@ mod test { } } } + +#[cfg(test)] +mod raster_path_tests { + use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; + use crate::test_utils::test_prelude::*; + use graph_craft::ProtoNodeIdentifier; + use graph_craft::document::{DocumentNodeImplementation, NodeId}; + use graphene_std::list::Item; + use graphene_std::raster_nodes::adjustments::invert; + use graphene_std::raster_nodes::dehaze::dehaze; + use graphene_std::raster_nodes::filter::median_filter; + use graphene_std::raster_nodes::std_nodes::eager; + use graphene_std::raster_types::{CPU, Raster}; + + /// A document whose image feeds `nodes` in turn, ending in the export. + async fn image_through(editor: &mut EditorTestUtils, color: Color, nodes: &[ProtoNodeIdentifier]) { + editor.new_document().await; + editor.create_raster_image(Image::new(4, 4, color), None).await; + let image = editor + .active_document() + .network_interface + .document_network() + .nodes + .iter() + .find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)) + .map(|(id, _)| *id) + .expect("the pasted image should be an Image node"); + + let mut previous = OutputConnector::node(image, 0); + for (index, identifier) in nodes.iter().enumerate() { + let id: NodeId = editor.create_node_by_name_at(DefinitionIdentifier::ProtoNode(identifier.clone()), (index as i32 + 1) * 10, 0).await; + let input = InputConnector::node_at_index(id, 0); + editor.active_document_mut().network_interface.create_wire(&previous, &input, &[]); + previous = OutputConnector::node(id, 0); + } + editor.active_document_mut().network_interface.create_wire(&previous, &InputConnector::Export(0), &[]); + } + + #[tokio::test] + async fn an_image_reaches_a_filter_as_a_cpu_raster() { + let mut editor = EditorTestUtils::create(); + image_through(&mut editor, Color::WHITE, &[median_filter::IDENTIFIER]).await; + + let instrumented = editor.eval_graph_until_finished().await.expect("the image should render through Median"); + let _: Item> = instrumented + .grab_all_input::>>(&editor.runtime) + .next() + .expect("the image should arrive at Median as a CPU raster"); + } + + #[tokio::test] + async fn an_image_feeding_dehaze_renders() { + let mut editor = EditorTestUtils::create(); + image_through(&mut editor, Color::from_rgbaf32_unchecked(0.5, 0.6, 0.7, 1.), &[dehaze::IDENTIFIER]).await; + + // A compile error or a failed render fails this + editor.eval_graph_uninstrumented().await.expect("the image should render through Dehaze"); + } + + #[tokio::test] + async fn an_image_uploaded_by_eager_renders_through_a_kernel() { + let mut editor = EditorTestUtils::create(); + image_through(&mut editor, Color::WHITE, &[eager::IDENTIFIER, invert::IDENTIFIER]).await; + + // A compile error or a failed render fails this + editor.eval_graph_uninstrumented().await.expect("the image should render through Eager and Invert"); + } +} diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 8f48060f442..6269721b5e6 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -103,6 +103,33 @@ impl EditorTestUtils { Ok(instrumented) } + /// Compiles and runs the graph without the monitors of [`Instrumented`], which can't wrap the runtime handle that asynchronous nodes take. + pub async fn eval_graph_uninstrumented(&mut self) -> Result<(), String> { + let portfolio = &mut self.portfolio_message_handler_mut(); + let document_id = portfolio.active_document_id.unwrap(); + let (executor, documents) = (&mut portfolio.executor, &mut portfolio.documents); + let document = documents.get_mut(&document_id).unwrap(); + + if let Err(e) = executor.submit_node_graph_evaluation(document, document_id, UVec2::ONE, 1., Default::default(), Vec::new(), true, Default::default()) { + return Err(format!("submit_node_graph_evaluation failed\n\n{e}")); + } + self.finish_evaluation().await + } + + /// Waits for the submitted evaluation and processes its results, failing if the graph didn't render. + async fn finish_evaluation(&mut self) -> Result<(), String> { + self.runtime.run().await; + + let frontend_messages = self.dispatch(Message::Wake); + if frontend_messages.contains(&FrontendMessage::UpdateRenderStatus { status: RenderStatus::Error }) { + return Err("Graph should render".to_string()); + } + for message in frontend_messages { + message.check_node_graph_error(); + } + Ok(()) + } + /// Repeatedly calls [`Self::eval_graph_once`] until no more runs are triggered by deferred messages. pub async fn eval_graph_until_finished(&mut self) -> Result { const MAX_ITERATIONS: usize = 16; diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 1d1f1d8b0e7..22fb74cb718 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -3701,4 +3701,13 @@ mod tests { // A white filter alters nothing, so the pixel keeps its channels once the above-white red is clipped assert_close(run_photo_filter([300., 200., 100.], [255., 255., 255.], 100., true), [255., 200., 100.]); } + + #[test] + fn an_adjustment_runs_on_a_cpu_raster_without_a_gpu() { + let image = Item::new_from_element(Raster::new_cpu(Image::new(4, 4, Color::BLACK))); + + let inverted: Item> = invert((), image, Item::new_from_element(WgpuPipelineCache::default())).unwrap(); + + assert!(inverted.element().data.iter().all(|pixel| *pixel == Color::WHITE)); + } } diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 9353599e1da..a362af88471 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -455,4 +455,13 @@ mod tests { } } } + + #[test] + fn dehaze_runs_on_a_cpu_raster_without_a_gpu() { + let image = Item::new_from_element(Raster::new_cpu(Image::new(4, 4, Color::WHITE))); + + let dehazed = futures::executor::block_on(Raster::::dehaze(image, 50., &WgpuPipelineCache::default())).unwrap(); + + assert_eq!((dehazed.element().width, dehazed.element().height), (4, 4)); + } } diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index b1e5798199b..e10837ba1ff 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -587,3 +587,19 @@ fn median_quickselect(values: &mut [f32]) -> f32 { // Use total_cmp for safe NaN handling instead of partial_cmp().unwrap() *values.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1 } + +#[cfg(test)] +mod tests { + use super::*; + use raster_types::Image; + + #[test] + fn blur_runs_on_a_cpu_raster_without_a_gpu() { + let image = Item::new_from_element(Raster::new_cpu(Image::new(4, 4, Color::WHITE))); + let pipeline = Item::new_from_element(WgpuPipelineCache::default()); + + let blurred: Item> = blur((), image, Item::new_from_element(2.), Item::new_from_element(false), Item::new_from_element(false), pipeline).unwrap(); + + assert_eq!((blurred.element().width, blurred.element().height), (4, 4)); + } +} diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index 38efd621e72..8001cf76cee 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -338,4 +338,13 @@ mod test { let colors = palette(&[(Color::BLACK, 50), (Color::WHITE, 50)], 1); assert_colors_near(&colors, &[Color::from_rgbf32_unchecked(0.125, 0.125, 0.125)]); } + + #[test] + fn the_palette_reads_a_cpu_raster_without_a_gpu() { + let image = Raster::new_cpu(Image::new(4, 4, Color::WHITE)); + + let pixels = futures::executor::block_on(image.read_pixels(&WgpuPipelineCache::default())).unwrap(); + + assert_eq!(palette_colors(&pixels, 1).len(), 1); + } } diff --git a/node-graph/nodes/raster/src/raster_kernel.rs b/node-graph/nodes/raster/src/raster_kernel.rs index bd231e18b7e..4d390d5520d 100644 --- a/node-graph/nodes/raster/src/raster_kernel.rs +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -605,3 +605,15 @@ impl ReadPixels for Raster { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pipeline_node_needs_no_gpu() { + let pipeline = raster_kernel_pipeline((), &WgpuPipelineCache::default(), Item::new_from_element(None)); + + assert!(pipeline.element().executor().is_none()); + } +} diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index b1359567b36..4db4a18c8dd 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -677,3 +677,65 @@ fn map_color(iter: usize, max_iter: usize) -> Color { let v = iter as f32 / max_iter as f32; Color::from_rgbaf32_unchecked(v, v, v, 1.) } + +#[cfg(test)] +mod tests { + use super::*; + use core_types::ops::ConvertAsync; + use wgpu_executor::WgpuExecutor; + use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelRuntime; + + fn gpu_pipeline(executor: &WgpuExecutor) -> Item { + let pipeline = WgpuPipelineCache::default(); + executor.pipeline_init::(&pipeline); + Item::new_from_element(pipeline) + } + + #[test] + fn eager_reports_that_it_needs_a_gpu() { + let image = Item::new_from_element(Raster::new_cpu(Image::new(4, 4, Color::WHITE))); + + assert!(eager((), image, Item::new_from_element(WgpuPipelineCache::default())).is_err()); + } + + #[test] + fn eager_uploads_the_pixels_and_keeps_the_attributes() { + futures::executor::block_on(async { + let Some(executor) = WgpuExecutor::new().await else { return }; + + let transform = DAffine2::from_scale(DVec2::splat(3.)); + let image = Item::new_from_element(Raster::new_cpu(Image::new(4, 4, Color::WHITE))).with_attribute(ATTR_TRANSFORM, transform); + let uploaded = eager((), image, gpu_pipeline(&executor)).unwrap(); + + assert_eq!(uploaded.attribute_cloned_or_default::(ATTR_TRANSFORM), transform); + assert_eq!(uploaded.element().size(), UVec2::splat(4)); + let downloaded: Raster = uploaded.into_element().convert(Footprint::DEFAULT, executor).await; + assert!(downloaded.data.iter().all(|pixel| (pixel.r() - 1.).abs() < 0.001 && (pixel.a() - 1.).abs() < 0.001)); + }); + } + + #[test] + fn eager_keeps_an_empty_raster_empty() { + futures::executor::block_on(async { + let Some(executor) = WgpuExecutor::new().await else { return }; + + let uploaded = eager((), Item::new_from_element(Raster::new_cpu(Image::default())), gpu_pipeline(&executor)).unwrap(); + + assert_eq!(uploaded.element().size(), UVec2::ZERO); + }); + } + + #[test] + fn mandelbrot_renders_a_cpu_raster_without_a_gpu() { + let footprint = Footprint { + transform: DAffine2::from_scale(DVec2::splat(8.)), + resolution: UVec2::splat(8), + ..Default::default() + }; + let context = core_types::context::OwnedContextImpl::default().with_footprint(footprint).into_context(); + + let raster: Item> = mandelbrot(context).unwrap(); + + assert_eq!((raster.element().width, raster.element().height), (8, 8)); + } +} diff --git a/node-graph/preprocessor/Cargo.toml b/node-graph/preprocessor/Cargo.toml index e6670be6351..c4ecf7d105d 100644 --- a/node-graph/preprocessor/Cargo.toml +++ b/node-graph/preprocessor/Cargo.toml @@ -21,7 +21,7 @@ log = { workspace = true } # Workspace dependencies graphene-std = { workspace = true, features = ["gpu"] } graph-craft = { workspace = true } -interpreted-executor = { workspace = true } +interpreted-executor = { workspace = true, features = ["gpu"] } [dev-dependencies] # Workspace dependencies diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index f9336a14fd3..78859e8de1a 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -865,4 +865,137 @@ mod tests { assert!(compile_metadata(&identifier).forwards_input, "{identifier:?} should forward its input"); } } + + /// The network of `nodes` with its export on the last one, compiled and type checked, with its typed nodes. + fn compile_rasters(nodes: Vec) -> Result<(graph_craft::proto::ProtoNetwork, graph_craft::proto::TypingContext), Vec> { + use graph_craft::application_io::PlatformEditorApi; + use graph_craft::graphene_compiler::Compiler; + use graph_craft::proto::TypingContext; + use graphene_std::runtime::{AsyncRuntime, Spawn, Task}; + use interpreted_executor::util::wrap_network_in_scope; + use std::sync::Arc; + + struct NoSpawner; + impl Spawn for NoSpawner { + fn spawn(&self, _: Task) {} + } + + let network = NodeNetwork { + exports: vec![NodeInput::node(NodeId(nodes.len() as u64 - 1), 0)], + nodes: nodes.into_iter().enumerate().map(|(index, node)| (NodeId(index as u64), node)).collect(), + ..Default::default() + }; + let runtime = AsyncRuntime::new(Arc::new(NoSpawner)); + let mut network = wrap_network_in_scope(network, Arc::new(PlatformEditorApi::default()), runtime.handle()); + Preprocessor::new().preprocess(&mut network, &|_| None).unwrap(); + let network = Compiler {}.compile_single(network).unwrap().network; + + let mut typing_context = TypingContext::new(&interpreted_executor::node_registry::NODE_REGISTRY); + typing_context.update(&network).map_err(|errors| errors.to_vec())?; + Ok((network, typing_context)) + } + + fn raster_node(identifier: ProtoNodeIdentifier, inputs: Vec) -> DocumentNode { + DocumentNode { + inputs, + implementation: DocumentNodeImplementation::ProtoNode(identifier), + ..Default::default() + } + } + + /// What the node with this identifier outputs in the compiled network. + fn output_of(compiled: &(graph_craft::proto::ProtoNetwork, graph_craft::proto::TypingContext), identifier: &ProtoNodeIdentifier) -> Type { + let (network, typing_context) = compiled; + let (id, _) = network.nodes.iter().find(|(_, node)| node.identifier == *identifier).expect("the node should be in the network"); + typing_context.type_of(*id).expect("the node should be typed").return_value.nested_type().clone() + } + + #[test] + fn a_map_over_cpu_rasters_stays_on_the_cpu() { + use graph_craft::document::value::TaggedValue; + use graphene_std::graphene_core::read_raster; + use graphene_std::graphic_nodes::graphic::map; + use graphene_std::raster_nodes::adjustments::invert; + use graphene_std::raster_nodes::raster_kernel::raster_kernel_pipeline; + use graphene_std::raster_types::{CPU, Raster}; + + let source = || raster_node(ops::passthrough::IDENTIFIER, vec![NodeInput::value(TaggedValue::TypeDefault(list!(Raster)), false)]); + let read = || raster_node(read_raster::IDENTIFIER, vec![NodeInput::value(TaggedValue::None, false)]); + + // The loop body reads each raster and returns it + let map_node = |mapped: usize| raster_node(map::IDENTIFIER, vec![NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(mapped as u64), 0)]); + let compiled = compile_rasters(vec![source(), read(), map_node(1)]).expect("a map over CPU rasters should type check"); + assert_eq!(output_of(&compiled, &map::IDENTIFIER), list!(Raster)); + + // The loop body adjusts each raster + let invert_node = raster_node(invert::IDENTIFIER, vec![NodeInput::node(NodeId(1), 0), NodeInput::scope(raster_kernel_pipeline::IDENTIFIER)]); + let compiled = compile_rasters(vec![source(), read(), invert_node, map_node(2)]).expect("a map adjusting CPU rasters should type check"); + assert_eq!(output_of(&compiled, &invert::IDENTIFIER), item!(Raster)); + assert_eq!(output_of(&compiled, &map::IDENTIFIER), list!(Raster)); + } + + #[test] + fn eager_opts_what_comes_after_it_into_the_gpu() { + use graph_craft::document::value::TaggedValue; + use graphene_std::raster_nodes::adjustments::invert; + use graphene_std::raster_nodes::raster_kernel::raster_kernel_pipeline; + use graphene_std::raster_nodes::std_nodes::eager; + use graphene_std::raster_types::{CPU, GPU, Raster}; + + let source = raster_node(ops::passthrough::IDENTIFIER, vec![NodeInput::value(TaggedValue::TypeDefault(item!(Raster)), false)]); + let scope = || NodeInput::scope(raster_kernel_pipeline::IDENTIFIER); + + let compiled = compile_rasters(vec![source.clone(), raster_node(invert::IDENTIFIER, vec![NodeInput::node(NodeId(0), 0), scope()])]).expect("Invert should take a CPU raster"); + assert_eq!(output_of(&compiled, &invert::IDENTIFIER), item!(Raster)); + + let uploaded = raster_node(eager::IDENTIFIER, vec![NodeInput::node(NodeId(0), 0), scope()]); + let compiled = compile_rasters(vec![source, uploaded, raster_node(invert::IDENTIFIER, vec![NodeInput::node(NodeId(1), 0), scope()])]).expect("Invert should take Eager's GPU raster"); + assert_eq!(output_of(&compiled, &invert::IDENTIFIER), item!(Raster)); + } + + #[test] + fn a_footprint_dependent_raster_warns_at_a_footprint_invariant_node() { + use graph_craft::application_io::PlatformEditorApi; + use graph_craft::document::value::TaggedValue; + use graph_craft::graphene_compiler::Compiler; + use graph_craft::proto::GraphErrorType; + use graphene_std::raster_nodes::dehaze::dehaze; + use graphene_std::raster_nodes::filter::median_filter; + use graphene_std::raster_nodes::raster_kernel::raster_kernel_pipeline; + use graphene_std::raster_nodes::std_nodes::mandelbrot; + use graphene_std::runtime::{AsyncRuntime, Spawn, Task}; + use interpreted_executor::util::wrap_network_in_scope; + use std::sync::Arc; + + struct NoSpawner; + impl Spawn for NoSpawner { + fn spawn(&self, _: Task) {} + } + + let compile = |consumer: ProtoNodeIdentifier| { + let consumer = raster_node( + consumer, + vec![ + NodeInput::node(NodeId(0), 0), + NodeInput::value(TaggedValue::Number(10.), false), + NodeInput::scope(raster_kernel_pipeline::IDENTIFIER), + ], + ); + let network = NodeNetwork { + exports: vec![NodeInput::node(NodeId(1), 0)], + nodes: [(NodeId(0), raster_node(mandelbrot::IDENTIFIER, Vec::new())), (NodeId(1), consumer)].into_iter().collect(), + ..Default::default() + }; + let runtime = AsyncRuntime::new(Arc::new(NoSpawner)); + let mut network = wrap_network_in_scope(network, Arc::new(PlatformEditorApi::default()), runtime.handle()); + Preprocessor::new().preprocess(&mut network, &|_| None).unwrap(); + Compiler {}.compile_single(network).unwrap().warnings + }; + + let warnings = compile(dehaze::IDENTIFIER); + assert_eq!(warnings.len(), 1, "Dehaze should warn about reading the footprint through its input: {warnings:?}"); + assert!(warnings[0].error == GraphErrorType::FootprintDependentInput); + + assert!(compile(median_filter::IDENTIFIER).is_empty(), "a node that isn't footprint invariant shouldn't warn"); + } } From 312dc1529b5bd480c941f9ff20f59499166d1a70 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 08:09:24 +0000 Subject: [PATCH 11/14] Version the nodes that gained GPU rasters and add their migrations --- document/migrations/nodes/src/lib.rs | 5 +++ .../src/raster_nodes/black_and_white_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/blur_v1.rs | 25 +++++++++++ .../raster_nodes/brightness_contrast_v1.rs | 25 +++++++++++ .../src/raster_nodes/channel_mixer_v1.rs | 25 +++++++++++ .../src/raster_nodes/channels_to_image_v1.rs | 25 +++++++++++ .../src/raster_nodes/color_balance_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/color_lookup_v1.rs | 25 +++++++++++ .../src/raster_nodes/color_overlay_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/curves_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/dehaze_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/desaturate_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/exposure_v1.rs | 25 +++++++++++ .../raster_nodes/extend_image_to_bounds_v1.rs | 25 +++++++++++ .../src/raster_nodes/gamma_correction_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/gradient_map_v1.rs | 25 +++++++++++ .../src/raster_nodes/hue_saturation_v1.rs | 25 +++++++++++ .../raster_nodes/image_color_palette_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/invert_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/levels_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/make_opaque_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/mask_v1.rs | 25 +++++++++++ .../src/raster_nodes/median_filter_v1.rs | 25 +++++++++++ .../migrations/nodes/src/raster_nodes/mod.rs | 27 ++++++++++++ .../nodes/src/raster_nodes/photo_filter_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/posterize_v1.rs | 25 +++++++++++ .../src/raster_nodes/selective_color_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/threshold_v1.rs | 25 +++++++++++ .../nodes/src/raster_nodes/vibrance_v1.rs | 25 +++++++++++ node-graph/nodes/raster/src/adjustments.rs | 44 +++++++++++-------- node-graph/nodes/raster/src/blending_nodes.rs | 2 +- node-graph/nodes/raster/src/dehaze.rs | 2 +- node-graph/nodes/raster/src/filter.rs | 4 +- .../nodes/raster/src/image_color_palette.rs | 2 +- node-graph/nodes/raster/src/std_nodes.rs | 6 +-- 35 files changed, 740 insertions(+), 27 deletions(-) create mode 100644 document/migrations/nodes/src/raster_nodes/black_and_white_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/blur_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/brightness_contrast_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/channel_mixer_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/channels_to_image_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/color_balance_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/color_overlay_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/curves_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/dehaze_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/desaturate_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/exposure_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/extend_image_to_bounds_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/gamma_correction_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/gradient_map_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/hue_saturation_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/image_color_palette_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/invert_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/levels_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/make_opaque_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/mask_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/median_filter_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/photo_filter_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/posterize_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/selective_color_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/threshold_v1.rs create mode 100644 document/migrations/nodes/src/raster_nodes/vibrance_v1.rs diff --git a/document/migrations/nodes/src/lib.rs b/document/migrations/nodes/src/lib.rs index df036483b28..943ab86da4f 100644 --- a/document/migrations/nodes/src/lib.rs +++ b/document/migrations/nodes/src/lib.rs @@ -4,3 +4,8 @@ mod graphene_core; mod math_nodes; mod raster_nodes; + +/// The scope input of nodes that came to take the GPU pipeline, the identifier frozen as it was when they did. +fn raster_kernel_pipeline() -> migration_core::NodeInput { + migration_core::NodeInput::Scope("raster_nodes::raster_kernel::RasterKernelPipelineNode".into()) +} diff --git a/document/migrations/nodes/src/raster_nodes/black_and_white_v1.rs b/document/migrations/nodes/src/raster_nodes/black_and_white_v1.rs new file mode 100644 index 00000000000..4ba6de2e1f4 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/black_and_white_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::BlackAndWhiteNode"@1 => @2)] +pub fn black_and_white_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(9, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&BlackAndWhiteV1, &contract()); + check_undo(&BlackAndWhiteV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/blur_v1.rs b/document/migrations/nodes/src/raster_nodes/blur_v1.rs new file mode 100644 index 00000000000..1e3af60a24e --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/blur_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::filter::BlurNode"@1 => @2)] +pub fn blur_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(4, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&BlurV1, &contract()); + check_undo(&BlurV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/brightness_contrast_v1.rs b/document/migrations/nodes/src/raster_nodes/brightness_contrast_v1.rs new file mode 100644 index 00000000000..813821f11bb --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/brightness_contrast_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::BrightnessContrastNode"@1 => @2)] +pub fn brightness_contrast_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(5, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&BrightnessContrastV1, &contract()); + check_undo(&BrightnessContrastV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/channel_mixer_v1.rs b/document/migrations/nodes/src/raster_nodes/channel_mixer_v1.rs new file mode 100644 index 00000000000..5856f962c9b --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/channel_mixer_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::ChannelMixerNode"@1 => @2)] +pub fn channel_mixer_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(19, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ChannelMixerV1, &contract()); + check_undo(&ChannelMixerV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/channels_to_image_v1.rs b/document/migrations/nodes/src/raster_nodes/channels_to_image_v1.rs new file mode 100644 index 00000000000..fcd45d0a660 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/channels_to_image_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::std_nodes::ChannelsToImageNode"@1 => @2)] +pub fn channels_to_image_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(5, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ChannelsToImageV1, &contract()); + check_undo(&ChannelsToImageV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/color_balance_v1.rs b/document/migrations/nodes/src/raster_nodes/color_balance_v1.rs new file mode 100644 index 00000000000..e83c8e8af86 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/color_balance_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::ColorBalanceNode"@1 => @2)] +pub fn color_balance_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(12, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ColorBalanceV1, &contract()); + check_undo(&ColorBalanceV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs b/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs new file mode 100644 index 00000000000..90451444bb2 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::ColorLookupNode"@1 => @2)] +pub fn color_lookup_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ColorLookupV1, &contract()); + check_undo(&ColorLookupV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/color_overlay_v1.rs b/document/migrations/nodes/src/raster_nodes/color_overlay_v1.rs new file mode 100644 index 00000000000..24a4ef89453 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/color_overlay_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::blending_nodes::ColorOverlayNode"@1 => @2)] +pub fn color_overlay_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(4, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ColorOverlayV1, &contract()); + check_undo(&ColorOverlayV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/curves_v1.rs b/document/migrations/nodes/src/raster_nodes/curves_v1.rs new file mode 100644 index 00000000000..d46753aab33 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/curves_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::CurvesNode"@1 => @2)] +pub fn curves_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(7, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&CurvesV1, &contract()); + check_undo(&CurvesV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/dehaze_v1.rs b/document/migrations/nodes/src/raster_nodes/dehaze_v1.rs new file mode 100644 index 00000000000..80e1ac11626 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/dehaze_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::dehaze::DehazeNode"@1 => @2)] +pub fn dehaze_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&DehazeV1, &contract()); + check_undo(&DehazeV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/desaturate_v1.rs b/document/migrations/nodes/src/raster_nodes/desaturate_v1.rs new file mode 100644 index 00000000000..db3173ab0dd --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/desaturate_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::DesaturateNode"@1 => @2)] +pub fn desaturate_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&DesaturateV1, &contract()); + check_undo(&DesaturateV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/exposure_v1.rs b/document/migrations/nodes/src/raster_nodes/exposure_v1.rs new file mode 100644 index 00000000000..6156985c9af --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/exposure_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::ExposureNode"@1 => @2)] +pub fn exposure_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(4, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ExposureV1, &contract()); + check_undo(&ExposureV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/extend_image_to_bounds_v1.rs b/document/migrations/nodes/src/raster_nodes/extend_image_to_bounds_v1.rs new file mode 100644 index 00000000000..803bc6312fd --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/extend_image_to_bounds_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::std_nodes::ExtendImageToBoundsNode"@1 => @2)] +pub fn extend_image_to_bounds_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ExtendImageToBoundsV1, &contract()); + check_undo(&ExtendImageToBoundsV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/gamma_correction_v1.rs b/document/migrations/nodes/src/raster_nodes/gamma_correction_v1.rs new file mode 100644 index 00000000000..ee1f590cad9 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/gamma_correction_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::GammaCorrectionNode"@1 => @2)] +pub fn gamma_correction_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&GammaCorrectionV1, &contract()); + check_undo(&GammaCorrectionV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/gradient_map_v1.rs b/document/migrations/nodes/src/raster_nodes/gradient_map_v1.rs new file mode 100644 index 00000000000..9b75fe690c9 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/gradient_map_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::GradientMapNode"@1 => @2)] +pub fn gradient_map_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&GradientMapV1, &contract()); + check_undo(&GradientMapV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/hue_saturation_v1.rs b/document/migrations/nodes/src/raster_nodes/hue_saturation_v1.rs new file mode 100644 index 00000000000..8cbd26b210f --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/hue_saturation_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::HueSaturationNode"@1 => @2)] +pub fn hue_saturation_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(51, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&HueSaturationV1, &contract()); + check_undo(&HueSaturationV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/image_color_palette_v1.rs b/document/migrations/nodes/src/raster_nodes/image_color_palette_v1.rs new file mode 100644 index 00000000000..394940d280f --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/image_color_palette_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::image_color_palette::ImageColorPaletteNode"@1 => @2)] +pub fn image_color_palette_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ImageColorPaletteV1, &contract()); + check_undo(&ImageColorPaletteV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/invert_v1.rs b/document/migrations/nodes/src/raster_nodes/invert_v1.rs new file mode 100644 index 00000000000..dc882f65867 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/invert_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::InvertNode"@1 => @2)] +pub fn invert_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(1, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&InvertV1, &contract()); + check_undo(&InvertV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/levels_v1.rs b/document/migrations/nodes/src/raster_nodes/levels_v1.rs new file mode 100644 index 00000000000..df50d75cfd1 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/levels_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::LevelsNode"@1 => @2)] +pub fn levels_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(27, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&LevelsV1, &contract()); + check_undo(&LevelsV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/make_opaque_v1.rs b/document/migrations/nodes/src/raster_nodes/make_opaque_v1.rs new file mode 100644 index 00000000000..1f1af08fe18 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/make_opaque_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::MakeOpaqueNode"@1 => @2)] +pub fn make_opaque_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(1, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&MakeOpaqueV1, &contract()); + check_undo(&MakeOpaqueV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/mask_v1.rs b/document/migrations/nodes/src/raster_nodes/mask_v1.rs new file mode 100644 index 00000000000..e54bea8b34e --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/mask_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::std_nodes::MaskNode"@1 => @2)] +pub fn mask_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&MaskV1, &contract()); + check_undo(&MaskV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/median_filter_v1.rs b/document/migrations/nodes/src/raster_nodes/median_filter_v1.rs new file mode 100644 index 00000000000..0ad2b2253ab --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/median_filter_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::filter::MedianFilterNode"@1 => @2)] +pub fn median_filter_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&MedianFilterV1, &contract()); + check_undo(&MedianFilterV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/mod.rs b/document/migrations/nodes/src/raster_nodes/mod.rs index a1646bd22a5..40194e09db6 100644 --- a/document/migrations/nodes/src/raster_nodes/mod.rs +++ b/document/migrations/nodes/src/raster_nodes/mod.rs @@ -1,4 +1,31 @@ //! Migrations of the nodes in `raster_nodes`. +mod black_and_white_v1; +mod blur_v1; +mod brightness_contrast_v1; +mod channel_mixer_v1; +mod channels_to_image_v1; +mod color_balance_v1; +mod color_lookup_v1; +mod color_overlay_v1; +mod curves_v1; +mod dehaze_v1; +mod desaturate_v1; +mod exposure_v1; +mod extend_image_to_bounds_v1; +mod gamma_correction_v1; +mod gradient_map_v1; +mod hue_saturation_v1; +mod image_color_palette_v1; +mod invert_v1; +mod levels_v1; +mod make_opaque_v1; +mod mask_v1; +mod median_filter_v1; mod mix_colors_from_mix_node; +mod photo_filter_v1; +mod posterize_v1; mod renames; +mod selective_color_v1; +mod threshold_v1; +mod vibrance_v1; diff --git a/document/migrations/nodes/src/raster_nodes/photo_filter_v1.rs b/document/migrations/nodes/src/raster_nodes/photo_filter_v1.rs new file mode 100644 index 00000000000..5492c39b719 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/photo_filter_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::PhotoFilterNode"@1 => @2)] +pub fn photo_filter_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(4, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&PhotoFilterV1, &contract()); + check_undo(&PhotoFilterV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/posterize_v1.rs b/document/migrations/nodes/src/raster_nodes/posterize_v1.rs new file mode 100644 index 00000000000..21ecb6a0c1e --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/posterize_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::PosterizeNode"@1 => @2)] +pub fn posterize_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&PosterizeV1, &contract()); + check_undo(&PosterizeV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/selective_color_v1.rs b/document/migrations/nodes/src/raster_nodes/selective_color_v1.rs new file mode 100644 index 00000000000..fc1a7ebd798 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/selective_color_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::SelectiveColorNode"@1 => @2)] +pub fn selective_color_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(39, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&SelectiveColorV1, &contract()); + check_undo(&SelectiveColorV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/threshold_v1.rs b/document/migrations/nodes/src/raster_nodes/threshold_v1.rs new file mode 100644 index 00000000000..82aa3ad5788 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/threshold_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::ThresholdNode"@1 => @2)] +pub fn threshold_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&ThresholdV1, &contract()); + check_undo(&ThresholdV1, &contract()); + } +} diff --git a/document/migrations/nodes/src/raster_nodes/vibrance_v1.rs b/document/migrations/nodes/src/raster_nodes/vibrance_v1.rs new file mode 100644 index 00000000000..485a58f1234 --- /dev/null +++ b/document/migrations/nodes/src/raster_nodes/vibrance_v1.rs @@ -0,0 +1,25 @@ +use crate::raster_kernel_pipeline; +use migration_core::{Inputs, migration}; + +/// It now also takes GPU rasters, and the GPU pipeline they run on in a new scope input. +#[migration("raster_nodes::adjustments::VibranceNode"@1 => @2)] +pub fn vibrance_v1(inputs: &mut Inputs) { + inputs.push(raster_kernel_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + use migration_core::literal; + use migration_runner::testing::{SourceContract, check_commutation, check_undo}; + + fn contract() -> SourceContract { + SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + } + + #[test] + fn rewrites_history() { + check_commutation(&VibranceV1, &contract()); + check_undo(&VibranceV1, &contract()); + } +} diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 22fb74cb718..90c35e43820 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -67,7 +67,7 @@ pub enum DesaturateMethod { gpu_param_enum!(u32: DesaturateMethod); -#[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"), version(2))] fn desaturate( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -122,7 +122,7 @@ impl MapColor for Desaturate { } } -#[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"), version(2))] fn gamma_correction( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -195,7 +195,7 @@ fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { ImageChannels { red, green, blue, alpha } } -#[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"), version(2))] fn make_opaque( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -324,7 +324,13 @@ impl BrightnessCurve { // https://geraldbakker.nl/psnumbers/brightness-contrast.html // // TODO: A Lab-only mode once Graphite supports the CIE Lab color space. -#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), icon("NodeBrightnessContrast"), inspector("brightness_contrast_inspector"))] +#[node_macro::node( + name("Brightness/Contrast"), + category("Raster: Adjustment"), + icon("NodeBrightnessContrast"), + inspector("brightness_contrast_inspector"), + version(2) +)] fn brightness_contrast( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -514,7 +520,7 @@ impl LevelsChain { // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html -#[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"), version(2))] fn levels( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -744,7 +750,7 @@ impl MapColor for Levels { // points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages // stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. // Needs the heap for its curves, so it stays off the shader build for now. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"), version(2))] fn curves( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -847,7 +853,7 @@ fn points_to_transfer_curve( // Algorithm from: // https://stackoverflow.com/a/55233732/775283 // Works the same for gamma and linear color -#[node_macro::node(name("Black & White"), category("Raster: Adjustment"), icon("NodeBlackAndWhite"), inspector("black_and_white_inspector"))] +#[node_macro::node(name("Black & White"), category("Raster: Adjustment"), icon("NodeBlackAndWhite"), inspector("black_and_white_inspector"), version(2))] fn black_and_white( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -1161,7 +1167,7 @@ fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings // // TODO: Residuals in 8-bit PSD interop: the byte-hue colorize table, the colorize lightness slider (up to 2.5 levels), and the range edges (a few tenths of a degree) -#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), icon("NodeHueSaturation"), inspector("hue_saturation_inspector"))] +#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), icon("NodeHueSaturation"), inspector("hue_saturation_inspector"), version(2))] fn hue_saturation( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -1584,7 +1590,7 @@ impl MapColor for HueSaturation { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize -#[node_macro::node(category("Raster: Adjustment"), icon("NodeInvert"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeInvert"), version(2))] fn invert( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -1615,7 +1621,7 @@ impl MapColor for Invert { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient -#[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"), version(2))] fn threshold( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -1685,7 +1691,7 @@ impl MapColor for Threshold { // TODO: - Linear lerps the interval's stop colors by the fraction of p. Smooth is a cubic Hermite over the stop index with tangent // TODO: `(c[i + 1] - c[i - 1]) / 2`, the end stops repeated past the ends, so two stops give `0.5 p + 1.5 p^2 - p^3`. // TODO: - The ramp is `(1 - s) * linear + s * smooth` for smoothness s, clamped per interval to its two stop colors. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeGradientMap"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeGradientMap"), version(2))] fn gradient_map( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -1759,7 +1765,7 @@ impl MapColor for GradientMap { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3) -#[node_macro::node(category("Raster: Adjustment"), icon("NodeVibrance"), inspector("vibrance_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeVibrance"), inspector("vibrance_inspector"), version(2))] fn vibrance( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -1973,7 +1979,7 @@ pub enum DomainWarpType { // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr // // TODO: CMYK source channels once Graphite supports the CMYK color space. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeChannelMixer"), inspector("channel_mixer_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeChannelMixer"), inspector("channel_mixer_inspector"), version(2))] fn channel_mixer( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -2214,7 +2220,7 @@ gpu_param_enum!(u32: SelectiveColorChoice); // // Algorithm based on: // https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html -#[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"), version(2))] fn selective_color( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -2554,7 +2560,7 @@ impl MapColor for SelectiveColor { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold -#[node_macro::node(category("Raster: Adjustment"), icon("NodePosterize"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodePosterize"), version(2))] fn posterize( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -2606,7 +2612,7 @@ impl MapColor for Posterize { // // The exposure, offset, and gamma operations follow: // https://geraldbakker.nl/psnumbers/exposure.html -#[node_macro::node(category("Raster: Adjustment"), icon("NodeExposure"), inspector("exposure_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeExposure"), inspector("exposure_inspector"), version(2))] fn exposure( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] input: Item, @@ -2759,7 +2765,7 @@ fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i3 // // Every channel is a Levels curve whose black point, white point, and two-decimal gamma are derived from // the nine sliders, see `color_balance_curve`. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorBalance"), inspector("color_balance_inspector"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorBalance"), inspector("color_balance_inspector"), version(2))] fn color_balance( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -2874,7 +2880,7 @@ impl MapColor for ColorBalance { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl -#[node_macro::node(category("Raster: Adjustment"), icon("NodePhotoFilter"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodePhotoFilter"), version(2))] fn photo_filter( _: impl Ctx, #[implementations(Raster, Raster, Color, Gradient)] image: Item, @@ -2959,7 +2965,7 @@ fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { // // TODO: Support dither, which needs the pixel position that a per-color adjustment never sees. // TODO: Verify the tetrahedral interpolation, which is unconfirmed against other implementations. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorLookup"))] +#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorLookup"), version(2))] fn color_lookup( _: impl Ctx, /// The image whose colors are remapped by the LUT (lookup table). diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index bc6e16d2a9a..b26aefa068f 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -32,7 +32,7 @@ fn mix_colors( } // TODO: Rename to "Paint Overlay" and take a `Graphic` paint like the `Fill` node, enabling this to serve the cases of Color Overlay, Gradient Overlay, and Pattern Overlay. -#[node_macro::node(category("Raster: Effect"), icon("NodeColorOverlay"))] +#[node_macro::node(category("Raster: Effect"), icon("NodeColorOverlay"), version(2))] fn color_overlay( _: impl Ctx, #[implementations( diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index a362af88471..b06b2547e6c 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -15,7 +15,7 @@ use std::cmp::{max, min}; use wgpu_executor::WgpuPipelineCache; use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelInput; -#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize, footprint_invariant, placeholder = pending_dehaze)] +#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize, footprint_invariant, placeholder = pending_dehaze, version(2))] async fn dehaze( _: impl Ctx, #[implementations(Raster, Raster)] image_frame: Item, diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index e10837ba1ff..02efa8a0622 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -91,7 +91,7 @@ fn unpremultiply_gamma_to_linear(buffer: Image) -> Imag } /// Blurs the image with a Gaussian or box blur kernel filter. -#[node_macro::node(category("Raster: Filter"), icon("NodeBlur"))] +#[node_macro::node(category("Raster: Filter"), icon("NodeBlur"), version(2))] fn blur( _: impl Ctx, /// The image to be blurred. @@ -131,7 +131,7 @@ fn blur_cpu(image_frame: Item>, radius: f64, box_blur: bool, gamma: } /// Applies a median filter to reduce noise while preserving edges. -#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize)] +#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize, version(2))] fn median_filter( _: impl Ctx, /// The image to be filtered. diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index 8001cf76cee..c2518087c70 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -17,7 +17,7 @@ const TABLE_SIDE: usize = CELLS_PER_AXIS + 1; const OKLAB_GAMUT_RANGES: [(f32, f32); 3] = [(0., 1.), (-0.24, 0.28), (-0.32, 0.2)]; /// Finds the colors that best represent the distinct color ranges in an image, ignoring transparent areas. -#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), memoize, footprint_invariant, placeholder = pending_palette)] +#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), memoize, footprint_invariant, placeholder = pending_palette, version(2))] async fn image_color_palette( _: impl Ctx, /// The image to find colors in. diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 4db4a18c8dd..5f340033b22 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -46,7 +46,7 @@ pub struct SizeMismatchError { pub second_size: UVec2, } -#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize)] +#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize, version(2))] fn channels_to_image( _: impl Ctx, _primary: (), @@ -121,7 +121,7 @@ fn channels_to_image_cpu(red: Item>, green: Item>, blue: Ok(Item::from_parts(Raster::new_cpu(image), attributes)) } -#[node_macro::node(category("Raster"), icon("NodeMask"), memoize)] +#[node_macro::node(category("Raster"), icon("NodeMask"), memoize, version(2))] fn mask( _: impl Ctx, /// The image to be masked. @@ -174,7 +174,7 @@ fn mask_cpu(image: Item>, stencil: Item>) -> Item( _: impl Ctx, #[implementations(Raster, Raster)] image: Item, From c22fb0ec3a9bccf2f60896df1a3ce9bd8cd459ed Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 1 Nov 2026 09:19:27 +0000 Subject: [PATCH 12/14] Add an Image to Channels GPU node for GPU rasters, since a multi-output struct can't be generic over its raster --- node-graph/nodes/raster/src/adjustments.rs | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 90c35e43820..77f9333a027 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -195,6 +195,55 @@ fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { ImageChannels { red, green, blue, alpha } } +/// The red, green, blue, and alpha channels of an image, split into separate node outputs. +#[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] +pub struct ImageChannelsGpu { + /// The red channel of the image, as a grayscale image. + pub red: Item>, + /// The green channel of the image, as a grayscale image. + pub green: Item>, + /// The blue channel of the image, as a grayscale image. + pub blue: Item>, + /// The alpha channel of the image, as a grayscale image. + pub alpha: Item>, +} + +/// Separates an image into its red, green, blue, and alpha channels on the GPU, each provided as a grayscale image. +#[node_macro::node(category("Raster"), icon("NodeImageToChannels"), name("Image to Channels GPU"))] +fn image_to_channels_gpu(_: impl Ctx, image: Item>, #[scope(raster_kernel_pipeline::IDENTIFIER)] pipeline: Item) -> Result { + let [red, green, blue, alpha] = [0, 1, 2, 3].map(|channel| image.clone().adjust(&ExtractChannel { channel }, pipeline.element())); + + Ok(ImageChannelsGpu { + red: red?, + green: green?, + blue: blue?, + alpha: alpha?, + }) +} + +gpu_params! { + #[derive(Clone)] + struct ExtractChannel { + channel: u32, + } +} + +impl MapColor for ExtractChannel { + fn map_cpu(&self, color: Color) -> Color { + let value = [color.r(), color.g(), color.b(), color.a()][self.channel as usize]; + Color::from_rgbaf32_unchecked(value, value, value, 1.) + } + + fn wgsl() -> Wgsl { + Wgsl::body( + r#" + let value = color[channel]; + return vec4(value, value, value, 1.); + "#, + ) + } +} + #[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"), version(2))] fn make_opaque( _: impl Ctx, From 21910d242fbbac167886903eb92209e4c58598e2 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Nov 2026 22:18:56 +0000 Subject: [PATCH 13/14] Address review comments on the GPU raster kernels --- .../nodes/src/raster_nodes/color_lookup_v1.rs | 2 +- editor/src/test_utils.rs | 59 ++++++-------- .../interpreted-executor/src/node_registry.rs | 1 - node-graph/nodes/raster/src/adjustments.rs | 2 +- node-graph/nodes/raster/src/std_nodes.rs | 77 +++++++++++++++++-- 5 files changed, 96 insertions(+), 45 deletions(-) diff --git a/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs b/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs index 90451444bb2..eb686857d62 100644 --- a/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs +++ b/document/migrations/nodes/src/raster_nodes/color_lookup_v1.rs @@ -14,7 +14,7 @@ mod tests { use migration_runner::testing::{SourceContract, check_commutation, check_undo}; fn contract() -> SourceContract { - SourceContract::new(3, |random, _slot| literal(random.below(10) as f64)) + SourceContract::new(2, |random, _slot| literal(random.below(10) as f64)) } #[test] diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 6269721b5e6..eba50897963 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -66,22 +66,30 @@ impl EditorTestUtils { } /// Submit an evaluation to the executor, run the runtime, and process the results. + /// With `instrument`, the graph is compiled with the monitors of [`Instrumented`], which can't wrap the runtime handle that asynchronous nodes take. /// /// If a deferred message triggers a second graph run, this will not occur. See [`Self::eval_graph_until_finished`] for this behaviour. - async fn eval_graph_once(&mut self) -> Result { + async fn eval_graph_once(&mut self, instrument: bool) -> Result, TestGraphError> { let portfolio = &mut self.portfolio_message_handler_mut(); let document_id = portfolio.active_document_id.unwrap(); let (executor, documents) = (&mut portfolio.executor, &mut portfolio.documents); let document = documents.get_mut(&document_id).unwrap(); - let instrumented = match executor.update_node_graph_instrumented(document) { - Ok(instrumented) => instrumented, - Err(e) => return Err(TestGraphError::Other(format!("update_node_graph_instrumented failed\n\n{e}"))), + let instrumented = if instrument { + let instrumented = match executor.update_node_graph_instrumented(document) { + Ok(instrumented) => instrumented, + Err(e) => return Err(TestGraphError::Other(format!("update_node_graph_instrumented failed\n\n{e}"))), + }; + if let Err(e) = executor.submit_current_node_graph_evaluation(document, document_id, UVec2::ONE, 1., Default::default(), DVec2::ZERO) { + return Err(TestGraphError::Other(format!("submit_current_node_graph_evaluation failed\n\n{e}"))); + } + Some(instrumented) + } else { + if let Err(e) = executor.submit_node_graph_evaluation(document, document_id, UVec2::ONE, 1., Default::default(), Vec::new(), true, DVec2::ZERO) { + return Err(TestGraphError::Other(format!("submit_node_graph_evaluation failed\n\n{e}"))); + } + None }; - - if let Err(e) = executor.submit_current_node_graph_evaluation(document, document_id, UVec2::ONE, 1., Default::default(), DVec2::ZERO) { - return Err(TestGraphError::Other(format!("submit_current_node_graph_evaluation failed\n\n{e}"))); - } self.runtime.update(); self.runtime.run().await; @@ -103,39 +111,22 @@ impl EditorTestUtils { Ok(instrumented) } - /// Compiles and runs the graph without the monitors of [`Instrumented`], which can't wrap the runtime handle that asynchronous nodes take. - pub async fn eval_graph_uninstrumented(&mut self) -> Result<(), String> { - let portfolio = &mut self.portfolio_message_handler_mut(); - let document_id = portfolio.active_document_id.unwrap(); - let (executor, documents) = (&mut portfolio.executor, &mut portfolio.documents); - let document = documents.get_mut(&document_id).unwrap(); - - if let Err(e) = executor.submit_node_graph_evaluation(document, document_id, UVec2::ONE, 1., Default::default(), Vec::new(), true, Default::default()) { - return Err(format!("submit_node_graph_evaluation failed\n\n{e}")); - } - self.finish_evaluation().await + /// Repeatedly calls [`Self::eval_graph_once`] until no more runs are triggered by deferred messages. + pub async fn eval_graph_until_finished(&mut self) -> Result { + let instrumented = self.eval_graph_repeatedly(true).await?; + Ok(instrumented.expect("an instrumented evaluation returns its monitors")) } - /// Waits for the submitted evaluation and processes its results, failing if the graph didn't render. - async fn finish_evaluation(&mut self) -> Result<(), String> { - self.runtime.run().await; - - let frontend_messages = self.dispatch(Message::Wake); - if frontend_messages.contains(&FrontendMessage::UpdateRenderStatus { status: RenderStatus::Error }) { - return Err("Graph should render".to_string()); - } - for message in frontend_messages { - message.check_node_graph_error(); - } - Ok(()) + /// Like [`Self::eval_graph_until_finished`], but compiles the graph without monitors so that it can contain asynchronous nodes. + pub async fn eval_graph_uninstrumented(&mut self) -> Result<(), TestGraphError> { + self.eval_graph_repeatedly(false).await.map(|_| ()) } - /// Repeatedly calls [`Self::eval_graph_once`] until no more runs are triggered by deferred messages. - pub async fn eval_graph_until_finished(&mut self) -> Result { + async fn eval_graph_repeatedly(&mut self, instrument: bool) -> Result, TestGraphError> { const MAX_ITERATIONS: usize = 16; for _iteration in 0..MAX_ITERATIONS { let execution_id = self.portfolio_message_handler().executor.current_execution_id(); - let result = self.eval_graph_once().await?; + let result = self.eval_graph_once(instrument).await?; // If there have been no other executions queued (other than the one we just queued ourselves above) then finish if execution_id + 1 >= self.portfolio_message_handler().executor.current_execution_id() { return Ok(result); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index af6f2701551..ac735eb5849 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -51,7 +51,6 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item>]), - #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 77f9333a027..eae31d2aa63 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -798,7 +798,7 @@ impl MapColor for Levels { // Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost // points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages // stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. -// Needs the heap for its curves, so it stays off the shader build for now. +// On the GPU, the curves are baked on the CPU into a lookup table that the kernel samples. #[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"), version(2))] fn curves( _: impl Ctx, diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 5f340033b22..25c6d97c20a 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -263,7 +263,7 @@ impl PlacedRaster for Raster { } impl PlacedRaster for Raster { - /// Each channel is placed by its transform into their [`union_frame`], which takes the red channel's other attributes, and gives its luminance weighted by its alpha. + /// Each channel is placed by its transform into their [`union_frame`], which takes the first present channel's other attributes, and gives its luminance weighted by its alpha. /// An empty channel gives zero, or full opacity for the alpha channel. fn channels_to_image(channels: [Item; 4], pipeline: &WgpuPipelineCache) -> Result, NodeError> { if channels.iter().all(|channel| channel.element().is_empty()) { @@ -273,8 +273,9 @@ impl PlacedRaster for Raster { let frame = union_frame(&channels.each_ref().map(raster_placement)).ok_or_else(|| NodeError::new("Every channel must be placed by an invertible transform"))?; let raster = run_raster_kernel(&CombineChannels {}, frame.kernel_inputs(&channels.each_ref().map(Item::element)), frame.size, pipeline)?; - let [red, ..] = channels; - let (_, mut attributes) = red.into_parts(); + // Take this item's blending attributes from the first present channel, as on the CPU + let first_present = channels.into_iter().find(|channel| !channel.element().is_empty()).expect("a channel is present"); + let (_, mut attributes) = first_present.into_parts(); attributes.insert(ATTR_TRANSFORM, frame.transform); Ok(Item::from_parts(raster, attributes)) } @@ -304,14 +305,25 @@ impl PlacedRaster for Raster { } fn extend_image_to_bounds(image: Item, bounds: DAffine2, pipeline: &WgpuPipelineCache) -> Result, (Item, NodeError)> { - if image.element().is_empty() { - return Ok(image); - } - let (image_size, image_transform) = raster_placement(&image); let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); - if image_transform.matrix2.determinant() == 0. || (image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end)) { + if image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end) { + return Ok(image); + } + + // An empty image becomes a transparent one filling the bounds, as on the CPU + if image.element().is_empty() { + let size = UVec2::new(bounds.transform_vector2(DVec2::X).length() as u32, bounds.transform_vector2(DVec2::Y).length() as u32); + if size.x == 0 || size.y == 0 { + return Ok(Item::default().with_attribute(ATTR_TRANSFORM, bounds)); + } + return match run_raster_kernel(&Transparent {}, Vec::new(), size, pipeline) { + Ok(raster) => Ok(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, bounds)), + Err(error) => Err((image, error)), + }; + } + if image_transform.matrix2.determinant() == 0. { return Ok(image); } @@ -418,6 +430,18 @@ impl RasterKernel for ExtendToBounds { } } +gpu_params! { + #[derive(Clone)] + struct Transparent {} +} + +impl RasterKernel for Transparent { + const INPUTS: usize = 0; + fn wgsl() -> Wgsl { + Wgsl::body("return vec4(0.);") + } +} + /// Uploads an image to the GPU, so the nodes that come next run on the GPU rather than the CPU. #[node_macro::node(category("Raster"), icon("NodeUploadTexture"), memoize)] fn eager( @@ -738,4 +762,41 @@ mod tests { assert_eq!((raster.element().width, raster.element().height), (8, 8)); } + + #[test] + fn channels_to_image_takes_the_first_present_channels_attributes_on_both_paths() { + futures::executor::block_on(async { + let Some(executor) = WgpuExecutor::new().await else { return }; + let pipeline = gpu_pipeline(&executor); + + // Only green is connected, so its opacity carries over rather than red's absence + let green = Item::new_from_element(Raster::new_cpu(Image::new(2, 2, Color::WHITE))).with_attribute(core_types::list::ATTR_OPACITY, 0.25_f64); + let channels = || [Item::default(), green.clone(), Item::default(), Item::default()]; + let cpu = Raster::::channels_to_image(channels(), pipeline.element()).unwrap(); + let gpu_channels = channels().map(|channel| eager((), channel, pipeline.clone()).unwrap()); + let gpu = Raster::::channels_to_image(gpu_channels, pipeline.element()).unwrap(); + + assert_eq!(cpu.attribute::(core_types::list::ATTR_OPACITY), Some(&0.25)); + assert_eq!(gpu.attribute::(core_types::list::ATTR_OPACITY), Some(&0.25)); + }); + } + + #[test] + fn extending_an_empty_image_fills_the_bounds_on_both_paths() { + futures::executor::block_on(async { + let Some(executor) = WgpuExecutor::new().await else { return }; + let pipeline = gpu_pipeline(&executor); + + let bounds = DAffine2::from_translation(DVec2::new(5., 7.)) * DAffine2::from_scale(DVec2::new(6., 3.)); + let cpu = Raster::::extend_image_to_bounds(Item::default(), bounds, pipeline.element()).unwrap(); + let gpu = Raster::::extend_image_to_bounds(Item::default(), bounds, pipeline.element()).unwrap(); + + assert_eq!((cpu.element().width, cpu.element().height), (6, 3)); + assert_eq!(gpu.element().size(), UVec2::new(6, 3)); + assert_eq!(cpu.attribute_cloned_or_default::(ATTR_TRANSFORM), bounds); + assert_eq!(gpu.attribute_cloned_or_default::(ATTR_TRANSFORM), bounds); + let downloaded: Raster = gpu.into_element().convert(Footprint::DEFAULT, executor).await; + assert!(downloaded.data.iter().all(|pixel| *pixel == Color::TRANSPARENT)); + }); + } } From 96a8e72fcf7db9377e29cbf49c751968e0460ee5 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Nov 2026 22:24:16 +0000 Subject: [PATCH 14/14] Fix Vivid Light burning and dodging the source by the backdrop instead of the backdrop by the source --- .../core-types/src/color/color_types.rs | 18 +++++++-- node-graph/nodes/raster/src/blending_nodes.rs | 37 +++++++++++++++++++ .../nodes/raster/src/wgsl/blending.wgsl | 4 +- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/node-graph/libraries/core-types/src/color/color_types.rs b/node-graph/libraries/core-types/src/color/color_types.rs index be92d572c89..a069aa57f18 100644 --- a/node-graph/libraries/core-types/src/color/color_types.rs +++ b/node-graph/libraries/core-types/src/color/color_types.rs @@ -694,12 +694,12 @@ impl Color { Color::blend_hardlight(c_s, c_b) } - /// Per-channel "Vivid Light" blend. + /// Per-channel "Vivid Light" blend: "Color Burn" of the backdrop by the doubled source in its darker half, and "Color Dodge" in its lighter half. pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 { if c_s <= 0.5 { - Color::blend_color_burn(2. * c_s, c_b) + Color::blend_color_burn(c_b, 2. * c_s) } else { - Color::blend_color_dodge(2. * c_s - 1., c_b) + Color::blend_color_dodge(c_b, 2. * c_s - 1.) } } @@ -1016,6 +1016,18 @@ impl Color { mod tests { use super::*; + #[test] + fn vivid_light_burns_and_dodges_the_backdrop_by_the_doubled_source() { + // The darker half is Color Burn of the backdrop by twice the source: 1 - (1 - 0.8) / 0.5 + assert!((Color::blend_vivid_light(0.8, 0.25) - 0.6).abs() < 1e-6); + // The lighter half is Color Dodge of the backdrop by twice the source less one: 0.4 / (1 - 0.5) + assert!((Color::blend_vivid_light(0.4, 0.75) - 0.8).abs() < 1e-6); + // A mid-gray source leaves the backdrop unchanged, and the extremes saturate + assert!((Color::blend_vivid_light(0.3, 0.5) - 0.3).abs() < 1e-6); + assert_eq!(Color::blend_vivid_light(0.3, 0.), 0.); + assert_eq!(Color::blend_vivid_light(0.3, 1.), 1.); + } + #[test] fn oklab_lightness_spans_black_to_white() { assert!(Color::BLACK.lightness_oklab().abs() < 1e-4); diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index b26aefa068f..8913801bf96 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -116,6 +116,43 @@ mod test { assert_eq!(result.data[0], Color::from_rgbaf32_unchecked(0., image_color.g(), 0., image_color.a())); } + #[test] + fn vivid_light_overlays_match_on_the_cpu_and_gpu() { + use core_types::ops::{Convert, ConvertAsync}; + use core_types::transform::Footprint; + use raster_types::{CPU, GPU}; + use wgpu_executor::shader_runtime::raster_kernel_runtime::RasterKernelRuntime; + use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; + + futures::executor::block_on(async { + let Some(executor) = WgpuExecutor::new().await else { return }; + let pipeline = WgpuPipelineCache::default(); + executor.pipeline_init::(&pipeline); + let pipeline = Item::new_from_element(pipeline); + + // A row of backdrops from black to white, overlaid by sources across both halves of Vivid Light + let steps = 11; + let backdrops = (0..steps).map(|step| step as f32 / (steps - 1) as f32); + let image = Image { + width: steps, + height: 1, + data: backdrops.map(|value| Color::from_rgbaf32_unchecked(value, value, value, 1.)).collect(), + }; + for source in [0., 0.1, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9, 1.] { + let color = Color::from_rgbaf32_unchecked(source, source, source, 1.); + let cpu = Item::new_from_element(Raster::new_cpu(image.clone())); + let cpu = super::color_overlay((), cpu, color.into(), BlendMode::VividLight.into(), 100_f64.into(), pipeline.clone()).unwrap(); + let uploaded: Raster = Raster::new_cpu(image.clone()).convert(Footprint::DEFAULT, executor.clone()); + let gpu = super::color_overlay((), Item::new_from_element(uploaded), color.into(), BlendMode::VividLight.into(), 100_f64.into(), pipeline.clone()).unwrap(); + let gpu: Raster = gpu.into_element().convert(Footprint::DEFAULT, executor.clone()).await; + + for (cpu, gpu) in cpu.element().data.iter().zip(&gpu.data) { + assert!((cpu.r() - gpu.r()).abs() < 2e-3, "source {source}: CPU {cpu:?} against GPU {gpu:?}"); + } + } + }); + } + #[test] fn mix_colors_paints_over_at_its_opacity() { let red = Color::from_rgbaf32_unchecked(1., 0., 0., 1.); diff --git a/node-graph/nodes/raster/src/wgsl/blending.wgsl b/node-graph/nodes/raster/src/wgsl/blending.wgsl index c41d117da6c..fcec84f0768 100644 --- a/node-graph/nodes/raster/src/wgsl/blending.wgsl +++ b/node-graph/nodes/raster/src/wgsl/blending.wgsl @@ -110,9 +110,9 @@ fn blend_overlay(c_b: f32, c_s: f32) -> f32 { fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 { if c_s <= 0.5 { - return blend_color_burn(2. * c_s, c_b); + return blend_color_burn(c_b, 2. * c_s); } - return blend_color_dodge(2. * c_s - 1., c_b); + return blend_color_dodge(c_b, 2. * c_s - 1.); } fn blend_linear_light(c_b: f32, c_s: f32) -> f32 {