diff --git a/Cargo.lock b/Cargo.lock index 3fd7e5f6d6a..620d68db4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5853,6 +5853,7 @@ dependencies = [ "tsify", "vector-types", "wasm-bindgen", + "wgpu-executor", ] [[package]] @@ -8736,6 +8737,7 @@ dependencies = [ "futures", "glam", "graphene-application-io", + "half", "log", "node-macro", "raster-types", 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..eb686857d62 --- /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(2, |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/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/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 690f2a947a0..716284c2c92 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -3519,6 +3519,49 @@ 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, + 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.contains(&raster_kernel_pipeline) { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + 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() { + 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 +4521,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/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..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; @@ -105,10 +113,20 @@ impl EditorTestUtils { /// 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")) + } + + /// 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(|_| ()) + } + + 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/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/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/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index b6d0e390e2d..ac735eb5849 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -51,6 +51,7 @@ 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>]), + 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]), @@ -90,6 +91,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 @@ -142,6 +144,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 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/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/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..1ecdd1f062c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -23,7 +23,7 @@ use core_types::{ use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_resource::Resource; -use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::raster_types::{CPU, GPU, Raster, Texture}; use graphic_types::vector_types::gradient::{Gradient, GradientForm}; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint, TargetPath}; use graphic_types::vector_types::vector::misc::dvec2_to_point; @@ -377,6 +377,9 @@ pub struct RenderParams { pub canvas_image_scale: f64, /// The nearest ancestor's appearance, cascading to items that lack their own. pub inherited_appearance: Option, + /// 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,11 +2642,26 @@ 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. 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 +2701,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/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 087703265c0..d59efb53a49 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,11 @@ const TEXTURE_CACHE_SIZE: u64 = 1024 * 1024 * 1024; // 1GB #[cfg(target_family = "wasm")] const TEXTURE_CACHE_SIZE: u64 = 512 * 1024 * 1024; // 512MB +/// 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 { inner: Arc, @@ -44,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, @@ -177,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); @@ -185,6 +199,7 @@ impl WgpuExecutor { inner: Arc::new(WgpuExecutorInner { context, texture_cache: texture_cache.into(), + raster_format, vello_renderer, document_canvas: Mutex::default(), }), @@ -195,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/pipeline.rs b/node-graph/libraries/wgpu-executor/src/pipeline.rs index 2717b73ea6d..4acfe038f87 100644 --- a/node-graph/libraries/wgpu-executor/src/pipeline.rs +++ b/node-graph/libraries/wgpu-executor/src/pipeline.rs @@ -32,6 +32,11 @@ impl PipelineCache { let pipeline = (**entry).downcast_ref::

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

() })?; 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() + } } 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..2ba825be71b --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/shader_runtime/raster_kernel_runtime.rs @@ -0,0 +1,390 @@ +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`. +/// 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, + 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, + empty_input: wgpu::Texture, +} + +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() + }); + 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()); + + 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().map_or(&self.empty_input, |texture| 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 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, size.x as f32, size.y 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..6a322f71990 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -2,32 +2,71 @@ 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 half::f16; use raster_types::Image; -use raster_types::{CPU, GPU, Raster}; +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) -> Raster { + if image.is_empty() { + return Raster::default(); + } + + 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(), + bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_pixel * image.width), + rows_per_image: Some(image.height), + }, + Extent3d { + width: image.width, + height: image.height, + depth_or_array_layers: 1, + }, + ); + Raster::new_gpu(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` or `FALLBACK_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, } 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 bytes_per_pixel = 4; // RGBA8 + let format = texture.format(); + 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; @@ -62,15 +101,16 @@ impl RasterGpuToRasterCpuConverter { }, ); - Self { + Some(Self { buffer, width, height, + format, 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> { @@ -114,8 +154,20 @@ 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()); + 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()); + } + } } } @@ -171,6 +223,48 @@ 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(); + Item::from_parts(upload_to_texture(&executor, &queue, &image), 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 raster = upload_to_texture(&executor, &queue, &self); + + queue.submit([]); + raster + } +} + +/// 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> { @@ -194,7 +288,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) @@ -227,6 +326,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/libraries/wgpu-executor/src/vello_render.rs b/node-graph/libraries/wgpu-executor/src/vello_render.rs index 01bc289eeaf..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,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() || matches!(source.format(), crate::RASTER_FORMAT | crate::FALLBACK_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..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::TextureFormat::Rgba8Unorm.into())], + targets: &[Some(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/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 92704103fa2..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(crop.size); + 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/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/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..227409b893e 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, } @@ -32,42 +31,60 @@ fn render_intermediate( Context -> List, Context -> List, Context -> List>, + Context -> List>, Context -> List, Context -> List, Context -> List, )] 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 +99,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 +128,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 +147,6 @@ fn render( }; RenderOutputType::Texture(texture) } - _ => unreachable!("Render node did not receive its requested data type"), }; Ok(Item::new_from_element(RenderOutput { data, metadata })) 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..eae31d2aa63 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, } -#[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(); +gpu_param_enum!(u32: DesaturateMethod); - input.element_mut().adjust(|color| { +#[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"), version(2))] +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())?) +} + +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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"), version(2))] +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. @@ -154,11 +195,77 @@ fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { ImageChannels { red, green, blue, alpha } } -#[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 +/// 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, + #[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 +296,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 { @@ -263,10 +373,16 @@ 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"))] -fn brightness_contrast>( +#[node_macro::node( + name("Brightness/Contrast"), + category("Raster: Adjustment"), + icon("NodeBrightnessContrast"), + inspector("brightness_contrast_inspector"), + version(2) +)] +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 +396,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 +411,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 +486,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 +522,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 { @@ -422,10 +569,10 @@ 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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"), version(2))] +fn levels( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -567,8 +714,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 +764,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 +798,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. -#[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"))] -fn curves + Send>( +// 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, - #[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] + } +} - image +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) } + } +} + +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)] @@ -690,10 +902,10 @@ 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"))] -fn black_and_white>( +#[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, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, use_tint: Item, #[default("#e1d3b3")] tint: Item, #[unit("%")] @@ -726,28 +938,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 +1011,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 +1095,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 +1182,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); } @@ -970,10 +1216,10 @@ 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"))] -fn hue_saturation>( +#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), icon("NodeHueSaturation"), inspector("hue_saturation_inspector"), version(2))] +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 +1486,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 +1536,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 +1610,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 +1630,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 +#[node_macro::node(category("Raster: Adjustment"), icon("NodeInvert"), version(2))] +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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"), version(2))] +fn threshold( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -1372,20 +1684,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())?) +} - image.element_mut().adjust(|color| { +gpu_params! { + #[derive(Clone)] + struct Threshold { + min_luminance: f32, + max_luminance: f32, + } +} + +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: @@ -1401,38 +1740,84 @@ fn 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"))] -fn gradient_map + Send>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeGradientMap"), version(2))] +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, + } +} + +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), + } + } +} - image.element_mut().adjust(|color| { +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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeVibrance"), inspector("vibrance_inspector"), version(2))] +fn vibrance( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, #[unit("%")] #[range] #[hard(-100..100)] @@ -1441,16 +1826,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 +1858,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 +1913,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 +1925,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 +1938,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 +1957,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 +1975,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 +1988,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 +2009,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] @@ -1620,10 +2028,10 @@ 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"))] -fn channel_mixer>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeChannelMixer"), inspector("channel_mixer_inspector"), version(2))] +fn channel_mixer( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, monochrome: Item, @@ -1729,8 +2137,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 +2155,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 +2239,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,16 +2261,18 @@ 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 // // 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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"), version(2))] +fn selective_color( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Raster, Color, Gradient)] image: Item, mode: Item, @@ -2009,8 +2466,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 +2479,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 +2561,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 +2588,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 +2600,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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodePosterize"), version(2))] +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: @@ -2145,10 +2661,10 @@ 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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeExposure"), inspector("exposure_inspector"), version(2))] +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 +2676,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 +2729,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 { @@ -2278,10 +2814,10 @@ 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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorBalance"), inspector("color_balance_inspector"), version(2))] +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 +2871,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 +2901,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>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodePhotoFilter"), version(2))] +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 +2940,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 +2954,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 +2983,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 @@ -2443,33 +3014,65 @@ 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>( +#[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). - #[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 +3117,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 +3223,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 +3269,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 +3381,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 +3472,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 +3482,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 +3511,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 +3566,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 +3604,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 +3631,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 +3683,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 +3729,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.] } @@ -3136,4 +3756,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/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 1be51ba6433..8913801bf96 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}; @@ -30,11 +32,12 @@ 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>( +#[node_macro::node(category("Raster: Effect"), icon("NodeColorOverlay"), version(2))] +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); - image.element_mut().adjust(|pixel| { - let overlay = apply_blend_mode(color, *pixel, blend_mode); - let mix = |image: f32, overlay: f32| image + (overlay - image) * opacity; +gpu_params! { + #[derive(Clone)] + struct ColorOverlay { + color: Color, + blend_mode: BlendMode, + opacity: f32, + } +} + +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,13 +108,51 @@ 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())); } + #[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/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/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 5ec82cc4e3f..b06b2547e6c 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; 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, Storage}; 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, footprint_invariant, placeholder = pending_dehaze, version(2))] +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,161 @@ 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 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); + } + + 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 = raster.size(); + + 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. @@ -283,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 23306d5b688..02efa8a0622 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; +use num_enum::IntoPrimitive; use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; -use raster_types::{CPU, Raster}; +use raster_types::{CPU, GPU, Raster, Storage}; +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). @@ -84,11 +91,12 @@ 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( +#[node_macro::node(category("Raster: Filter"), icon("NodeBlur"), version(2))] +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 @@ -119,18 +131,20 @@ fn blur( } /// Applies a median filter to reduce noise while preserving edges. -#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize)] -fn median_filter( +#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize, version(2))] +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,53 @@ 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 { + if image.is_empty() { + return Ok(image); + } + + let size = image.size(); + 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 +353,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 || image.element().is_empty() { + return Ok(image); + } + let (raster, attributes) = image.into_parts(); + let space = if gamma { BlurSpace::Gamma } else { BlurSpace::Linear }; + let size = raster.size(); + 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); @@ -353,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 30b4e271bff..c2518087c70 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, version(2))] +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() } @@ -325,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/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..4d390d5520d --- /dev/null +++ b/node-graph/nodes/raster/src/raster_kernel.rs @@ -0,0 +1,619 @@ +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, ImageError, Raster, Storage}; +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, + }; + 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 { + 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), pipeline) +} + +/// 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>, + 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 { + if self.is_empty() { + return Ok(self); + } + + let size = self.size(); + let input = RasterKernelInput { + raster: &self, + output_to_texel: DAffine2::IDENTITY, + }; + run(kernel, map_color_module::, false, vec![input], size, raster_format(pipeline), 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) { + (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. 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 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 = 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 = placed + .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)) +} + +/// 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 { + 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) + } + } +} + +#[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 d0dca74b6a7..25c6d97c20a 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -1,12 +1,16 @@ 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}; 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; use glam::{DAffine2, DVec2, UVec2, Vec2}; @@ -14,10 +18,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, Storage}; use raster_types::{Image, ImageError}; use std::fmt::Debug; use std::hash::Hash; +use wgpu_executor::WgpuPipelineCache; #[derive(Debug, DynAny)] pub enum Error { @@ -41,15 +46,28 @@ pub struct SizeMismatchError { pub second_size: UVec2, } -#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize)] -fn channels_to_image( +#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize, version(2))] +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)); @@ -103,15 +121,22 @@ fn channels_to_image( Ok(Item::from_parts(Raster::new_cpu(image), attributes)) } -#[node_macro::node(category("Raster"), icon("NodeMask"), memoize)] -fn mask( +#[node_macro::node(category("Raster"), icon("NodeMask"), memoize, version(2))] +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; @@ -149,10 +174,17 @@ fn mask( row } -#[node_macro::node(category(""))] -fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Result>, (Item>, ImageError)> { - let bounds = *bounds.element(); +#[node_macro::node(category(""), version(2))] +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 +241,225 @@ 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 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()) { + 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)?; + + // 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)) + } + + /// 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. { + 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_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); + } + + 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 = 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); + "#, + ) + } +} + +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); + "#, + ) + } +} + +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( + _: 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. @@ -450,3 +701,102 @@ 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)); + } + + #[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)); + }); + } +} 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..fcec84f0768 --- /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(c_b, 2. * c_s); + } + return blend_color_dodge(c_b, 2. * c_s - 1.); +} + +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/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); +} 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); +} 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"); + } }