diff --git a/.github/workflows/provide-shaders.yml b/.github/workflows/provide-shaders.yml index b9fa8d2de2e..095c30ade3c 100644 --- a/.github/workflows/provide-shaders.yml +++ b/.github/workflows/provide-shaders.yml @@ -1,9 +1,6 @@ name: "Provide Shaders" on: - push: - branches: - - master workflow_dispatch: {} jobs: diff --git a/.nix/pkgs/graphite-raster-nodes-shaders.nix b/.nix/pkgs/graphite-raster-nodes-shaders.nix index 4a0fff67674..f0d120a70cf 100644 --- a/.nix/pkgs/graphite-raster-nodes-shaders.nix +++ b/.nix/pkgs/graphite-raster-nodes-shaders.nix @@ -17,7 +17,7 @@ env = deps.rustGPU.env; buildPhase = '' - cargo build -r -p raster-nodes-shaders + cargo build -r -p raster-nodes-shaders --features compile ''; installPhase = '' diff --git a/.nix/pkgs/graphite.nix b/.nix/pkgs/graphite.nix index 04a87b98f6a..e8493ce757b 100644 --- a/.nix/pkgs/graphite.nix +++ b/.nix/pkgs/graphite.nix @@ -97,7 +97,6 @@ deps.crane.lib.buildPackage ( makeCacheWritable = true; env = { - RASTER_NODES_SHADER_PATH = self.packages.${system}.graphite-raster-nodes-shaders; GRAPHITE_GIT_COMMIT_HASH = self.rev or "unknown"; GRAPHITE_GIT_COMMIT_DATE = self.lastModified or "unknown"; CEF_PATH = "${self.packages.${system}.graphite-cef}/lib"; diff --git a/Cargo.lock b/Cargo.lock index 5c7fcdab395..0622e6ad95a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2272,12 +2272,15 @@ dependencies = [ "brush-types", "core-types", "dyn-any", + "fastnoise-lite", "glam 0.33.10", "graphene-hash", + "graphene-resource", "node-macro", "raster-types", "serde", "serde_json", + "tsify", "vector-types", "wasm-bindgen", ] @@ -2467,6 +2470,7 @@ dependencies = [ name = "graphite-wasm-wrapper" version = "0.0.0" dependencies = [ + "base64", "graph-craft", "graphene-std", "graphite-editor", @@ -3870,7 +3874,6 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "strum", "syn 2.0.106", ] @@ -4814,30 +4817,23 @@ dependencies = [ "color", "core-types", "dyn-any", - "fastnoise-lite", "futures", "glam 0.33.10", "graphene-core", "graphene-hash", "graphene-resource", + "graphic-types", "image", "kurbo", "ndarray", "no-std-types", "node-macro", - "num-traits", - "num_enum", - "rand", - "rand_chacha", - "raster-nodes-shaders", "raster-types", "serde", - "spirv-std", "tokio", "tsify", "vector-types", "wasm-bindgen", - "wgpu-executor", ] [[package]] @@ -4852,9 +4848,6 @@ dependencies = [ [[package]] name = "raster-nodes-shaders-entrypoint" version = "0.0.0" -dependencies = [ - "raster-nodes", -] [[package]] name = "raster-types" @@ -5055,6 +5048,7 @@ dependencies = [ "usvg", "vector-types", "vello_gpu", + "wgpu", ] [[package]] @@ -5940,27 +5934,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "subtle" version = "2.6.1" @@ -6820,7 +6793,7 @@ dependencies = [ [[package]] name = "vello_common" version = "0.3.0" -source = "git+https://github.com/Keavon/vello.git?rev=7306bb4f206d956a8989173237c406b1d7798b35#7306bb4f206d956a8989173237c406b1d7798b35" +source = "git+https://github.com/Keavon/vello.git?rev=d11b17aaf78e5c8c2439ad3074d8cffa618994c2#d11b17aaf78e5c8c2439ad3074d8cffa618994c2" dependencies = [ "bytemuck", "fearless_simd", @@ -6835,7 +6808,7 @@ dependencies = [ [[package]] name = "vello_gpu" version = "0.3.0" -source = "git+https://github.com/Keavon/vello.git?rev=7306bb4f206d956a8989173237c406b1d7798b35#7306bb4f206d956a8989173237c406b1d7798b35" +source = "git+https://github.com/Keavon/vello.git?rev=d11b17aaf78e5c8c2439ad3074d8cffa618994c2#d11b17aaf78e5c8c2439ad3074d8cffa618994c2" dependencies = [ "bytemuck", "hashbrown 0.17.1", @@ -6849,7 +6822,7 @@ dependencies = [ [[package]] name = "vello_gpu_shaders" version = "0.3.0" -source = "git+https://github.com/Keavon/vello.git?rev=7306bb4f206d956a8989173237c406b1d7798b35#7306bb4f206d956a8989173237c406b1d7798b35" +source = "git+https://github.com/Keavon/vello.git?rev=d11b17aaf78e5c8c2439ad3074d8cffa618994c2#d11b17aaf78e5c8c2439ad3074d8cffa618994c2" dependencies = [ "naga", "wesl", @@ -7301,6 +7274,8 @@ dependencies = [ "futures", "glam 0.33.10", "graphene-application-io", + "graphic-types", + "half", "log", "node-macro", "raster-types", diff --git a/Cargo.toml b/Cargo.toml index 317e4dec803..daa891991ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,6 +292,6 @@ debug = true [patch.crates-io] rfd = { git = "https://github.com/timon-schelling/rfd.git", branch = "graphite" } # TODO: Remove this once https://github.com/PolyMeilex/rfd/pull/317 is merged and released # TODO: Remove these once the fork's fixes are upstreamed and released -vello_gpu = { git = "https://github.com/Keavon/vello.git", rev = "7306bb4f206d956a8989173237c406b1d7798b35" } -vello_common = { git = "https://github.com/Keavon/vello.git", rev = "7306bb4f206d956a8989173237c406b1d7798b35" } -vello_gpu_shaders = { git = "https://github.com/Keavon/vello.git", rev = "7306bb4f206d956a8989173237c406b1d7798b35" } +vello_gpu = { git = "https://github.com/Keavon/vello.git", rev = "d11b17aaf78e5c8c2439ad3074d8cffa618994c2" } +vello_common = { git = "https://github.com/Keavon/vello.git", rev = "d11b17aaf78e5c8c2439ad3074d8cffa618994c2" } +vello_gpu_shaders = { git = "https://github.com/Keavon/vello.git", rev = "d11b17aaf78e5c8c2439ad3074d8cffa618994c2" } diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index da3bfeb7fa3..61b8f50330d 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -21,7 +21,7 @@ pub(crate) struct RenderState { ui_texture: Option, bind_group: Option, #[derivative(Debug = "ignore")] - overlays_scene: Option, + overlays_scene: Option>, #[derivative(Debug = "ignore")] overlays_canvas: VelloCanvas, surface_outdated: bool, @@ -220,7 +220,7 @@ impl RenderState { self.viewport_offset = offset; } - pub(crate) fn set_overlays_scene(&mut self, scene: VelloSceneBuilder) { + pub(crate) fn set_overlays_scene(&mut self, scene: VelloSceneBuilder<'static>) { self.surface_outdated = true; self.overlays_scene = Some(scene); } diff --git a/desktop/wrapper/Cargo.toml b/desktop/wrapper/Cargo.toml index dd338ffe74e..2245795fb49 100644 --- a/desktop/wrapper/Cargo.toml +++ b/desktop/wrapper/Cargo.toml @@ -15,7 +15,7 @@ publish.workspace = true workspace = true [features] -gpu = ["graphite-editor/gpu", "graphene-std/shader-nodes"] +gpu = ["graphite-editor/gpu"] [dependencies] # Local dependencies diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 092d919ed61..f428aaa0b42 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -43,7 +43,7 @@ pub enum DesktopFrontendMessage { WindowUpdateDirectInput { enabled: bool, }, - UpdateOverlays(wgpu_executor::VelloSceneBuilder), + UpdateOverlays(wgpu_executor::VelloSceneBuilder<'static>), PersistenceWritePreferences { preferences: Preferences, }, diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 0c9a7418797..2229c162b25 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -3,7 +3,6 @@ use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; -use graph_craft::application_io::EditorPreferences; #[derive(ExtractField)] pub struct PreferencesDialogMessageContext<'a> { @@ -362,44 +361,10 @@ impl PreferencesDialogMessageHandler { // ============= { let is_desktop = cfg!(not(target_family = "wasm")); - let header = vec![TextLabel::new("Compatibility").italic(true).widget_instance()]; - - let render_tile_resolution_description = " - Maximum X or Y resolution per render tile. Larger tiles may improve performance but can cause flickering or missing content in complex artwork if set too high.\n\ - \n\ - *Default: 1280 px.* - " - .trim(); - let render_tile_resolution_label = vec![ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - TextLabel::new("Render Tile Resolution") - .tooltip_label("Render Tile Resolution") - .tooltip_description(render_tile_resolution_description) - .widget_instance(), - ]; - let render_tile_resolution = vec![ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - NumberInput::new(Some(preferences.max_render_region_size as f64)) - .tooltip_label("Render Tile Resolution") - .tooltip_description(render_tile_resolution_description) - .mode_range() - .int() - .min(256.) - .max(4096.) - .increment_step(256.) - .unit(" px") - .on_update(|number_input: &NumberInput| { - let size = number_input.value.unwrap_or(EditorPreferences::default().max_render_region_size as f64) as u32; - PreferencesMessage::MaxRenderRegionSize { size }.into() - }) - .widget_instance(), - ]; - - rows.extend_from_slice(&[header, render_tile_resolution_label, render_tile_resolution]); - if is_desktop { + let header = vec![TextLabel::new("Compatibility").italic(true).widget_instance()]; + rows.push(header); + let ui_acceleration_description = " Use the CPU to draw the Graphite user interface (areas outside of the canvas) instead of the GPU. This does not affect the rendering of artwork in the canvas, which remains hardware accelerated.\n\ \n\ diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 3c2be4dd2c9..0dc8070def7 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -263,8 +263,11 @@ pub enum FrontendMessage { UpdateRenderStatus { status: RenderStatus, }, + /// Gives the frontend the canvas images it lacks, and names every canvas the latest render uses so it can drop the rest. UpdateImageData { image_data: Vec, + #[serde(rename = "canvasIdsInUse")] + canvas_ids_in_use: Vec, }, UpdateDocumentLayerDetails { data: LayerPanelEntry, 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 b680cfaff48..97ab633f4bb 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 @@ -10,6 +10,7 @@ use graphene_std::animation::RealTimeMode; use graphene_std::blending::BlendMode; use graphene_std::color::SRGBA8; use graphene_std::core_types::misc::format_f64; +use graphene_std::generator::Generator; use graphene_std::gradient::Gradient; use graphene_std::list::{INTERNAL_ATTRIBUTE_PREFIX, Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; @@ -31,7 +32,7 @@ use graphene_std::vector::style::{ DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin, }; use graphene_std::vector::{CurveType, Path, PointId, QRCodeErrorCorrectionLevel, SegmentId, Vector}; -use graphene_std::{Appearance, Artboard, Color, Context, Cover, Coverage, Graphic}; +use graphene_std::{Appearance, Artboard, BevelDirection, BevelStyle, Color, Context, Cover, Coverage, Effect, EffectKind, Effects, GlowSource, Graphic}; use kurbo::BezPath; use std::any::Any; use std::sync::Arc; @@ -192,6 +193,7 @@ fn generate_layout(introspected_data: &Arc>, List, List, + List, List, List, List, @@ -248,6 +250,7 @@ fn generate_layout(introspected_data: &Arc>, Item, Item, + Item, Item, Item, Item, @@ -546,6 +549,45 @@ impl TableItemLayout for Coverage { } } +impl TableItemLayout for Effects { + fn type_name() -> &'static str { + "Effects" + } + fn identifier(&self) -> String { + "Effects".to_string() + } + // The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level + fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec { + self.value_page(data) + } + // Label the spreadsheet's element button with the inner list's identifier, like Appearance + fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec { + self.0.value_widgets(target, data) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + self.0.layout_with_breadcrumb(data) + } +} + +impl TableItemLayout for Effect { + fn type_name() -> &'static str { + "Effect" + } + fn identifier(&self) -> String { + "Effect".to_string() + } + // The wrapping row already contributes the breadcrumb; the inner item supplies the next level + fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec { + self.value_page(data) + } + fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec { + self.0.value_widgets(target, data) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + self.0.layout_with_breadcrumb(data) + } +} + impl TableItemLayout for TransferCurve { fn type_name() -> &'static str { "TransferCurve" @@ -611,6 +653,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(item) => item.identifier(), Self::Color(item) => item.identifier(), Self::Gradient(item) => item.identifier(), + Self::Generator(item) => item.identifier(), Self::Text(item) => item.identifier(), Self::NoneList(list) => list.identifier(), Self::GraphicList(list) => list.identifier(), @@ -619,6 +662,7 @@ impl TableItemLayout for Graphic { Self::RasterGPUList(list) => list.identifier(), Self::ColorList(list) => list.identifier(), Self::GradientList(list) => list.identifier(), + Self::GeneratorList(list) => list.identifier(), Self::TextList(list) => list.identifier(), Self::StrokeList(list) => list.identifier(), } @@ -636,6 +680,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(item) => item.layout_with_breadcrumb(data), Self::Color(item) => item.layout_with_breadcrumb(data), Self::Gradient(item) => item.layout_with_breadcrumb(data), + Self::Generator(item) => item.layout_with_breadcrumb(data), Self::Text(item) => item.layout_with_breadcrumb(data), Self::NoneList(list) => list.layout_with_breadcrumb(data), Self::GraphicList(list) => list.layout_with_breadcrumb(data), @@ -644,6 +689,7 @@ impl TableItemLayout for Graphic { Self::RasterGPUList(list) => list.layout_with_breadcrumb(data), Self::ColorList(list) => list.layout_with_breadcrumb(data), Self::GradientList(list) => list.layout_with_breadcrumb(data), + Self::GeneratorList(list) => list.layout_with_breadcrumb(data), Self::TextList(list) => list.layout_with_breadcrumb(data), Self::StrokeList(list) => list.layout_with_breadcrumb(data), } @@ -704,18 +750,7 @@ impl TableItemLayout for Raster { return vec![LayoutGroup::row(widgets)]; } - let base64_string = raster.base64_string.clone().unwrap_or_else(|| { - use base64::Engine; - - let output = raster.to_png(); - let preamble = "data:image/png;base64,"; - let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); - base64_string.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); - base64_string - }); - - let widgets = vec![ImageLabel::new(base64_string).widget_instance()]; + let widgets = vec![ImageLabel::new(self.png_data_url().to_string()).widget_instance()]; vec![LayoutGroup::row(widgets)] } } @@ -978,6 +1013,11 @@ macro_rules! impl_table_item_layout_for_choice_enum { impl_table_item_layout_for_choice_enum!( BlendMode, Cover, + BevelDirection, + BevelStyle, + EffectKind, + Generator, + GlowSource, GradientForm, GradientSpread, GradientSpace, @@ -1124,9 +1164,11 @@ macro_rules! known_item_types { List>, List, List, + List, List, List, Gradient, + Generator, Color, NodeId, DAffine2, @@ -1148,6 +1190,12 @@ macro_rules! known_item_types { Appearance, Coverage, Cover, + Effects, + Effect, + EffectKind, + GlowSource, + BevelStyle, + BevelDirection, DashPattern, BoxCorners, TransferCurve, diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index c5660d5618a..b7b3498cd99 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -315,6 +315,9 @@ pub enum DocumentMessage { UpdateAppearanceAttributes { appearance_attributes: HashMap>, }, + UpdateEffectsReach { + effects_reach: HashMap, + }, Undo, UngroupSelectedLayers, UngroupLayer { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 3cbdd8831b2..c45c1000709 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1877,6 +1877,14 @@ impl MessageHandler> for DocumentMes .collect(); self.network_interface.update_appearance_attributes(layer_appearance_attributes); } + DocumentMessage::UpdateEffectsReach { effects_reach } => { + let layer_effects_reach = effects_reach + .into_iter() + .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id) && self.network_interface.is_layer(node_id, &[])) + .map(|(node_id, reach)| (LayerNodeIdentifier::new(node_id, &self.network_interface), reach)) + .collect(); + self.network_interface.update_effects_reach(layer_effects_reach); + } DocumentMessage::Undo => { if self.network_interface.transaction_status() != TransactionStatus::Finished { return; diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index a72e32d4d16..c57fb826bbd 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -757,20 +757,26 @@ impl<'a> ModifyInputsContext<'a> { self.responses.add(NodeGraphMessage::RunDocumentGraph); } - /// Write the Gradient Form to the last 'Gradient Form' node in the chain, inserting one only when the value differs + /// Write the Gradient Form's curvatures to the last 'Gradient Curvature' node in the chain, inserting one only when the form differs /// from the default (`Linear`). pub fn gradient_form_set(&mut self, gradient_form: GradientForm) { + use graphene_std::math_nodes::gradient_curvature::{EndCurvatureInput, IDENTIFIER, StartCurvatureInput}; + use graphene_std::renderer::dial_gradient::{LINEAR_DIALS, RADIAL_DIALS}; + let Some(output_layer) = self.get_output_layer() else { return }; let target_input = gradient_chain_target_input(output_layer, self.network_interface); - let identifier = graphene_std::math_nodes::gradient_form::IDENTIFIER; let create_if_nonexistent = gradient_form != GradientForm::default(); - let Some(node_id) = self.existing_proto_node_id_at(&target_input, identifier, create_if_nonexistent) else { + let Some(node_id) = self.existing_proto_node_id_at(&target_input, IDENTIFIER, create_if_nonexistent) else { return; }; - let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_form::GradientFormInput); - self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientForm(gradient_form), false), false); + let [start, end] = match gradient_form { + GradientForm::Linear => LINEAR_DIALS, + GradientForm::Radial => RADIAL_DIALS, + }; + self.set_input_with_refresh(InputConnector::node(node_id, StartCurvatureInput), NodeInput::value(TaggedValue::Number(start), false), false); + self.set_input_with_refresh(InputConnector::node(node_id, EndCurvatureInput), NodeInput::value(TaggedValue::Number(end), false), false); } /// Set the spread on the chain's gradient value, which is where the ramp carries it. diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 43823d5fec1..67a81101de3 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -879,6 +879,7 @@ fn static_node_inspector() -> NodeInspector { map.insert("exposure_inspector".to_string(), Box::new(node_inspector::exposure_inspector)); map.insert("format_number_inspector".to_string(), Box::new(node_inspector::format_number_inspector)); map.insert("string_capitalization_inspector".to_string(), Box::new(node_inspector::string_capitalization_inspector)); + map.insert("gradient_curvature_inspector".to_string(), Box::new(node_inspector::gradient_curvature_inspector)); map.insert("rectangle_inspector".to_string(), Box::new(node_inspector::rectangle_inspector)); map.insert("grid_inspector".to_string(), Box::new(node_inspector::grid_inspector)); map.insert("spiral_inspector".to_string(), Box::new(node_inspector::spiral_inspector)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs index 3c0d0b0d48f..4e4f0930cd6 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs @@ -40,7 +40,7 @@ use graphene_std::vector::style::{ build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; -use graphene_std::{Artboard, Graphic, Vector}; +use graphene_std::{Artboard, BevelDirection, BevelStyle, GlowSource, Graphic, Vector}; use graphene_std::{NodeParameter, ParameterRef}; use math_parser::parser::{MessagePart, ParseError}; use std::path::PathBuf; @@ -388,6 +388,7 @@ pub(crate) fn inspector_row_from_type(node_id: NodeId, index: usize, ty: &Type, Some(x) if id_is::(x) => transform_widget(default_info, &mut extra_widgets), Some(x) if id_is::(x) => color_widget(default_info, ColorInput::default().allow_none(false)), Some(x) if id_is::(x) => color_widget(default_info, ColorInput::default().allow_none(false)), + Some(x) if id_is::(x) => color_widget(default_info, ColorInput::default()), // ============ // STRUCT TYPES // ============ @@ -438,6 +439,9 @@ pub(crate) fn inspector_row_from_type(node_id: NodeId, index: usize, ty: &Type, Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), @@ -2984,6 +2988,79 @@ pub(crate) fn string_capitalization_inspector(node_id: NodeId, context: &mut Nod vec![capitalization, LayoutGroup::row(joiner_widgets), LayoutGroup::row(joiner_preset_buttons)] } +pub(crate) fn gradient_curvature_inspector(node_id: NodeId, context: &mut NodeInspectorContext) -> Vec { + use graphene_std::math_nodes::gradient_curvature::*; + use graphene_std::renderer::dial_gradient::{LINEAR_DIALS, RADIAL_DIALS}; + + // The curvatures set in the panel, or `None` when either is fed from the graph, so it can't take a preset + let curvatures = match get_document_node(node_id, context) { + Ok(document_node) => { + let curvature = |index: usize| match document_node.inputs.get(index).and_then(|input| input.as_non_exposed_value()) { + Some(&TaggedValue::Number(value)) => Some(value), + _ => None, + }; + curvature(StartCurvatureInput::INDEX).zip(curvature(EndCurvatureInput::INDEX)).map(|(start, end)| [start, end]) + } + Err(err) => { + log::error!("Could not get document node in gradient_curvature_inspector: {err}"); + return Vec::new(); + } + }; + + // Each form's button sets both curvatures to the form's own, and stands out while they're exactly those + let mut form = vec![ + TextLabel::new("Form") + .tooltip_label("Form") + .tooltip_description("Sets both curvatures to those of a standard gradient form.") + .widget_instance(), + ]; + add_blank_assist(&mut form); + form.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + for (label, [start, end], tooltip) in [ + ("Linear", LINEAR_DIALS, "Sets both curvatures to 0, making straight contours."), + ("Radial", RADIAL_DIALS, "Sets the start curvature to 1 and the end curvature to 0.5, making circles around the start."), + ] { + form.push( + TextButton::new(label) + .tooltip_description(tooltip) + .emphasized(curvatures == Some([start, end])) + .disabled(curvatures.is_none()) + .on_update(move |_: &TextButton| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: StartCurvatureInput::INDEX, + value: TaggedValue::Number(start).into(), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: EndCurvatureInput::INDEX, + value: TaggedValue::Number(end).into(), + } + .into(), + ]), + }) + .on_commit(commit_value) + .widget_instance(), + ); + } + + // The same sliders the curvatures would get without this panel + let options = ParameterOptions { + soft_min: Some(-1.), + soft_max: Some(1.), + slider: true, + ..Default::default() + }; + let mut curvature = |index: usize| inspector_row_from_type(node_id, index, &concrete!(f64), options, context).unwrap_or_else(|rows| rows); + + let mut layout = vec![LayoutGroup::row(form)]; + layout.extend(curvature(StartCurvatureInput::INDEX)); + layout.extend(curvature(EndCurvatureInput::INDEX)); + layout +} + pub(crate) fn rectangle_inspector(node_id: NodeId, context: &mut NodeInspectorContext) -> Vec { use graphene_std::vector::generator_nodes::rectangle::*; diff --git a/editor/src/messages/portfolio/document/node_graph/utility_types.rs b/editor/src/messages/portfolio/document/node_graph/utility_types.rs index 2a11dd4a3da..6bc5e9f5f14 100644 --- a/editor/src/messages/portfolio/document/node_graph/utility_types.rs +++ b/editor/src/messages/portfolio/document/node_graph/utility_types.rs @@ -34,6 +34,8 @@ impl FrontendGraphDataType { "Gradient" => Self::Gradient, "String" => Self::Typography, "f64" | "i64" | "bool" | "DVec2" | "DAffine2" => Self::Number, + // A generator draws pixels like an image, at whatever resolution is needed + "Generator" => Self::Raster, raster if raster.starts_with("Raster") => Self::Raster, _ => Self::General, } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index a5200626150..510ed3aa9b6 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -270,7 +270,7 @@ impl OverlayContext { } } - pub fn take_scene(self) -> VelloSceneBuilder { + pub fn take_scene(self) -> VelloSceneBuilder<'static> { let mut internal = self.internal.lock().expect("Failed to lock internal overlay context"); let empty_scene = overlay_scene(&internal.viewport); std::mem::replace(&mut internal.scene, empty_scene) @@ -486,13 +486,13 @@ pub enum DrawHandles { } pub(super) struct OverlayContextInternal { - scene: VelloSceneBuilder, + scene: VelloSceneBuilder<'static>, viewport: ViewportMessageHandler, visibility_settings: OverlaysVisibilitySettings, } /// An empty scene covering the viewport's physical pixels, matching the resolution the viewport renders at. -fn overlay_scene(viewport: &ViewportMessageHandler) -> VelloSceneBuilder { +fn overlay_scene(viewport: &ViewportMessageHandler) -> VelloSceneBuilder<'static> { // The smallest texture size limit WebGL2 guarantees, which only matters for the overlays' tiny pattern images const MAX_TEXTURE_SIZE: u32 = 2048; diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 442eb0cf679..c7764fb2e8f 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -51,6 +51,8 @@ pub struct DocumentMetadata { pub layer_vector_data: HashMap>>, /// Per-layer `ATTR_APPEARANCE` attribute, exposed so message handlers can read paint information that lives on the list. pub layer_appearance_attributes: HashMap>, + /// How far each layer's effects paint past its content, in document pixels, for the layers with effects. + pub layer_effects_reach: HashMap, /// Transform from document space to viewport space. pub document_to_viewport: DAffine2, /// The transform from document space to viewport space that the latest render was made with, which is the one `upstream_footprints` include. @@ -347,21 +349,22 @@ impl DocumentMetadata { } /// Get the bounding box of the specified layer in document space, expanded to include the actual rendered - /// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based - /// bounds for non-vector layers (groups, raster, text, color, gradient). - pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> { - if let Some(item) = self.layer_vector_data.get(&layer) { + /// stroke geometry when the layer is a vector with a stroke style, and how far its effects paint past it. + /// Falls back to the click-target-based bounds for non-vector layers (groups, raster, text, color, gradient). + pub fn bounding_box_document_with_stroke_and_effects(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> { + let stroke_bounds = self.layer_vector_data.get(&layer).and_then(|item| { let stroke = self .layer_appearance_attributes .get(&layer) .and_then(|appearance| appearance.first_coverage_of(graphene_std::Cover::Stroke)) .map(graphene_std::Coverage::stroke_params); let paths = paths_from_item_attributes(item.as_ref()).unwrap_or_else(|| no_paths()); - if let Some(bounds) = item.element().stroke_inclusive_bounding_box_with_transform(paths, self.transform_to_document(layer), stroke.as_ref()) { - return Some(bounds); - } - } - self.bounding_box_document(layer) + item.element().stroke_inclusive_bounding_box_with_transform(paths, self.transform_to_document(layer), stroke.as_ref()) + }); + let [min, max] = stroke_bounds.or_else(|| self.bounding_box_document(layer))?; + + let reach = self.layer_effects_reach.get(&layer).copied().unwrap_or_default(); + Some([min - reach, max + reach]) } /// Get the bounding box of the click target of the specified layer in viewport space diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index cd8229f4bf3..99fb3ac333e 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -887,11 +887,11 @@ impl NodeNetworkInterface { Some(transformed.bounding_box()) } - /// Calculates the document bounds in document space, expanding vector layer bounds to include the rendered - /// stroke width. Used for export so the output canvas captures strokes that overflow the path geometry. - pub fn document_bounds_document_space_with_stroke(&self, include_artboards: bool) -> Option<[DVec2; 2]> { + /// Calculates the document bounds in document space, expanding layer bounds to include the rendered stroke width and + /// effects. Used for export so the output canvas captures strokes and shadows that overflow the path geometry. + pub fn document_bounds_document_space_with_stroke_and_effects(&self, include_artboards: bool) -> Option<[DVec2; 2]> { self.document_metadata.document_bounds(include_artboards, true, || { - self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document_with_stroke(layer)) + self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document_with_stroke_and_effects(layer)) }) } @@ -908,17 +908,17 @@ impl NodeNetworkInterface { .reduce(Quad::combine_bounds) } - /// Calculates the selected layer bounds in document space, expanding vector layer bounds to include the - /// rendered stroke width. Used for export so the output canvas captures strokes that overflow the path geometry. - pub fn selected_bounds_document_space_with_stroke(&self, include_artboards: bool, network_path: &[NodeId]) -> Option<[DVec2; 2]> { + /// Calculates the selected layer bounds in document space, expanding layer bounds to include the rendered stroke width + /// and effects. Used for export so the output canvas captures strokes and shadows that overflow the path geometry. + pub fn selected_bounds_document_space_with_stroke_and_effects(&self, include_artboards: bool, network_path: &[NodeId]) -> Option<[DVec2; 2]> { let Some(selected_nodes) = self.selected_nodes_in_nested_network(network_path) else { - log::error!("Could not get selected nodes in selected_bounds_document_space_with_stroke"); + log::error!("Could not get selected nodes in selected_bounds_document_space_with_stroke_and_effects"); return None; }; selected_nodes .selected_layers(&self.document_metadata) .filter(|&layer| include_artboards || !self.is_artboard(&layer.to_node(), &[])) - .filter_map(|layer| self.document_metadata.bounding_box_document_with_stroke(layer)) + .filter_map(|layer| self.document_metadata.bounding_box_document_with_stroke_and_effects(layer)) .reduce(Quad::combine_bounds) } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs index ac020869b1a..7127bf0f79a 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs @@ -200,4 +200,9 @@ impl NodeNetworkInterface { pub fn update_appearance_attributes(&mut self, new_layer_appearance_attributes: HashMap>) { self.document_metadata.layer_appearance_attributes = new_layer_appearance_attributes; } + + /// Update how far each layer's effects paint past its content. + pub fn update_effects_reach(&mut self, new_layer_effects_reach: HashMap) { + self.document_metadata.layer_effects_reach = new_layer_effects_reach; + } } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index b7f944d648a..a89703d94d6 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -20,7 +20,7 @@ use graphene_std::text::{TextAlign, TypesettingConfig}; use graphene_std::transform::ScaleType; use graphene_std::uuid::NodeId; use graphene_std::vector::graphic_types; -use graphene_std::vector::style::{GradientRamp, GradientSpread, PaintOrder, StrokeAlign}; +use graphene_std::vector::style::{GradientForm, GradientRamp, GradientSpread, PaintOrder, StrokeAlign}; use std::collections::{HashMap, HashSet}; use std::f64::consts::PI; use std::ops::Range; @@ -299,10 +299,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::math_nodes::footprint_value::IDENTIFIER, aliases: &["graphene_math_nodes::FootprintValueNode", "graphene_core::ops::FootprintValueNode"], }, - NodeReplacement { - node: graphene_std::math_nodes::gradient_form::IDENTIFIER, - aliases: &["math_nodes::GradientTypeNode"], - }, NodeReplacement { node: graphene_std::math_nodes::gradient_spread::IDENTIFIER, aliases: &["math_nodes::SpreadMethodNode"], @@ -511,8 +507,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::filter::blur::IDENTIFIER, - aliases: &["graphene_raster_nodes::filter::BlurNode", "graphene_std::filter::BlurNode"], + node: graphene_std::blending_nodes::effects::blur::IDENTIFIER, + aliases: &["raster_nodes::filter::BlurNode", "graphene_raster_nodes::filter::BlurNode", "graphene_std::filter::BlurNode"], }, NodeReplacement { node: graphene_std::raster_nodes::adjustments::brightness_contrast::IDENTIFIER, @@ -531,8 +527,9 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_core::raster::ChannelMixerNode", ], }, + // Color Overlay became Paint Overlay, whose paint input takes the old color input's value as a solid paint NodeReplacement { - node: graphene_std::raster_nodes::blending_nodes::color_overlay::IDENTIFIER, + node: graphene_std::blending_nodes::effects::paint_overlay::IDENTIFIER, aliases: &[ "graphene_raster_nodes::adjustments::ColorOverlayNode", "graphene_raster_nodes::generate_curves::ColorOverlayNode", @@ -540,6 +537,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_core::raster::adjustments::ColorOverlayNode", "raster_nodes::generate_curves::ColorOverlayNode", "graphene_raster_nodes::blending_nodes::ColorOverlayNode", + "raster_nodes::blending_nodes::ColorOverlayNode", ], }, NodeReplacement { @@ -1195,10 +1193,48 @@ fn is_monitored_transform_subgraph(node: &DocumentNode) -> bool { monitor_reads_first_import && transform_reads_monitor_and_other_imports && exports_transform } +/// The CPU node a removed GPU twin was generated from, as named in its era, like `raster_nodes::adjustments::InvertNode` for `raster_nodes::adjustments::invert_shader_node::InvertShaderNodeNode`. +fn cpu_node_of_gpu_twin(identifier: &str) -> Option { + let identifier = identifier.split('<').next()?; + let (module_path, struct_name) = identifier.rsplit_once("::")?; + let (parent_module_path, twin_module) = module_path.rsplit_once("::")?; + let cpu_struct_name = struct_name.strip_suffix("ShaderNodeNode")?; + + twin_module.ends_with("_shader_node").then(|| format!("{parent_module_path}::{cpu_struct_name}Node")) +} + pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) { document.network_interface.migrate_path_modify_node(); document.network_interface.normalize_stored_types(); + // The removed GPU twins become their CPU nodes minus the trailing executor input, so the renames below carry them forward like their era's CPU nodes + let gpu_twins = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let DocumentNodeImplementation::ProtoNode(identifier) = &node.implementation else { return None }; + let kept_input_count = node.inputs.len().saturating_sub(1); + Some((*node_id, path, cpu_node_of_gpu_twin(identifier.as_str())?, node.inputs[..kept_input_count].to_vec())) + }) + .collect::>(); + for (node_id, network_path, cpu_identifier, inputs) in gpu_twins { + let input_metadata = document + .network_interface + .node_metadata(&node_id, &network_path) + .map(|metadata| metadata.persistent_metadata.input_metadata.iter().take(inputs.len()).cloned().collect()) + .unwrap_or_default(); + + let mut node_template = NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::with_owned_string(cpu_identifier)), + inputs, + input_metadata, + ..Default::default() + }; + document.network_interface.replace_implementation(&node_id, &network_path, &mut node_template); + document.network_interface.replace_inputs(&node_id, &network_path, &mut node_template); + } + let network = document.network_interface.document_network().clone(); // Apply string and node replacements to each node @@ -1223,6 +1259,10 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ }; document.network_interface.replace_implementation(node_id, &network_path, &mut default_template); document.network_interface.set_call_argument(node_id, &network_path, default_template.call_argument); + + if *new == graphene_std::blending_nodes::effects::paint_overlay::IDENTIFIER { + reset_default_display_name(document, node_id, &network_path, "Color Overlay"); + } } } } @@ -1563,6 +1603,49 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } + // The "Gradient Form" node was replaced by "Gradient Curvature", whose two curvatures take the presets of the form it set + const GRADIENT_FORM: [&str; 2] = ["math_nodes::GradientFormNode", "math_nodes::GradientTypeNode"]; + let gradient_form_nodes: Vec<(NodeId, Vec)> = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let DocumentNodeImplementation::ProtoNode(identifier) = &node.implementation else { return None }; + GRADIENT_FORM.contains(&identifier.as_str().split('<').next().unwrap_or_default()).then_some((*node_id, path)) + }) + .collect(); + for (node_id, network_path) in &gradient_form_nodes { + let Some(definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_curvature::IDENTIFIER)) else { + continue; + }; + let mut node_template = definition.default_node_template(); + + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else { + continue; + }; + reset_default_display_name(document, node_id, network_path, "Gradient Form"); + + if let Some(input) = old_inputs.first() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), input.clone(), network_path); + } + + let [start, end] = match old_inputs.get(1).and_then(|input| input.as_value()) { + Some(TaggedValue::GradientForm(GradientForm::Radial)) => graphene_std::renderer::dial_gradient::RADIAL_DIALS, + Some(TaggedValue::GradientForm(GradientForm::Linear)) => graphene_std::renderer::dial_gradient::LINEAR_DIALS, + _ => { + log::warn!("Old \"Gradient Form\" node {node_id} has no fixed form to migrate, so it becomes linear"); + graphene_std::renderer::dial_gradient::LINEAR_DIALS + } + }; + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::Number(start), false), network_path); + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 2), NodeInput::value(TaggedValue::Number(end), false), network_path); + } + // "Position on Path" and "Tangent on Path" were merged into the multi-output "Evaluate Path" node, with position at output 0 and tangent direction at output 1. // Tangent instances get an "Inverse Tangent" node inserted to turn that direction back into the angle their downstream wires expect. const POSITION_ON_PATH: &str = "graphene_core::vector::PositionOnPathNode"; @@ -1741,6 +1824,52 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } + // Noise Pattern and Mandelbrot once drew images cut off at a square, 100 units for a clipped Noise Pattern and 1 for Mandelbrot, where their + // patterns now fill the plane. An old Noise Pattern has a Clip toggle, after the primary input or first from before that input existed, + // and an old Mandelbrot lacks the primary input. Each takes the current inputs, with a square filled by its pattern where it was cut off. + let old_pattern_nodes = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let reference = document.network_interface.reference(node_id, &path)?; + let clipped = |clip_index: usize| !matches!(node.inputs.get(clip_index).and_then(|input| input.as_value()), Some(TaggedValue::Bool(false))); + + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) { + let starts_with_clip = matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::Bool(_))); + let clip_index = match node.inputs.len() { + 16 => 1, + 15 if starts_with_clip => 0, + _ => return None, + }; + return Some((*node_id, path, reference, Some(clip_index), clipped(clip_index).then_some(100.))); + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::mandelbrot::IDENTIFIER) && node.inputs.is_empty() { + return Some((*node_id, path, reference, None, Some(1.))); + } + None + }) + .collect::>(); + for (node_id, network_path, reference, clip_index, clipped_size) in &old_pattern_nodes { + let Some(definition) = resolve_document_node_type(reference) else { continue }; + let mut node_template = definition.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else { + continue; + }; + + // The noise settings follow the Clip toggle, in the same order as the current inputs after the primary one + if let Some(clip_index) = clip_index { + for (offset, input) in old_inputs.iter().skip(clip_index + 1).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1 + offset), input.clone(), network_path); + } + } + + if let Some(size) = clipped_size { + fill_square_with_pattern(document, node_id, network_path, *size); + } + } + // Parameters that historically accepted fractional numbers are now integers (the polygon and star "Sides", the index, // shift amount, and slice bound parameters of the list, string, and regex nodes, the "Path is Closed" index, and the // "Blend" count), so a stored F64 truncates as those slots' own casts and Floor node did, preserving what documents drew @@ -1794,6 +1923,76 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ document.require_whole_document_stage(); } +/// Splices a rectangle filled with the pattern node's output onto the wires leaving it, covering the square from the origin of the pattern's space out to `size`. +/// The rectangle is centered on its origin, so a Transform moves it onto the square, and the paint is moved back by the same amount to stay in place. +fn fill_square_with_pattern(document: &mut DocumentMessageHandler, pattern_node_id: &NodeId, network_path: &[NodeId], size: f64) -> Option<()> { + let downstream_consumers: Vec = document + .network_interface + .outward_wires(network_path) + .and_then(|wires| wires.get(&OutputConnector::node(*pattern_node_id, 0))) + .cloned() + .unwrap_or_default(); + let was_in_chain = document.network_interface.is_chain(pattern_node_id, network_path); + + let template = |identifier| resolve_document_node_type(&DefinitionIdentifier::ProtoNode(identifier)).map(|definition| definition.default_node_template()); + let rectangle_template = template(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)?; + let fill_template = template(graphene_std::vector_nodes::fill::IDENTIFIER)?; + let transform_template = template(graphene_std::transform_nodes::transform::IDENTIFIER)?; + + let [rectangle_id, fill_id, paint_transform_id, placement_transform_id] = [(); 4].map(|_| NodeId::new()); + document.network_interface.insert_node(rectangle_id, rectangle_template, network_path); + document.network_interface.insert_node(fill_id, fill_template, network_path); + document.network_interface.insert_node(paint_transform_id, transform_template.clone(), network_path); + document.network_interface.insert_node(placement_transform_id, transform_template, network_path); + + let set_value = |document: &mut DocumentMessageHandler, node_id: NodeId, index: usize, value: TaggedValue| { + document + .network_interface + .set_input(&InputConnector::node_at_index(node_id, index), NodeInput::value(value, false), network_path); + }; + let wire = |document: &mut DocumentMessageHandler, node_id: NodeId, index: usize, source: NodeId| { + document + .network_interface + .set_input(&InputConnector::node_at_index(node_id, index), NodeInput::node(source, 0), network_path); + }; + + // The rectangle's width and height, and the half-square translations of the paint and the filled rectangle + set_value(document, rectangle_id, 1, TaggedValue::Number(size)); + set_value(document, rectangle_id, 2, TaggedValue::Number(size)); + set_value(document, paint_transform_id, 1, TaggedValue::DVec2(DVec2::splat(-size / 2.))); + set_value(document, placement_transform_id, 1, TaggedValue::DVec2(DVec2::splat(size / 2.))); + + wire(document, paint_transform_id, 0, *pattern_node_id); + wire(document, fill_id, 0, rectangle_id); + wire(document, fill_id, 1, paint_transform_id); + wire(document, placement_transform_id, 0, fill_id); + for consumer in &downstream_consumers { + document.network_interface.set_input(consumer, NodeInput::node(placement_transform_id, 0), network_path); + } + + // A pattern in a layer chain gives way to the filled rectangle there, moving below to paint it, and otherwise the new nodes sit beside it + if was_in_chain { + document.network_interface.force_set_upstream_to_chain(&placement_transform_id, network_path); + + if let Some(fill_position) = document.network_interface.position(&fill_id, network_path) { + document.network_interface.set_absolute_position(&paint_transform_id, fill_position + IVec2::new(-7, 3), network_path); + document.network_interface.set_absolute_position(pattern_node_id, fill_position + IVec2::new(-14, 3), network_path); + } + } else if let Some(position) = document.network_interface.position(pattern_node_id, network_path) { + let offsets = [ + (paint_transform_id, IVec2::new(7, 0)), + (rectangle_id, IVec2::new(7, -3)), + (fill_id, IVec2::new(14, 0)), + (placement_transform_id, IVec2::new(21, 0)), + ]; + for (node_id, offset) in offsets { + document.network_interface.shift_absolute_node_position(&node_id, position + offset, network_path); + } + } + + Some(()) +} + /// Converts a legacy stroke dash input (a `List`, single `f64`, or comma/space separated `String`) to the `DashPattern` value type. fn migrate_dash_input(input: &NodeInput) -> Option { let NodeInput::Value { tagged_value, exposed } = input else { return None }; @@ -2324,7 +2523,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], (graphene_std::artboard::create_artboard::BackgroundInput.into(), Color::WHITE), (graphene_std::math_nodes::color_value::ColorInput.into(), Color::TRANSPARENT), (graphene_std::raster_nodes::adjustments::black_and_white::TintInput.into(), Color::BLACK), - (graphene_std::raster_nodes::blending_nodes::color_overlay::ColorInput.into(), Color::BLACK), ]; for (parameter, fallback) in conversions { if reference != DefinitionIdentifier::ProtoNode(parameter.node_identifier.clone()) { @@ -2645,20 +2843,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } - if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) && inputs_count == 15 { - let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); - - let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - - document - .network_interface - .set_input(&InputConnector::node_at_index(*node_id, 0), NodeInput::value(TaggedValue::None, false), network_path); - for (i, input) in old_inputs.iter().enumerate() { - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, i + 1), input.clone(), network_path); - } - } - // The Threshold node's luminance calculation input was retired if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::adjustments::threshold::IDENTIFIER) && inputs_count == 4 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); @@ -3725,11 +3909,14 @@ fn migrate_removed_catalog_definitions(node_id: &NodeId, node: &DocumentNode, ne } } - // The Upload Texture node's old wrapper-network form maps onto its proto node form, which draws the executor from scope - if let Some(DefinitionIdentifier::Network(name)) = document.network_interface.reference(node_id, network_path) - && name == "Upload Texture" - { - let mut node_template = resolve_proto_node_type(graphene_std::platform_application_io::upload_texture::IDENTIFIER)?.default_node_template(); + // The removed Upload Texture node, in its proto node or older wrapper network form, degrades to a passthrough of its content + let is_upload_texture = match document.network_interface.reference(node_id, network_path) { + Some(DefinitionIdentifier::Network(name)) => name == "Upload Texture", + Some(DefinitionIdentifier::ProtoNode(identifier)) => identifier.as_str().ends_with("::UploadTextureNode"), + None => false, + }; + if is_upload_texture { + let mut node_template = resolve_proto_node_type(graphene_std::ops::passthrough::IDENTIFIER)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; if let Some(content) = old_inputs.first() { @@ -3873,7 +4060,19 @@ mod tests { #[test] fn removed_definition_swap_targets_resolve() { assert!(resolve_proto_node_type(graphene_std::ops::passthrough::IDENTIFIER).is_some()); - assert!(resolve_proto_node_type(graphene_std::platform_application_io::upload_texture::IDENTIFIER).is_some()); + } + + #[test] + fn gpu_twins_name_the_cpu_nodes_they_came_from() { + assert_eq!( + cpu_node_of_gpu_twin("raster_nodes::adjustments::desaturate_shader_node::DesaturateShaderNodeNode").as_deref(), + Some(graphene_std::raster_nodes::adjustments::desaturate::IDENTIFIER.as_str()) + ); + assert_eq!( + cpu_node_of_gpu_twin("graphene_raster_nodes::blending_nodes::color_overlay_shader_node::ColorOverlayShaderNodeNode").as_deref(), + Some("graphene_raster_nodes::blending_nodes::ColorOverlayNode") + ); + assert_eq!(cpu_node_of_gpu_twin(graphene_std::raster_nodes::adjustments::invert::IDENTIFIER.as_str()), None); } // Migrating a Merge network's coercion nodes by alias would leave a reducer in the primary slot, so every alias must reset instead @@ -4332,6 +4531,54 @@ mod tests { assert!(matches!(migrated, NodeInput::Value { exposed: true, .. }), "the exposed flag should be kept"); } + #[test] + fn gradient_form_nodes_become_gradient_curvature_presets() { + use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate; + use graphene_std::renderer::dial_gradient::{LINEAR_DIALS, RADIAL_DIALS}; + + let (source_id, form_id) = (NodeId(1), NodeId(2)); + for (identifier, form, dials) in [ + ("math_nodes::GradientFormNode", GradientForm::Radial, RADIAL_DIALS), + ("math_nodes::GradientTypeNode", GradientForm::Linear, LINEAR_DIALS), + ] { + let mut document = DocumentMessageHandler::default(); + let placeholder = || NodeInput::value(TaggedValue::None, false); + document.network_interface.insert_node( + source_id, + NodeTemplate { + inputs: vec![placeholder()], + ..Default::default() + }, + &[], + ); + document.network_interface.insert_node( + form_id, + NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new(identifier)), + inputs: vec![placeholder(); 2], + ..Default::default() + }, + &[], + ); + document.network_interface.set_input(&InputConnector::node_at_index(form_id, 0), NodeInput::node(source_id, 0), &[]); + document + .network_interface + .set_input(&InputConnector::node_at_index(form_id, 1), NodeInput::value(TaggedValue::GradientForm(form), false), &[]); + migrate(&mut document); + + let node = &document.network_interface.document_network().nodes[&form_id]; + assert!(matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::math_nodes::gradient_curvature::IDENTIFIER)); + assert_eq!(node.inputs.first(), Some(&NodeInput::node(source_id, 0)), "the gradient feeding {identifier} should stay wired"); + + let curvature = |index: usize| node.inputs.get(index).and_then(|input| input.as_value()).cloned(); + assert_eq!( + [curvature(1), curvature(2)], + dials.map(|dial| Some(TaggedValue::Number(dial))), + "{form:?} should become its curvature presets" + ); + } + } + #[test] fn test_no_duplicate_node_replacements() { let mut hashmap = HashMap::::new(); @@ -4844,4 +5091,81 @@ mod tests { assert_eq!(interface.display_name(&node_id, &[]), "Image", "node {node_id}"); } } + + // Noise Pattern and Mandelbrot once drew a square of their patterns, which now fill the plane, so the square becomes a rectangle filled with the pattern + #[tokio::test] + async fn clipped_patterns_become_rectangles_filled_with_them() { + use crate::messages::portfolio::document_storage_io::load_legacy_document; + use crate::test_utils::test_prelude::*; + use graph_craft::application_io::resource::HashMapResourceStorage; + + let proto = |identifier| DefinitionIdentifier::ProtoNode(identifier); + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let noise = editor.create_node_by_name(proto(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER)).await; + let mandelbrot = editor.create_node_by_name(proto(graphene_std::raster_nodes::std_nodes::mandelbrot::IDENTIFIER)).await; + let noise_reader = editor.create_node_by_name(proto(graphene_std::transform_nodes::transform::IDENTIFIER)).await; + let mandelbrot_reader = editor.create_node_by_name(proto(graphene_std::transform_nodes::transform::IDENTIFIER)).await; + + let document = editor.active_document_mut(); + for (reader, pattern) in [(noise_reader, noise), (mandelbrot_reader, mandelbrot)] { + document.network_interface.set_input(&InputConnector::node_at_index(reader, 0), NodeInput::node(pattern, 0), &[]); + } + + // Save them as an older editor did, the Noise Pattern with its Clip toggle on and the Mandelbrot without a primary input + let mut noise_inputs = document.network_interface.document_node(&noise, &[]).unwrap().inputs.clone(); + noise_inputs.insert(1, NodeInput::value(TaggedValue::Bool(true), false)); + noise_inputs[2] = NodeInput::value(TaggedValue::Integer(7), false); + for (node_id, inputs) in [(noise, noise_inputs), (mandelbrot, Vec::new())] { + let mut legacy = NodeTemplate { inputs, ..Default::default() }; + legacy.normalize_input_metadata(); + document.network_interface.replace_inputs(&node_id, &[], &mut legacy); + } + let serialized = document.serialize_document(); + + let opened = load_legacy_document(serialized, &HashMapResourceStorage::new(), false).expect("the document should open"); + let interface = &opened.network_interface; + let input = |node_id: NodeId, index: usize| interface.document_node(&node_id, &[]).and_then(|node| node.inputs.get(index)).cloned(); + let upstream = |node_id: NodeId, index: usize| input(node_id, index).and_then(|input| input.as_node()); + let value = |node_id: NodeId, index: usize| input(node_id, index).and_then(|input| input.as_value().cloned()); + + // Each reader now reads a rectangle of the square's size, moved onto the square, filled with the pattern moved back by the same amount + for (reader, pattern, size) in [(noise_reader, noise, 100.), (mandelbrot_reader, mandelbrot, 1.)] { + let placement = upstream(reader, 0).expect("the reader should read the placement transform"); + assert_eq!(value(placement, 1), Some(TaggedValue::DVec2(DVec2::splat(size / 2.)))); + + let fill = upstream(placement, 0).expect("the placement should move the filled rectangle"); + let rectangle = upstream(fill, 0).expect("the fill should paint a rectangle"); + assert_eq!((value(rectangle, 1), value(rectangle, 2)), (Some(TaggedValue::Number(size)), Some(TaggedValue::Number(size)))); + + let paint = upstream(fill, 1).expect("the fill should paint with the moved pattern"); + assert_eq!(value(paint, 1), Some(TaggedValue::DVec2(DVec2::splat(-size / 2.)))); + assert_eq!(upstream(paint, 0), Some(pattern)); + } + + // The noise settings shift down past the removed Clip toggle, and the Mandelbrot gains its primary input + assert_eq!(interface.document_node(&noise, &[]).unwrap().inputs.len(), 15); + assert_eq!(value(noise, 1), Some(TaggedValue::Integer(7))); + assert_eq!(interface.document_node(&mandelbrot, &[]).unwrap().inputs.len(), 1); + } + + // The demo draws its patterns through layers and adjustments, which must all take the generators the patterns now are + #[tokio::test] + async fn marbled_mandelbrot_renders_after_migration() { + use crate::messages::portfolio::ingest::utility_types::IngestAction; + use crate::messages::prelude::IngestMessage; + use crate::test_utils::test_prelude::*; + + let mut editor = EditorTestUtils::create(); + let content = std::fs::read_to_string("../demo-artwork/marbled-mandelbrot.graphite").unwrap(); + editor.editor.handle_message(IngestMessage::Ingest { + data: content.into_bytes(), + action: IngestAction::Open, + mime_type: String::new(), + path: Some("marbled-mandelbrot.graphite".into()), + }); + if let Err(error) = editor.eval_graph_until_finished().await { + panic!("The migrated demo failed to render: {error}"); + } + } } diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 3be1f19733e..cb1665ba97a 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -47,7 +47,6 @@ pub enum PortfolioMessage { document_id: DocumentId, }, DestroyAllDocuments, - EditorPreferences, GarbageCollectResources, ResolveResources, ResolveDocumentResources { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 3360abaaf30..e675c7e4720 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -141,6 +141,8 @@ impl MessageHandler> for Portfolio // Messages PortfolioMessage::Init => { + self.executor.forget_canvas_images(); + // Tell frontend to load persistent preferences responses.add(FrontendMessage::TriggerLoadPreferences); @@ -344,7 +346,6 @@ impl MessageHandler> for Portfolio responses.add(MenuBarMessage::SendLayout); responses.add(PersistentStateMessage::WriteState); } - PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), PortfolioMessage::GarbageCollectResources => { // The resources of documents that are not loaded yet are unknown, so GC waits for every load and for a successful store listing. if !self.persistent_state.loaded() || self.pending_opens > 0 || !self.loading_documents.is_empty() || self.store_unavailable { diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs index 9c02ee9fb17..9bbee2a3dbf 100644 --- a/editor/src/messages/preferences/preferences_message.rs +++ b/editor/src/messages/preferences/preferences_message.rs @@ -30,9 +30,6 @@ pub enum PreferencesMessage { UIScale { scale: f64, }, - MaxRenderRegionSize { - size: u32, - }, DisableUIAcceleration { disable_ui_acceleration: bool, }, diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 90a8138c2ce..2a3e4ead846 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -4,7 +4,6 @@ use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; use crate::messages::tool::utility_types::ToolType; -use graph_craft::application_io::EditorPreferences; #[derive(ExtractField)] pub struct PreferencesMessageContext<'a> { @@ -21,7 +20,6 @@ pub struct PreferencesMessageHandler { pub graph_wire_style: GraphWireStyle, pub viewport_zoom_wheel_rate: f64, pub ui_scale: f64, - pub max_render_region_size: u32, pub disable_ui_acceleration: bool, pub validate_storage_round_trip: bool, pub save_as_gdd: bool, @@ -46,12 +44,6 @@ impl PreferencesMessageHandler { pub fn get_selection_mode(&self) -> SelectionMode { self.selection_mode } - - pub fn editor_preferences(&self) -> EditorPreferences { - EditorPreferences { - max_render_region_size: self.max_render_region_size, - } - } } impl Default for PreferencesMessageHandler { @@ -63,7 +55,6 @@ impl Default for PreferencesMessageHandler { graph_wire_style: GraphWireStyle::default(), viewport_zoom_wheel_rate: VIEWPORT_ZOOM_WHEEL_RATE, ui_scale: UI_SCALE_DEFAULT, - max_render_region_size: EditorPreferences::default().max_render_region_size, disable_ui_acceleration: cfg!(target_os = "linux"), // TODO: Set this back to false once we have ui acceleration working more reliably on linux validate_storage_round_trip: false, save_as_gdd: false, @@ -84,7 +75,6 @@ impl MessageHandler> for Prefe PreferencesMessage::Load { preferences } => { *self = preferences; - responses.add(PortfolioMessage::EditorPreferences); responses.add(PreferencesMessage::ModifyLayout { zoom_with_scroll: self.zoom_with_scroll, }); @@ -128,11 +118,6 @@ impl MessageHandler> for Prefe self.ui_scale = scale; responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale }); } - PreferencesMessage::MaxRenderRegionSize { size } => { - self.max_render_region_size = size; - responses.add(PortfolioMessage::EditorPreferences); - responses.add(NodeGraphMessage::RunDocumentGraph); - } PreferencesMessage::DisableUIAcceleration { disable_ui_acceleration } => { self.disable_ui_acceleration = disable_ui_acceleration; } diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 28500d3d40e..0ca152e26ee 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -342,7 +342,7 @@ pub fn replaceable_paint_chain(layer: LayerNodeIdentifier, network_interface: &N // A generator discards its primary input, so the chain ends at whichever one is reached let generators = [graphene_std::math_nodes::color_value::IDENTIFIER, graphene_std::math_nodes::gradient_value::IDENTIFIER]; let setters = [ - graphene_std::math_nodes::gradient_form::IDENTIFIER, + graphene_std::math_nodes::gradient_curvature::IDENTIFIER, graphene_std::math_nodes::gradient_spread::IDENTIFIER, graphene_std::math_nodes::gradient_positions::IDENTIFIER, graphene_std::math_nodes::gradient_midpoints::IDENTIFIER, diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index e779f93fb52..1d9367e39df 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -399,7 +399,7 @@ fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &Nod let walk_from = network_interface.upstream_output_connector(&target_input, &[]).and_then(|out| out.node_id()).unwrap_or(layer.to_node()); let transform_reference = DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER); - let gradient_form_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_form::IDENTIFIER); + let gradient_curvature_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_curvature::IDENTIFIER); let mut transforms_downstream_to_upstream: Vec = Vec::new(); let mut gradient_form: Option = None; @@ -416,11 +416,14 @@ fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &Nod if reference == transform_reference { transforms_downstream_to_upstream.push(read_transform_node_value(&document_node.inputs)); - } else if reference == gradient_form_reference - && gradient_form.is_none() - && let Some(TaggedValue::GradientForm(value)) = document_node.inputs.get(1).and_then(|input| input.as_value()) - { - gradient_form = Some(*value); + } else if reference == gradient_curvature_reference && gradient_form.is_none() { + // Curvatures other than the radial form's are drawn with the linear form's controls + let curvature = |index: usize| match document_node.inputs.get(index).and_then(|input| input.as_value()) { + Some(&TaggedValue::Number(value)) => value, + _ => 0., + }; + let is_radial = [curvature(1), curvature(2)] == graphene_std::renderer::dial_gradient::RADIAL_DIALS; + gradient_form = Some(if is_radial { GradientForm::Radial } else { GradientForm::Linear }); } } @@ -3195,7 +3198,7 @@ mod test_gradient { .await; let feeder_position = editor.active_document_mut().network_interface.position(&gradient_value_id, &[]).expect("Gradient Value position"); - // Set the gradient form through the tool, which splices a 'Gradient Form' node onto the Fill's fill input wire. + // Set the gradient form through the tool, which splices a 'Gradient Curvature' node onto the Fill's fill input wire. editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; editor.select_tool(ToolType::Gradient).await; editor @@ -3204,7 +3207,7 @@ mod test_gradient { }) .await; - let form_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_form::IDENTIFIER); + let form_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_curvature::IDENTIFIER); let form_node_id = { let network_interface = &editor.active_document().network_interface; network_interface @@ -3213,10 +3216,10 @@ mod test_gradient { .keys() .copied() .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&form_reference)) - .expect("Gradient Form node should have been inserted") + .expect("Gradient Curvature node should have been inserted") }; - let form_position = editor.active_document_mut().network_interface.position(&form_node_id, &[]).expect("Gradient Form position"); + let form_position = editor.active_document_mut().network_interface.position(&form_node_id, &[]).expect("Gradient Curvature position"); let feeder_position_after = editor.active_document_mut().network_interface.position(&gradient_value_id, &[]).expect("Gradient Value position after"); assert_eq!(form_position, feeder_position, "the inserted node should occupy the feeder's former slot, not the graph origin"); diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 485eb3f26ea..106d7283bbd 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -3,7 +3,6 @@ use crate::messages::portfolio::document::node_graph::utility_types::NodeEvaluat use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::*; use glam::{DAffine2, DVec2, UVec2}; -use graph_craft::application_io::EditorPreferences; use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue}; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork}; use graph_craft::proto::GraphErrors; @@ -95,6 +94,8 @@ pub struct NodeGraphExecutor { /// Documents whose gradient migration pass already ran this session, so entries that failed to measure /// (kept pending for a retry on the next open) don't re-run the pass on every render request. gradient_migration_attempted: HashSet, + /// The canvas images the frontend holds, so a render sends only those it lacks. + canvas_images_sent: HashSet, } #[derive(Debug, Clone)] @@ -139,10 +140,16 @@ impl NodeGraphExecutor { gradient_migration: None, gradient_migration_attempted: HashSet::new(), pending: None, + canvas_images_sent: HashSet::new(), }; (node_executor, node_runtime) } + /// Forgets which canvas images the frontend holds, for a frontend that started over without them. + pub fn forget_canvas_images(&mut self) { + self.canvas_images_sent.clear(); + } + /// Allows the test runner to see if any more executions have been queued. #[cfg(test)] pub fn current_execution_id(&self) -> u64 { @@ -172,12 +179,6 @@ impl NodeGraphExecutor { .expect("Failed to send the thumbnails in view"); } - pub fn update_editor_preferences(&self, editor_preferences: EditorPreferences) { - self.sender - .send(GraphRuntimeRequest::EditorPreferencesUpdate(editor_preferences)) - .expect("Failed to send editor preferences"); - } - /// Updates the network to monitor all inputs. Useful for the testing. #[cfg(test)] pub(crate) fn update_node_graph_instrumented(&mut self, document: &mut DocumentMessageHandler) -> Result { @@ -344,11 +345,11 @@ impl NodeGraphExecutor { }; // Calculate the bounding box of the region to be exported (artboard bounds always contribute). - // `AllArtwork` and `Selection` expand vector layer bounds by the rendered stroke width so strokes - // drawn at render-time (without a `Solidify Stroke`) aren't clipped at the export canvas edge. + // `AllArtwork` and `Selection` expand layer bounds by the rendered stroke width and effects reach so strokes + // and shadows drawn at render-time (without a `Solidify Stroke`) aren't clipped at the export canvas edge. let bounds = match export_config.bounds { - ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space_with_stroke(true), - ExportBounds::Selection => document.network_interface.selected_bounds_document_space_with_stroke(true, &[]), + ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space_with_stroke_and_effects(true), + ExportBounds::Selection => document.network_interface.selected_bounds_document_space_with_stroke_and_effects(true, &[]), ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id), } .ok_or_else(|| "No bounding box".to_string())?; @@ -819,18 +820,28 @@ impl NodeGraphExecutor { match render_output.data { RenderOutputType::Svg { svg, image_data } => { - // Convert each linear-light `Image` into gamma sRGB bytes (the DOM boundary form) before dispatching. + // The frontend keeps the canvases it was sent until a render stops using them, so only the images it lacks are sent + let canvas_ids_in_use: Vec = image_data.iter().map(|(id, _)| *id).collect(); + let in_use: HashSet = canvas_ids_in_use.iter().copied().collect(); + self.canvas_images_sent.retain(|id| in_use.contains(id)); + + // Convert each linear-light image, at its display resolution, into gamma sRGB bytes (the DOM boundary form) let rgba8_bytes = |&color: &_| <[u8; 4]>::from(SRGBA8::from(color)); let image_data = image_data .into_iter() - .map(|(id, image)| RasterizedImage { - id, - width: image.width, - height: image.height, - pixels: image.data.iter().flat_map(rgba8_bytes).collect::>().into(), + .filter(|(id, _)| self.canvas_images_sent.insert(*id)) + .map(|(id, canvas_image)| { + let image = canvas_image.raster.halved(canvas_image.halvings); + RasterizedImage { + id, + width: image.width, + height: image.height, + pixels: image.data.iter().flat_map(rgba8_bytes).collect::>().into(), + } }) .collect(); - responses.add(FrontendMessage::UpdateImageData { image_data }); + + responses.add(FrontendMessage::UpdateImageData { image_data, canvas_ids_in_use }); responses.add(FrontendMessage::UpdateDocumentArtwork { svg }); } #[cfg(target_family = "wasm")] @@ -842,8 +853,8 @@ impl NodeGraphExecutor { responses.add(FrontendMessage::UpdateDocumentArtwork { svg }); } RenderOutputType::Texture { .. } => {} - _ => { - log::error!("Invalid node graph output type: {:#?}", render_output.data); + RenderOutputType::Buffer { width, height, .. } => { + log::error!("Invalid node graph output type: a {width}×{height} pixel buffer"); responses.add(FrontendMessage::UpdateRenderStatus { status: RenderStatus::Error }); return; } @@ -860,6 +871,7 @@ impl NodeGraphExecutor { clip_targets, vector_data, appearance_attributes, + effects_reach, backgrounds: _, } = render_output.metadata; @@ -876,6 +888,7 @@ impl NodeGraphExecutor { responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); responses.add(DocumentMessage::UpdateVectorData { vector_data }); responses.add(DocumentMessage::UpdateAppearanceAttributes { appearance_attributes }); + responses.add(DocumentMessage::UpdateEffectsReach { effects_reach }); responses.add(DocumentMessage::RenderScrollbars); responses.add(DocumentMessage::RenderRulers); responses.add(OverlaysMessage::Draw); diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index ba87679582e..e5b7aeda9e5 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -43,7 +43,6 @@ pub struct NodeRuntime { dirty: Arc, pending: Option, pending_export: Option, - editor_preferences: EditorPreferences, old_graph: Option, async_runtime: Option, @@ -79,7 +78,6 @@ pub struct NodeRuntime { pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), - EditorPreferencesUpdate(EditorPreferences), ThumbnailsInView { layers: HashSet, document_id: DocumentId }, } @@ -114,14 +112,11 @@ impl NodeRuntime { dirty: Default::default(), pending: None, pending_export: None, - editor_preferences: EditorPreferences::default(), old_graph: None, resources: ResourceRegistry::default(), async_runtime: None, editor_api: PlatformEditorApi { - editor_preferences: Box::new(EditorPreferences::default()), - #[cfg(not(test))] application_io: None, @@ -155,7 +150,6 @@ impl NodeRuntime { } async fn handle_requests(&mut self) -> Option { - let mut preferences = None; let mut graph = None; let mut eyedropper = None; let mut execution = None; @@ -179,7 +173,6 @@ impl NodeRuntime { break; } } - GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request), GraphRuntimeRequest::ThumbnailsInView { layers, document_id } => thumbnails_in_view = Some((layers, document_id)), } } @@ -208,23 +201,11 @@ impl NodeRuntime { eyedropper.render_config.pointer = execution.render_config.pointer; } - let requests = [preferences, graph, eyedropper, execution].into_iter().flatten(); + let requests = [graph, eyedropper, execution].into_iter().flatten(); for request in requests { match request { GraphRuntimeRequest::ThumbnailsInView { .. } => {} - GraphRuntimeRequest::EditorPreferencesUpdate(preferences) => { - self.editor_preferences = preferences.clone(); - self.editor_api = PlatformEditorApi { - application_io: self.editor_api.application_io.clone(), - editor_preferences: Box::new(preferences), - } - .into(); - if let Some(graph) = self.old_graph.clone() { - // We ignore this result as compilation errors should have been reported in an earlier iteration - let _ = self.update_network(graph); - } - } GraphRuntimeRequest::GraphUpdate(GraphUpdate { mut network, resources, @@ -691,7 +672,6 @@ impl NodeRuntime { pub(crate) fn replace_application_io(&mut self, application_io: PlatformApplicationIo) { self.editor_api = PlatformEditorApi { application_io: Some(application_io.into()), - editor_preferences: Box::new(self.editor_preferences.clone()), } .into(); } diff --git a/frontend/src/components/widgets/NodeSection.svelte b/frontend/src/components/widgets/NodeSection.svelte index dcfb746a198..b1c13759cb4 100644 --- a/frontend/src/components/widgets/NodeSection.svelte +++ b/frontend/src/components/widgets/NodeSection.svelte @@ -531,9 +531,10 @@ margin-left: 8px; } - // A label alone in its row lines up with the parameter names that follow their expose buttons - > .text-label:only-child { + // A label with no expose button before it lines up with the parameter names that follow one + > .text-label:first-child { margin-left: calc(20px + 8px + 8px); + --wire-indent: 20px; } > .separator.horizontal.unrelated { diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index 8574c2b23c3..e3a9e420dd7 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -11,11 +11,10 @@ publish.workspace = true readme = "./README.md" [features] -default = ["gpu", "shader-nodes", "web"] +default = ["gpu", "web"] web = ["editor", "editor?/wasm"] editor = ["dep:editor", "dep:graphene-std", "dep:graph-craft", "dep:spin", "dep:wgpu"] gpu = ["editor?/gpu"] -shader-nodes = ["graphene-std?/shader-nodes", "gpu"] native = [] [lib] @@ -28,6 +27,7 @@ graphite-proc-macros = { workspace = true } graphene-std = { workspace = true, optional = true } # Workspace dependencies +base64 = { workspace = true } graph-craft = { workspace = true, optional = true } log = { workspace = true } serde = { workspace = true } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 071fb9992c7..5f3b3c08cb5 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -7,12 +7,12 @@ use crate::EDITOR; use crate::MESSAGE_BUFFER; #[cfg(all(feature = "native", target_family = "wasm"))] use crate::editor_commands::EditorCommand; +#[cfg(feature = "editor")] +use crate::helpers::render_image_data_to_canvases; #[cfg(not(feature = "native"))] use crate::helpers::run_node_runtime; #[cfg(any(not(feature = "native"), target_family = "wasm"))] use crate::helpers::wrapper; -#[cfg(feature = "editor")] -use crate::helpers::{calculate_hash, render_image_data_to_canvases}; use crate::helpers::{request_animation_frame, set_timeout}; use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY}; #[cfg(all(not(feature = "native"), target_family = "wasm"))] @@ -22,12 +22,10 @@ use editor::messages::prelude::*; #[cfg(feature = "editor")] use serde::Serialize; use std::cell::RefCell; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::Ordering; use std::time::Duration; use wasm_bindgen::prelude::*; -pub(crate) static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0); - #[wasm_bindgen] #[derive(Clone)] pub struct EditorWrapper { @@ -128,14 +126,13 @@ impl EditorWrapper { #[cfg(feature = "editor")] pub(crate) fn send_frontend_message_to_js(&self, message: FrontendMessage) { - if let FrontendMessage::UpdateImageData { ref image_data } = message { - let new_hash = calculate_hash(image_data); - let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed); - - if new_hash != prev_hash { - render_image_data_to_canvases(image_data.iter()); - IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed); - } + if let FrontendMessage::UpdateImageData { + ref image_data, + ref canvas_ids_in_use, + } = message + { + let images = image_data.iter().map(|image| (image.id, image.width, image.height, image.pixels.as_slice())); + render_image_data_to_canvases(images, canvas_ids_in_use); return; } diff --git a/frontend/wrapper/src/helpers.rs b/frontend/wrapper/src/helpers.rs index e39a0400810..4ed3b6658e8 100644 --- a/frontend/wrapper/src/helpers.rs +++ b/frontend/wrapper/src/helpers.rs @@ -2,7 +2,6 @@ use crate::EDITOR_HAS_CRASHED; use crate::EDITOR_WRAPPER; use crate::editor_wrapper::EditorWrapper; -use crate::native_communication::RasterizedImage; #[cfg(feature = "editor")] use editor::messages::input_mapper::utility_types::keyboard::Key; #[cfg(all(not(feature = "native"), target_family = "wasm"))] @@ -86,12 +85,15 @@ pub(crate) fn wake_runtime() { } thread_local! { - // The placeholder IDs of the last rendered artwork's images, each with its canvas on `window.imageCanvases` - static SHOWN_IMAGE_IDS: RefCell> = RefCell::new(HashSet::new()); + /// The canvases put on `window.imageCanvases` for images, the only ones dropped from it here, since other canvases share it. + static IMAGE_CANVAS_IDS: RefCell> = RefCell::new(HashSet::new()); } -/// Blits each rasterized image to a canvas registered on `window.imageCanvases` -pub(crate) fn render_image_data_to_canvases<'a>(image_data: impl IntoIterator) { +/// A canvas image's ID, width, height, and RGBA8 pixels. +pub(crate) type CanvasImagePixels<'a> = (u64, u32, u32, &'a [u8]); + +/// Blits each new image to a canvas registered on `window.imageCanvases`, and drops the image canvases no render uses anymore. +pub(crate) fn render_image_data_to_canvases<'a>(images: impl IntoIterator>, canvas_ids_in_use: &[u64]) { let window = match window() { Some(window) => window, None => { @@ -116,19 +118,18 @@ pub(crate) fn render_image_data_to_canvases<'a>(image_data: impl IntoIterator>(); - let shown = image_data.iter().map(|image| image.id).collect::>(); - let previously_shown = SHOWN_IMAGE_IDS.replace(shown.clone()); - for stale in previously_shown.difference(&shown) { - if Reflect::delete_property(&canvases_obj, &JsValue::from_str(&stale.to_string())).is_err() { - error!("Failed to free the canvas of image {stale}"); - } - } + IMAGE_CANVAS_IDS.with_borrow_mut(|registered| { + let in_use: HashSet = canvas_ids_in_use.iter().copied().collect(); + registered.retain(|id| { + let used = in_use.contains(id); + if !used { + let _ = Reflect::delete_property(&canvases_obj, &JsValue::from_str(&id.to_string())); + } + used + }); + }); - for image in image_data { - let (placeholder_id, width, height) = (image.id, image.width, image.height); - let pixels = image.pixels.as_slice(); + for (placeholder_id, width, height, pixels) in images { let canvas_name = placeholder_id.to_string(); let js_key = JsValue::from_str(&canvas_name); @@ -167,18 +168,12 @@ pub(crate) fn render_image_data_to_canvases<'a>(image_data: impl IntoIterator(t: &T) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::Hasher; - let mut hasher = DefaultHasher::new(); - t.hash(&mut hasher); - hasher.finish() -} - /// Translate a keyboard key from its JS name to its Rust `Key` enum #[cfg(feature = "editor")] pub(crate) fn translate_key(name: &str) -> Key { diff --git a/frontend/wrapper/src/native_communication.rs b/frontend/wrapper/src/native_communication.rs index c43b2ba6900..051884e4e6b 100644 --- a/frontend/wrapper/src/native_communication.rs +++ b/frontend/wrapper/src/native_communication.rs @@ -1,13 +1,29 @@ //! Messaging with the native process. Serialized `FrontendMessage`s come in and are forwarded to JS, //! `EditorCommand`s go back out. Encoding and decoding both live here so the wire format stays in one place. -use crate::editor_wrapper::{EditorWrapper, IMAGE_DATA_HASH}; -use crate::helpers::{calculate_hash, render_image_data_to_canvases, wrapper}; +use crate::editor_wrapper::EditorWrapper; +use crate::helpers::{render_image_data_to_canvases, wrapper}; use crate::wasm_value::WasmValue; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; use js_sys::{ArrayBuffer, Uint8Array}; -use std::sync::atomic::Ordering; use wasm_bindgen::prelude::*; +/// An `UpdateImageData` message as it crosses to the native frontend, with pixels in base64 since JSON would spell out each byte as a number. +#[derive(serde::Serialize, serde::Deserialize)] +struct EncodedImageData { + images: Vec, + canvas_ids_in_use: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct EncodedImage { + id: u64, + width: u32, + height: u32, + pixels: String, +} + #[wasm_bindgen(js_name = "receiveNativeMessage")] pub fn receive_native_message(buffer: ArrayBuffer) { let buffer = Uint8Array::new(buffer.as_ref()).to_vec(); @@ -16,17 +32,21 @@ pub fn receive_native_message(buffer: ArrayBuffer) { wrapper(move |wrapper: &mut EditorWrapper| { for (name, data) in messages { match name.as_str() { - "UpdateImageData" => { - let new_hash = calculate_hash(&data); - if IMAGE_DATA_HASH.swap(new_hash, Ordering::Relaxed) == new_hash { - continue; + "UpdateImageData" => match serde_json::from_str::(&data) { + Ok(encoded) => { + let images: Vec<_> = encoded + .images + .iter() + .filter_map(|image| { + let pixels = BASE64.decode(&image.pixels).inspect_err(|e| error!("Failed to decode image {}: {e}", image.id)).ok()?; + Some((image.id, image.width, image.height, pixels)) + }) + .collect(); + let images = images.iter().map(|(id, width, height, pixels)| (*id, *width, *height, pixels.as_slice())); + render_image_data_to_canvases(images, &encoded.canvas_ids_in_use); } - - match serde_json::from_str::>(&data) { - Ok(image_data) => render_image_data_to_canvases(image_data.iter()), - Err(e) => error!("Failed to deserialize image data: {e:?}"), - } - } + Err(e) => error!("Failed to deserialize image data: {e:?}"), + }, _ => match serde_json::from_str::(&data) { Ok(value) => wrapper.forward_serialized_frontend_message_to_js(&name, value), Err(e) => log::error!("Failed to deserialize frontend message {name}: {e:?}"), @@ -49,7 +69,20 @@ pub fn encode_frontend_messages(messages: Vec serde_json::to_string(&image_data).inspect_err(|e| log::error!("Failed to serialize {name} payload: {e}")).ok()?, + FrontendMessage::UpdateImageData { image_data, canvas_ids_in_use } => { + let images = image_data + .into_iter() + .map(|image| EncodedImage { + id: image.id, + width: image.width, + height: image.height, + pixels: BASE64.encode(&image.pixels), + }) + .collect(); + serde_json::to_string(&EncodedImageData { images, canvas_ids_in_use }) + .inspect_err(|e| log::error!("Failed to serialize {name} payload: {e}")) + .ok()? + } message => crate::wasm_value::encode(&message) .inspect_err(|e| log::error!("Failed to serialize FrontendMessage {name}: {e}")) .ok()?, @@ -97,37 +130,32 @@ pub fn send_message_to_cef(message: String) { func.call1(&JsValue::NULL, &JsValue::from(buffer)).expect("Function call failed"); } -#[cfg(feature = "editor")] -pub(crate) use editor::messages::frontend::utility_types::RasterizedImage; - -#[cfg(not(feature = "editor"))] -pub(crate) use RasterizedImageCopy as RasterizedImage; - -#[cfg(any(not(feature = "editor"), test))] -#[derive(serde::Deserialize)] -pub(crate) struct RasterizedImageCopy { - pub(crate) id: u64, - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) pixels: Vec, -} - #[cfg(all(test, feature = "editor"))] mod tests { use super::*; + use editor::messages::frontend::utility_types::RasterizedImage; + use editor::messages::prelude::FrontendMessage; #[test] - fn rasterized_image_copy_matches_editor_shape() { - let editor_image = RasterizedImage { + fn image_data_crosses_to_the_native_frontend_intact() { + let image = RasterizedImage { id: 7, width: 2, height: 1, pixels: serde_bytes::ByteBuf::from(vec![1, 2, 3, 4, 5, 6, 7, 8]), }; - let json = serde_json::to_string(&vec![editor_image]).unwrap(); - let decoded: Vec = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded[0].id, 7); - assert_eq!((decoded[0].width, decoded[0].height), (2, 1)); - assert_eq!(decoded[0].pixels, [1, 2, 3, 4, 5, 6, 7, 8]); + let message = FrontendMessage::UpdateImageData { + image_data: vec![image], + canvas_ids_in_use: vec![7, 9], + }; + + let encoded = encode_frontend_messages(vec![message]).unwrap(); + let messages: Vec<(String, String)> = serde_json::from_slice(&encoded).unwrap(); + let decoded: EncodedImageData = serde_json::from_str(&messages[0].1).unwrap(); + + assert_eq!(messages[0].0, "UpdateImageData"); + assert_eq!(decoded.canvas_ids_in_use, [7, 9]); + assert_eq!((decoded.images[0].id, decoded.images[0].width, decoded.images[0].height), (7, 2, 1)); + assert_eq!(BASE64.decode(&decoded.images[0].pixels).unwrap(), [1, 2, 3, 4, 5, 6, 7, 8]); } } diff --git a/node-graph/graph-craft/src/application_io.rs b/node-graph/graph-craft/src/application_io.rs index 9000eb5cb79..dc9a306b4fc 100644 --- a/node-graph/graph-craft/src/application_io.rs +++ b/node-graph/graph-craft/src/application_io.rs @@ -73,27 +73,3 @@ unsafe impl StaticType for PlatformApplicationIo { } pub type PlatformEditorApi = graphene_application_io::EditorApi; - -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Clone, Debug, PartialEq, Hash, serde::Serialize, serde::Deserialize)] -pub struct EditorPreferences { - /// Maximum render region size in pixels along one dimension of the square area. - pub max_render_region_size: u32, -} - -impl graphene_application_io::GetEditorPreferences for EditorPreferences { - fn max_render_region_area(&self) -> u32 { - let size = self.max_render_region_size.min(u32::MAX.isqrt()); - size.pow(2) - } -} - -impl Default for EditorPreferences { - fn default() -> Self { - Self { max_render_region_size: 1280 } - } -} - -unsafe impl StaticType for EditorPreferences { - type Static = EditorPreferences; -} diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index caa564285f1..925c418d05d 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -19,7 +19,7 @@ use graphic_types::vector_types::vector::style::DashPattern; use graphic_types::vector_types::vector::style::{Gradient, GradientRamp}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; -use rendering::RenderMetadata; +use rendering::{CanvasImage, RenderMetadata}; use std::fmt::Display; use std::hash::Hash; use std::str::FromStr; @@ -577,6 +577,9 @@ tagged_value! { TonalRange(raster_nodes::adjustments::TonalRange), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), HueSaturationRange(raster_nodes::adjustments::HueSaturationRange), + GlowSource(graphic_types::GlowSource), + BevelStyle(graphic_types::BevelStyle), + BevelDirection(graphic_types::BevelDirection), GridType(vector::misc::GridType), GridTopology(vector::misc::GridTopology), ArcType(vector::misc::ArcType), @@ -706,6 +709,25 @@ impl TaggedValue { None } + fn to_stroke_align(input: &str) -> Option { + let mut choices = input.split("::"); + let (first, second) = (choices.next()?.trim(), choices.next()?.trim()); + if first == "StrokeAlign" { + return Some(match second { + "Center" => vector::style::StrokeAlign::Center, + "Inside" => vector::style::StrokeAlign::Inside, + "Outside" => vector::style::StrokeAlign::Outside, + _ => { + log::error!("Invalid StrokeAlign default type variant: {input}"); + return None; + } + }); + } + + log::error!("Invalid StrokeAlign default type: {input}"); + None + } + match ty { Type::Generic(_) => None, Type::Concrete(concrete_type) => { @@ -724,6 +746,7 @@ impl TaggedValue { () if ty == TypeId::of::() => to_color(string).map(TaggedValue::Color)?, () if ty == TypeId::of::() => to_gradient(string).map(|gradient| TaggedValue::GradientRamp(gradient.into()))?, () if ty == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, + () if ty == TypeId::of::() => to_stroke_align(string).map(TaggedValue::StrokeAlign)?, () if ty == TypeId::of::() => TaggedValue::DashPattern(core_types::misc::parse_f64_list(string)), () if ty == TypeId::of::() => TaggedValue::BoxCorners(core_types::misc::parse_f64_list(string)), _ => return None, @@ -933,20 +956,14 @@ pub enum RenderOutputType { #[serde(skip)] Texture(graphene_application_io::Texture), #[serde(skip)] - Buffer { - data: Vec, - width: u32, - height: u32, - }, + Buffer { data: Vec, width: u32, height: u32 }, Svg { svg: String, - image_data: Vec<(u64, Image)>, + /// The images the SVG draws to canvases, by the canvas ID their placeholders name. + image_data: Vec<(u64, CanvasImage)>, }, #[cfg(target_family = "wasm")] - CanvasFrame { - canvas_id: u64, - resolution: DVec2, - }, + CanvasFrame { canvas_id: u64, resolution: DVec2 }, } impl CacheHash for RenderOutputType { diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index f7190c81769..a1bfb56dcf5 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -5,7 +5,6 @@ use document_container::AnyContainer; use document_container::backends::memory::MemoryBackend; use document_format::{GddV1, GddV1Layout}; use fern::colors::{Color, ColoredLevelConfig}; -use graph_craft::application_io::EditorPreferences; use graph_craft::application_io::{PlatformApplicationIo, PlatformEditorApi}; use graph_craft::document::*; use graph_craft::graphene_compiler::Compiler; @@ -170,12 +169,8 @@ async fn main() -> Result<(), Box> { let wgpu_executor_ref = application_io_arc.gpu_executor().unwrap(); let device = wgpu_executor_ref.context().device.clone(); - let preferences = EditorPreferences { - max_render_region_size: EditorPreferences::default().max_render_region_size, - }; let editor_api = Arc::new(PlatformEditorApi { application_io: Some(application_io_for_api), - editor_preferences: Box::new(preferences), }); let spawner = Arc::new(TokioSpawner::default()); let runtime = AsyncRuntime::new(spawner.clone()); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 6cad5f0be45..229adb84e43 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -8,6 +8,7 @@ use graphene_animation::AnimationCurve; use graphene_std::animation::RealTimeMode; use graphene_std::any::DynAnyNode; use graphene_std::brush::Stroke; +use graphene_std::generator::Generator; use graphene_std::gradient::Gradient; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] @@ -29,6 +30,7 @@ use graphene_std::vector::misc::{ use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification}; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type}; +use graphene_std::{BevelDirection, BevelStyle, GlowSource}; use node_registry_macros::async_node; use std::collections::HashMap; use std::sync::Arc; @@ -47,6 +49,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), 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]), @@ -55,6 +58,7 @@ fn node_registry() -> HashMap, 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]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -104,6 +108,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), @@ -124,6 +129,7 @@ fn node_registry() -> HashMap, 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]), 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]), @@ -335,6 +341,9 @@ fn node_registry() -> HashMap HashMap HashMap, Color, Gradient, + Generator, f64, i64, bool, @@ -440,6 +451,7 @@ fn node_registry() -> HashMap HashMap, element: Graphic)); @@ -550,6 +563,7 @@ fn node_registry() -> HashMap), attribute_value_node!(List), attribute_value_node!(List), + attribute_value_node!(List), attribute_value_node!(List), attribute_value_node!(List>), #[cfg(feature = "gpu")] @@ -577,6 +591,7 @@ fn node_registry() -> HashMap), transform_list_node!(element: Color), transform_list_node!(element: Gradient), + transform_list_node!(element: Generator), ]; node_types.extend(transform_list_rows); let mut map: HashMap> = HashMap::new(); diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index e612ebc9c08..a14abc9c1ec 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -51,11 +51,7 @@ pub fn wrap_network_in_scope(network: NodeNetwork, editor_api: Arc ApplicationIo for &T { } } -pub trait GetEditorPreferences { - fn max_render_region_area(&self) -> u32; -} - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum ExportFormat { @@ -82,36 +78,22 @@ impl RenderConfig { } } -struct DummyPreferences; - -impl GetEditorPreferences for DummyPreferences { - fn max_render_region_area(&self) -> u32 { - 1024 * 1024 - } -} - pub struct EditorApi { /// Gives access to APIs like resources. pub application_io: Option>, - /// Editor preferences made available to the graph through the `PlatformEditorApi`. - pub editor_preferences: Box, } impl Eq for EditorApi {} impl Default for EditorApi { fn default() -> Self { - Self { - application_io: None, - editor_preferences: Box::new(DummyPreferences), - } + Self { application_io: None } } } impl Hash for EditorApi { fn hash(&self, state: &mut H) { self.application_io.as_ref().map_or(0, |io| io as *const _ as usize).hash(state); - (self.editor_preferences.as_ref() as *const dyn GetEditorPreferences).hash(state); } } @@ -124,7 +106,6 @@ impl CacheHash for EditorApi { impl PartialEq for EditorApi { fn eq(&self, other: &Self) -> bool { self.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) == other.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) - && std::ptr::eq(self.editor_preferences.as_ref() as *const _, other.editor_preferences.as_ref() as *const _) } } diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 60fce461052..0722a5d0c0f 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -30,9 +30,10 @@ pub use glam_ext::FallibleVec2Operations; pub use graphene_hash; pub use graphene_hash::CacheHash; pub use list::{ - ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END, - ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, - ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, + ATTR_EFFECTS, ATTR_END, ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_END_CURVATURE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, + ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_GRADIENT_START_CURVATURE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, + ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, }; pub use memo::MemoHash; pub use no_std_types::AsU32; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 1c75ea823ad..3bcae391762 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -61,6 +61,11 @@ pub const ATTR_CLIP: &str = "clip"; pub const ATTR_GRADIENT_SPREAD: &str = "gradient_spread"; /// Gradient's `GradientForm` (`Linear` or `Radial`). pub const ATTR_GRADIENT_FORM: &str = "gradient_form"; +/// Gradient's `f64` curvature dial for the contour through its start, from -1 to 1: 0 is a line, ±1 is the start point +/// itself, and between is a circle bowing toward (positive) or away from (negative) the end. Absent, the form decides it. +pub const ATTR_GRADIENT_START_CURVATURE: &str = "gradient_start_curvature"; +/// Gradient's `f64` curvature dial for the contour through its end, like `gradient_start_curvature` with the ends swapped. +pub const ATTR_GRADIENT_END_CURVATURE: &str = "gradient_end_curvature"; /// Gradient's `GradientSpace`, the color space its stops interpolate in. pub const ATTR_GRADIENT_SPACE: &str = "gradient_space"; /// Gradient's `GradientHueDirection` (`Shorter`, `Longer`, `Increasing`, or `Decreasing`), which way around the @@ -80,6 +85,9 @@ pub const ATTR_POSITION: &str = "position"; pub const ATTR_MIDPOINT: &str = "midpoint"; /// Item's ordered list of paint passes, of type `Appearance`. Earlier coverages paint first, compositing below later ones. pub const ATTR_APPEARANCE: &str = "appearance"; +/// Item's ordered list of effects, of type `Effects`, which the renderer applies in turn to the pixels the item renders to. +/// Unlike the appearance, it doesn't cascade to descendants. +pub const ATTR_EFFECTS: &str = "effects"; // TODO: Add a "fill_rule" attribute as a sibling of "paint" on the coverage list (uniform across covers) once a FillRule type ships /// Coverage's `Graphic` paint (implicit default `Graphic::None`, painting nothing), on the /// `List` inside an `Appearance`. diff --git a/node-graph/libraries/graphic-types/Cargo.toml b/node-graph/libraries/graphic-types/Cargo.toml index 2b9274be11d..8d83022f46c 100644 --- a/node-graph/libraries/graphic-types/Cargo.toml +++ b/node-graph/libraries/graphic-types/Cargo.toml @@ -21,6 +21,7 @@ wasm = [ "core-types/wasm", "vector-types/wasm", "raster-types/wasm", + "tsify", "wasm-bindgen", ] @@ -28,6 +29,7 @@ wasm = [ # Local dependencies core-types = { workspace = true } graphene-hash = { workspace = true } +graphene-resource = { workspace = true } brush-types = { workspace = true } raster-types = { workspace = true, features = ["wgpu"] } vector-types = { workspace = true } @@ -35,9 +37,11 @@ node-macro = { workspace = true } # Workspace dependencies dyn-any = { workspace = true } +fastnoise-lite = { workspace = true } glam = { workspace = true } serde_json = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } +tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } diff --git a/node-graph/libraries/graphic-types/src/adjustment.rs b/node-graph/libraries/graphic-types/src/adjustment.rs new file mode 100644 index 00000000000..b32410634c9 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/adjustment.rs @@ -0,0 +1,517 @@ +//! Color adjustments as plain data holding their nodes' settings in the nodes' own units, decoded from effect rows, with the CPU kernels in [`crate::adjustment_kernels`]. + +use crate::Graphic; +use crate::effects::EffectKind; +use crate::parameters::ParameterVisitor; +use core_types::Color; +use core_types::blending::BlendMode; +use core_types::color::SRGBA8; +use core_types::graphene_hash::CacheHash; +use core_types::list::Item; +use core_types::transfer_curve::TransferCurve; +use dyn_any::DynAny; +use graphene_resource::Resource; +use vector_types::Gradient; + +/// A color adjustment applied to each color on its own, with no regard for its neighbors. +#[derive(Clone, Debug, PartialEq, CacheHash)] +pub enum Adjustment { + Desaturate { + method: DesaturateMethod, + }, + GammaCorrection { + gamma: f64, + inverse: bool, + }, + MakeOpaque, + BrightnessContrast { + /// Percent. + brightness: f64, + /// Percent. + contrast: f64, + use_classic: bool, + /// On a scale of 0 to 255. + classic_pivot: f64, + }, + Levels(Box), + Curves(Box), + BlackAndWhite(BlackAndWhiteSettings), + HueSaturation(Box), + Invert, + Threshold { + /// Percent. + min_luminance: f64, + /// Percent. + max_luminance: f64, + }, + GradientMap { + /// The gradient, with its whole-ramp settings as its attributes. + gradient: Item, + reverse: bool, + }, + Vibrance { + /// Percent. + vibrance: f64, + /// Percent. + saturation: f64, + }, + ChannelMixer(Box), + SelectiveColor(Box), + Posterize { + levels: i64, + }, + Exposure { + exposure: f64, + offset: f64, + gamma_correction: f64, + }, + ColorBalance(ColorBalanceSettings), + PhotoFilter { + color: Color, + /// Percent. + density: f64, + preserve_luminosity: bool, + }, + ColorLookup { + /// The file the lookup table is read from. + lut_file: Resource, + }, + ColorOverlay { + color: Color, + blend_mode: BlendMode, + /// Percent. + opacity: f64, + }, +} + +impl Adjustment { + pub fn kind(&self) -> EffectKind { + match self { + Self::Desaturate { .. } => EffectKind::Desaturate, + Self::GammaCorrection { .. } => EffectKind::GammaCorrection, + Self::MakeOpaque => EffectKind::MakeOpaque, + Self::BrightnessContrast { .. } => EffectKind::BrightnessContrast, + Self::Levels(_) => EffectKind::Levels, + Self::Curves(_) => EffectKind::Curves, + Self::BlackAndWhite(_) => EffectKind::BlackAndWhite, + Self::HueSaturation(_) => EffectKind::HueSaturation, + Self::Invert => EffectKind::Invert, + Self::Threshold { .. } => EffectKind::Threshold, + Self::GradientMap { .. } => EffectKind::GradientMap, + Self::Vibrance { .. } => EffectKind::Vibrance, + Self::ChannelMixer(_) => EffectKind::ChannelMixer, + Self::SelectiveColor(_) => EffectKind::SelectiveColor, + Self::Posterize { .. } => EffectKind::Posterize, + Self::Exposure { .. } => EffectKind::Exposure, + Self::ColorBalance(_) => EffectKind::ColorBalance, + Self::PhotoFilter { .. } => EffectKind::PhotoFilter, + Self::ColorLookup { .. } => EffectKind::ColorLookup, + Self::ColorOverlay { .. } => EffectKind::PaintOverlay, + } + } + + /// Visits each parameter with the attribute it rides, named after the node's parameter, and its implicit default, which is the node's. + pub(crate) fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor) { + match self { + Self::Desaturate { method } => visitor.parameter("method", method, DesaturateMethod::default()), + Self::GammaCorrection { gamma, inverse } => { + visitor.parameter("gamma", gamma, 2.2); + visitor.parameter("inverse", inverse, false); + } + Self::MakeOpaque | Self::Invert => {} + Self::BrightnessContrast { + brightness, + contrast, + use_classic, + classic_pivot, + } => { + visitor.parameter("brightness", brightness, 0.); + visitor.parameter("contrast", contrast, 0.); + visitor.parameter("use_classic", use_classic, false); + visitor.parameter("classic_pivot", classic_pivot, 127.); + } + Self::Levels(settings) => { + let LevelsSettings { composite, red, green, blue, alpha } = settings.as_mut(); + composite.visit_parameters(visitor, ""); + red.visit_parameters(visitor, "red"); + green.visit_parameters(visitor, "green"); + blue.visit_parameters(visitor, "blue"); + alpha.visit_parameters(visitor, "alpha"); + } + Self::Curves(settings) => { + let CurvesSettings { composite, red, green, blue, alpha } = settings.as_mut(); + visitor.parameter("curve", composite, TransferCurve::default()); + visitor.parameter("red_curve", red, TransferCurve::default()); + visitor.parameter("green_curve", green, TransferCurve::default()); + visitor.parameter("blue_curve", blue, TransferCurve::default()); + visitor.parameter("alpha_curve", alpha, TransferCurve::default()); + } + Self::BlackAndWhite(settings) => { + visitor.parameter("use_tint", &mut settings.use_tint, false); + visitor.parameter("tint", &mut settings.tint, Color::from(SRGBA8::new(0xe1, 0xd3, 0xb3, 0xff))); + visitor.parameter("reds", &mut settings.reds, 40.); + visitor.parameter("yellows", &mut settings.yellows, 60.); + visitor.parameter("greens", &mut settings.greens, 40.); + visitor.parameter("cyans", &mut settings.cyans, 60.); + visitor.parameter("blues", &mut settings.blues, 20.); + visitor.parameter("magentas", &mut settings.magentas, 80.); + } + Self::HueSaturation(settings) => { + let HueSaturationSettings { + master, + colorize, + colorize_sliders, + reds, + yellows, + greens, + cyans, + blues, + magentas, + } = settings.as_mut(); + + master.visit_parameters(visitor, "", [0., 0., 0.]); + visitor.parameter("colorize", colorize, false); + colorize_sliders.visit_parameters(visitor, "colorize", [24., 25., 0.]); + + reds.visit_parameters(visitor, "reds", [315., 345., 15., 45.]); + yellows.visit_parameters(visitor, "yellows", [15., 45., 75., 105.]); + greens.visit_parameters(visitor, "greens", [75., 105., 135., 165.]); + cyans.visit_parameters(visitor, "cyans", [135., 165., 195., 225.]); + blues.visit_parameters(visitor, "blues", [195., 225., 255., 285.]); + magentas.visit_parameters(visitor, "magentas", [255., 285., 315., 345.]); + } + Self::Threshold { min_luminance, max_luminance } => { + visitor.parameter("min_luminance", min_luminance, 50.); + visitor.parameter("max_luminance", max_luminance, 100.); + } + Self::GradientMap { gradient, reverse } => { + visitor.parameter("gradient", gradient, Item::new_from_element(Gradient::from(vec![Color::BLACK, Color::WHITE]))); + visitor.parameter("reverse", reverse, false); + } + Self::Vibrance { vibrance, saturation } => { + visitor.parameter("vibrance", vibrance, 0.); + visitor.parameter("saturation", saturation, 0.); + } + Self::ChannelMixer(settings) => { + let ChannelMixerSettings { + monochrome, + monochrome_mix, + red, + green, + blue, + } = settings.as_mut(); + + visitor.parameter("monochrome", monochrome, false); + monochrome_mix.visit_parameters(visitor, "monochrome", [40., 40., 20., 0.]); + red.visit_parameters(visitor, "red", [100., 0., 0., 0.]); + green.visit_parameters(visitor, "green", [0., 100., 0., 0.]); + blue.visit_parameters(visitor, "blue", [0., 0., 100., 0.]); + } + Self::SelectiveColor(settings) => { + let SelectiveColorSettings { + mode, + reds, + yellows, + greens, + cyans, + blues, + magentas, + whites, + neutrals, + blacks, + } = settings.as_mut(); + + visitor.parameter("mode", mode, RelativeAbsolute::default()); + let groups = [ + ("reds", reds), + ("yellows", yellows), + ("greens", greens), + ("cyans", cyans), + ("blues", blues), + ("magentas", magentas), + ("whites", whites), + ("neutrals", neutrals), + ("blacks", blacks), + ]; + for (name, inks) in groups { + inks.visit_parameters(visitor, name); + } + } + Self::Posterize { levels } => visitor.parameter("levels", levels, 4), + Self::Exposure { exposure, offset, gamma_correction } => { + visitor.parameter("exposure", exposure, 0.); + visitor.parameter("offset", offset, 0.); + visitor.parameter("gamma_correction", gamma_correction, 1.); + } + Self::ColorBalance(settings) => { + settings.shadows.visit_parameters(visitor, "shadows"); + settings.midtones.visit_parameters(visitor, "midtones"); + settings.highlights.visit_parameters(visitor, "highlights"); + visitor.parameter("preserve_luminosity", &mut settings.preserve_luminosity, true); + } + Self::PhotoFilter { color, density, preserve_luminosity } => { + visitor.parameter("color", color, Color::from(SRGBA8::new(0xec, 0x8a, 0x00, 0xff))); + visitor.parameter("density", density, 25.); + visitor.parameter("preserve_luminosity", preserve_luminosity, true); + } + Self::ColorLookup { lut_file } => visitor.parameter("lut_file", lut_file, Resource::default()), + Self::ColorOverlay { color, blend_mode, opacity } => { + // Rides the row as the paint of the Paint Overlay node, which stamps it + let mut paint = Graphic::from(*color); + visitor.parameter("paint", &mut paint, Graphic::from(Color::BLACK)); + if let Graphic::Color(paint) = paint { + *color = *paint.element(); + } + visitor.parameter("blend_mode", blend_mode, BlendMode::default()); + visitor.parameter("opacity", opacity, 100.); + } + } + } +} + +/// Conversion from a color to grayscale. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum DesaturateMethod { + /// Light level of the color, the Y (luminance) of Rec. 709, which weights the linear-light RGB channels by `0.2126, 0.7152, 0.0722`. + /// + /// Accessibility contrast ratios and SVG luminance masks use this. + #[default] + #[label("Luminance (Rec. 709)")] + #[cfg_attr(feature = "serde", serde(alias = "SRGB"))] + LuminanceRec709, + /// Light level approximation for the color, the Y′ (luma) of Rec. 709, which weights the gamma-encoded RGB channels by `0.2126, 0.7152, 0.0722`. + /// + /// CSS filter functions such as `grayscale()` use this. + #[label("Luma (Rec. 709)")] + LumaRec709, + /// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`. + /// + /// The Luminosity family of blend modes uses this, rounded to `0.3, 0.59, 0.11`. + #[label("Luma (Rec. 601)")] + LumaRec601, + /// Perceptually uniform scale from black to white, the L (lightness) of OkLab. + #[label("Lightness (OkLab)")] + #[cfg_attr(feature = "serde", serde(alias = "Perceptual"))] + LightnessOkLab, + /// Mean of the three linear-light RGB channels. + #[menu_separator] + #[cfg_attr(feature = "serde", serde(alias = "AverageChannels"))] + ChannelsAverage, + /// Smallest of the three linear-light RGB channels. + #[cfg_attr(feature = "serde", serde(alias = "MinimumChannels"))] + ChannelsMinimum, + /// Largest of the three linear-light RGB channels, the V (value) of HSV. + #[cfg_attr(feature = "serde", serde(alias = "MaximumChannels"))] + ChannelsMaximum, + /// Midpoint of the largest and smallest gamma-encoded RGB channels, the L (lightness) of HSL. + /// + /// The classic "Desaturate" command of many image editors uses this. + #[label("Lightness (HSL)")] + LightnessHsl, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum RelativeAbsolute { + #[default] + Relative, + Absolute, +} + +/// One channel's Levels record: percentage input and output points and the midtones gamma. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct LevelsRecord { + pub shadows: f64, + pub midtones: f64, + pub highlights: f64, + pub output_minimums: f64, + pub output_maximums: f64, +} + +/// The composite record that the three color channels pass through after their own, and the alpha record that stands apart. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct LevelsSettings { + pub composite: LevelsRecord, + pub red: LevelsRecord, + pub green: LevelsRecord, + pub blue: LevelsRecord, + pub alpha: LevelsRecord, +} + +/// The composite curve that the three color channels pass through after their own, and the alpha curve that stands apart. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct CurvesSettings { + pub composite: TransferCurve, + pub red: TransferCurve, + pub green: TransferCurve, + pub blue: TransferCurve, + pub alpha: TransferCurve, +} + +/// The percentage each hue contributes to the gray, and the color tinting it when `use_tint` is set. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct BlackAndWhiteSettings { + pub use_tint: bool, + pub tint: Color, + pub reds: f64, + pub yellows: f64, + pub greens: f64, + pub cyans: f64, + pub blues: f64, + pub magentas: f64, +} + +/// One set of Hue/Saturation sliders: a hue in degrees and saturation and lightness percentages. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct HueSaturationSliders { + pub hue: f64, + pub saturation: f64, + pub lightness: f64, +} + +/// The sliders for one range of hues, which apply fully from the range start to its end, fading out at the falloff ends. Edges are in degrees. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct HueRangeSettings { + pub sliders: HueSaturationSliders, + pub falloff_start: f64, + pub range_start: f64, + pub range_end: f64, + pub falloff_end: f64, +} + +/// The master sliders shift every color, each hue range adds its own, and colorizing replaces them all with an absolute hue and saturation. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct HueSaturationSettings { + pub master: HueSaturationSliders, + pub colorize: bool, + pub colorize_sliders: HueSaturationSliders, + pub reds: HueRangeSettings, + pub yellows: HueRangeSettings, + pub greens: HueRangeSettings, + pub cyans: HueRangeSettings, + pub blues: HueRangeSettings, + pub magentas: HueRangeSettings, +} + +/// The percentages of each source channel, plus a constant, that sum to one output channel. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct ChannelMixerRow { + pub red: f64, + pub green: f64, + pub blue: f64, + pub constant: f64, +} + +/// A row for each output channel, or for the single gray channel when monochrome. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct ChannelMixerSettings { + pub monochrome: bool, + pub monochrome_mix: ChannelMixerRow, + pub red: ChannelMixerRow, + pub green: ChannelMixerRow, + pub blue: ChannelMixerRow, +} + +/// Percentage offsets to the cyan, magenta, yellow, and black inks of one group of colors. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct SelectiveColorInks { + pub cyan: f64, + pub magenta: f64, + pub yellow: f64, + pub black: f64, +} + +/// The ink offsets of each group of colors, and whether they are relative to the inks already present. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct SelectiveColorSettings { + pub mode: RelativeAbsolute, + pub reds: SelectiveColorInks, + pub yellows: SelectiveColorInks, + pub greens: SelectiveColorInks, + pub cyans: SelectiveColorInks, + pub blues: SelectiveColorInks, + pub magentas: SelectiveColorInks, + pub whites: SelectiveColorInks, + pub neutrals: SelectiveColorInks, + pub blacks: SelectiveColorInks, +} + +/// Percentage shifts along the three opponent color axes for one tonal range. +#[derive(Clone, Copy, Debug, Default, PartialEq, CacheHash)] +pub struct ColorBalanceShifts { + pub cyan_red: f64, + pub magenta_green: f64, + pub yellow_blue: f64, +} + +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct ColorBalanceSettings { + pub shadows: ColorBalanceShifts, + pub midtones: ColorBalanceShifts, + pub highlights: ColorBalanceShifts, + pub preserve_luminosity: bool, +} + +/// The attribute key of a parameter in one of a node's groups of parameters, which the node names with the group's prefix. +fn grouped_key(group: &str, parameter: &str) -> String { + if group.is_empty() { parameter.to_string() } else { format!("{group}_{parameter}") } +} + +impl LevelsRecord { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str) { + visitor.parameter(&grouped_key(group, "shadows"), &mut self.shadows, 0.); + visitor.parameter(&grouped_key(group, "midtones"), &mut self.midtones, 1.); + visitor.parameter(&grouped_key(group, "highlights"), &mut self.highlights, 100.); + visitor.parameter(&grouped_key(group, "output_minimums"), &mut self.output_minimums, 0.); + visitor.parameter(&grouped_key(group, "output_maximums"), &mut self.output_maximums, 100.); + } +} + +impl HueSaturationSliders { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str, [hue, saturation, lightness]: [f64; 3]) { + visitor.parameter(&grouped_key(group, "hue"), &mut self.hue, hue); + visitor.parameter(&grouped_key(group, "saturation"), &mut self.saturation, saturation); + visitor.parameter(&grouped_key(group, "lightness"), &mut self.lightness, lightness); + } +} + +impl HueRangeSettings { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str, [falloff_start, range_start, range_end, falloff_end]: [f64; 4]) { + self.sliders.visit_parameters(visitor, group, [0.; 3]); + visitor.parameter(&grouped_key(group, "falloff_start"), &mut self.falloff_start, falloff_start); + visitor.parameter(&grouped_key(group, "range_start"), &mut self.range_start, range_start); + visitor.parameter(&grouped_key(group, "range_end"), &mut self.range_end, range_end); + visitor.parameter(&grouped_key(group, "falloff_end"), &mut self.falloff_end, falloff_end); + } +} + +impl ChannelMixerRow { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str, [red, green, blue, constant]: [f64; 4]) { + visitor.parameter(&grouped_key(group, "red"), &mut self.red, red); + visitor.parameter(&grouped_key(group, "green"), &mut self.green, green); + visitor.parameter(&grouped_key(group, "blue"), &mut self.blue, blue); + visitor.parameter(&grouped_key(group, "constant"), &mut self.constant, constant); + } +} + +impl SelectiveColorInks { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str) { + visitor.parameter(&grouped_key(group, "cyan"), &mut self.cyan, 0.); + visitor.parameter(&grouped_key(group, "magenta"), &mut self.magenta, 0.); + visitor.parameter(&grouped_key(group, "yellow"), &mut self.yellow, 0.); + visitor.parameter(&grouped_key(group, "black"), &mut self.black, 0.); + } +} + +impl ColorBalanceShifts { + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor, group: &str) { + visitor.parameter(&grouped_key(group, "cyan_red"), &mut self.cyan_red, 0.); + visitor.parameter(&grouped_key(group, "magenta_green"), &mut self.magenta_green, 0.); + visitor.parameter(&grouped_key(group, "yellow_blue"), &mut self.yellow_blue, 0.); + } +} diff --git a/node-graph/libraries/graphic-types/src/adjustment_kernels.rs b/node-graph/libraries/graphic-types/src/adjustment_kernels.rs new file mode 100644 index 00000000000..ca8618aa895 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/adjustment_kernels.rs @@ -0,0 +1,1271 @@ +//! The CPU kernels that carry out each [`Adjustment`], precomputing what its settings need once and then mapping each color. + +use crate::adjustment::*; +use crate::color_lookup_table::parse_cached; +use core_types::Color; +use core_types::blending::{BlendMode, apply_blend_mode}; +use core_types::color::{linear_to_srgb, set_luminosity, srgb_to_linear}; +use core_types::transfer_curve::TransferCurveEvaluator; +use glam::Vec3; +use raster_types::parallel_rows::fill_rows; +use vector_types::GradientSettings; + +/// How many colors each thread band handles at a time when a long slice is split across threads. +const COLORS_PER_ROW: usize = 4096; + +impl Adjustment { + /// Adjusts each color in place, on threads for long slices on native platforms. O(n). + pub fn apply(&self, colors: &mut [Color]) { + match self { + Self::Desaturate { method } => map_colors(colors, |color| desaturate(color, *method)), + Self::GammaCorrection { gamma, inverse } => { + let exponent = if *inverse { 1. / gamma } else { *gamma }; + map_colors(colors, |color| color.apply_gamma_exponent(exponent as f32)); + } + Self::MakeOpaque => map_colors(colors, |color| color.with_alpha(1.)), + Self::BrightnessContrast { + brightness, + contrast, + use_classic, + classic_pivot, + } => { + let kernel = BrightnessContrastKernel::new(*brightness, *contrast, *use_classic, *classic_pivot); + map_colors(colors, |color| kernel.apply(color)); + } + Self::Levels(settings) => { + let kernel = LevelsKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::Curves(settings) => { + let kernel = CurvesKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::BlackAndWhite(settings) => { + let kernel = BlackAndWhiteKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::HueSaturation(settings) => { + let kernel = HueSaturationKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::Invert => map_colors(colors, |color| color.map_gamma_rgb(|channel| 1. - channel)), + Self::Threshold { min_luminance, max_luminance } => { + let min_luminance = (min_luminance / 100.) as f32; + let max_luminance = (max_luminance / 100.) as f32; + + map_colors(colors, |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 }; + output.with_alpha(color.a()) + }); + } + Self::GradientMap { gradient, reverse } => { + let evaluator = gradient.element().evaluator(GradientSettings::from_item_attributes(gradient)); + + map_colors(colors, |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 }; + + // The source alpha is kept and the gradient's own alpha stops are ignored + evaluator.evaluate(intensity as f64).with_alpha(alpha) + }); + } + Self::Vibrance { vibrance, saturation } => { + let vibrance = (vibrance / 100.) as f32; + let saturation_scale = (1. + saturation / 100.) as f32; + map_colors(colors, |color| apply_vibrance(color, vibrance, saturation_scale)); + } + Self::ChannelMixer(settings) => { + let kernel = ChannelMixerKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::SelectiveColor(settings) => { + let kernel = SelectiveColorKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::Posterize { levels } => { + let levels = *levels as f32; + + map_colors(colors, |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.) + }) + }); + } + Self::Exposure { exposure, offset, gamma_correction } => { + let kernel = ExposureKernel { + exposure: *exposure as f32, + offset: *offset as f32, + gamma_correction: *gamma_correction as f32, + }; + map_colors(colors, |color| kernel.apply(color)); + } + Self::ColorBalance(settings) => { + let kernel = ColorBalanceKernel::new(settings); + map_colors(colors, |color| kernel.apply(color)); + } + Self::PhotoFilter { color, density, preserve_luminosity } => { + let kernel = PhotoFilterKernel::new(*color, *density, *preserve_luminosity); + map_colors(colors, |pixel| kernel.apply(pixel)); + } + Self::ColorLookup { lut_file } => { + // A file that isn't a lookup table leaves the colors as they are + let Ok(lut) = parse_cached(lut_file) else { return }; + + map_colors(colors, |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]); + + Color::from_gamma_srgb_channels(r, g, b, a) + }); + } + Self::ColorOverlay { color, blend_mode, opacity } => { + let opacity = (opacity / 100.) as f32; + map_colors(colors, |pixel| overlay_paint(pixel, *color, *blend_mode, opacity)); + } + } + } + + /// The adjustment of a single color. + pub fn apply_to_color(&self, color: Color) -> Color { + let mut colors = [color]; + self.apply(&mut colors); + colors[0] + } + + /// Whether each channel of the result depends only on the same channel of the color, making the adjustment four curves. + pub fn is_per_channel(&self) -> bool { + matches!( + self, + Self::GammaCorrection { .. } + | Self::MakeOpaque + | Self::BrightnessContrast { .. } + | Self::Levels(_) + | Self::Curves(_) + | Self::Invert + | Self::Posterize { .. } + | Self::Exposure { .. } + | Self::ColorBalance(_) + ) + } + + /// The red, green, blue, and alpha curves of a per-channel adjustment, each sampled at `size` evenly spaced gamma-encoded values from 0 to 1. + pub fn channel_curves(&self, size: usize) -> Option<[Vec; 4]> { + if !self.is_per_channel() || size < 2 { + return None; + } + + // Each sample feeds the same value to every channel, which the curves map independently + let mut samples: Vec = (0..size) + .map(|index| { + let value = index as f32 / (size - 1) as f32; + Color::from_gamma_srgb_channels(value, value, value, value) + }) + .collect(); + self.apply(&mut samples); + + let channels: Vec<[f32; 4]> = samples.iter().map(Color::to_gamma_srgb_channels).collect(); + Some(std::array::from_fn(|channel| channels.iter().map(|values| values[channel]).collect())) + } + + /// Whether every color keeps its alpha to within half an 8-bit level. + /// Only a per-channel adjustment's alpha curve can change it, since the kernels of the others all keep alpha. + pub fn preserves_alpha(&self) -> bool { + const LEVELS: usize = 256; + + self.channel_curves(LEVELS).is_none_or(|[.., alpha]| { + let identity = |index: usize| index as f32 / (LEVELS - 1) as f32; + alpha.iter().enumerate().all(|(index, value)| (value - identity(index)).abs() <= 0.5 / 255.) + }) + } +} + +/// Replaces each color with the kernel's result for it. +fn map_colors(colors: &mut [Color], kernel: impl Fn(Color) -> Color + Sync) { + fill_rows(colors, COLORS_PER_ROW, |_, row| row.iter_mut().for_each(|color| *color = kernel(*color))); +} + +/// The color with `paint` mixed over it by the blend mode, as much as the paint's alpha times `opacity` (from 0 to 1), keeping the color's own alpha. +pub fn overlay_paint(color: Color, paint: Color, blend_mode: BlendMode, opacity: f32) -> Color { + let overlay = apply_blend_mode(paint, color, blend_mode); + let amount = opacity * paint.a(); + let mix = |under: f32, over: f32| under + (over - under) * amount; + + Color::from_rgbaf32_unchecked(mix(color.r(), overlay.r()), mix(color.g(), overlay.g()), mix(color.b(), overlay.b()), color.a()) +} + +fn desaturate(color: Color, method: DesaturateMethod) -> Color { + // Gamma-encoded formulas are decoded as if they were a gray + let gamma = || color.to_gamma_srgb_channels(); + let luminance = match method { + DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(), + DesaturateMethod::LumaRec709 => { + let [r, g, b, _] = gamma(); + srgb_to_linear(0.2126 * r + 0.7152 * g + 0.0722 * b) + } + DesaturateMethod::LumaRec601 => { + let [r, g, b, _] = gamma(); + srgb_to_linear(0.299 * r + 0.587 * g + 0.114 * b) + } + DesaturateMethod::LightnessOkLab => { + // A gray's OkLab lightness is the cube root of its linear value, so cubing gives the gray of equal lightness + let lightness = color.lightness_oklab(); + lightness * lightness * lightness + } + DesaturateMethod::ChannelsAverage => color.average_rgb_channels(), + DesaturateMethod::ChannelsMinimum => color.minimum_rgb_channels(), + DesaturateMethod::ChannelsMaximum => color.maximum_rgb_channels(), + DesaturateMethod::LightnessHsl => { + // The transfer curve is monotonic, so the extremes are found first and only they are encoded + let max = linear_to_srgb(color.maximum_rgb_channels()); + let min = linear_to_srgb(color.minimum_rgb_channels()); + srgb_to_linear((max + min) / 2.) + } + }; + color.map_rgb(|_| luminance) +} + +// =================== +// Brightness/Contrast +// =================== + +/// Remaps a gamma-space channel through the stages of a Levels adjustment: the input range, the midtones gamma, and the output range. +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 = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); + let value = ((value - input_shadows).max(0.) / highlights_minus_shadows).min(1.); + let value = value.powf(inverse_gamma); + + value * (output_maximum - output_minimum) + output_minimum +} + +/// The classic Brightness/Contrast algorithm: a Levels remap around the pivot, adding the brightness before a positive +/// contrast stretch and after a negative contrast squeeze. +fn brightness_contrast_classic(value: f32, brightness: f32, contrast: f32, pivot: f32) -> f32 { + // Full contrast is a hard step, sending values at the pivot or above to white (with half a 16-bit step of slack for float ties) + if contrast >= 1. { + return if value + brightness >= pivot - 1. / 65536. { 1. } else { 0. }; + } + + let result = if contrast > 0. { + let input_shadows = pivot * contrast - brightness; + apply_levels(value, input_shadows, input_shadows + 1. - contrast, 1., 0., 1.) + } else { + let output_minimum = brightness - pivot * contrast; + apply_levels(value, 0., 1., 1., output_minimum, output_minimum + 1. + contrast) + }; + + 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, +} + +impl BrightnessCurve { + fn new(brightness: f32) -> Self { + let slope = 2_f32.powf(brightness / 110.); + let knee = 0.5 / slope; + let end_slope = (1. / (1. + 12. * (slope - 1.))).max(0.1); + + Self { slope, knee, end_slope } + } + + /// Evaluates the Hermite segment at its parameter t in 0..1, returning the value and its derivative with respect to x. + fn hermite(&self, t: f32) -> (f32, f32) { + let length = 1. - self.knee; + let start_tangent = length * self.slope; + let end_tangent = length * self.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; + + (value, derivative) + } + + fn forward(&self, x: f32) -> f32 { + if x < self.knee { + return self.slope * x; + } + + let t = ((x - self.knee) / (1. - self.knee)).min(1.); + self.hermite(t).0.min(1.) + } + + /// Inverts the curve, solving the monotone Hermite segment with a bracketed Newton iteration. + fn inverse(&self, y: f32) -> f32 { + if y <= 0.5 { + return y / self.slope; + } + + let mut low = 0.; + let mut high = 1.; + let mut t = (y - 0.5) * 2.; + for _ in 0..8 { + let (value, derivative) = self.hermite(t); + let error = value - y; + if error > 0. { + high = t; + } else { + low = t; + } + + let step = t - error / (derivative * (1. - self.knee)); + t = if step >= low && step <= high { step } else { (low + high) * 0.5 }; + } + + self.knee + t * (1. - self.knee) + } +} + +struct BrightnessContrastKernel { + use_classic: bool, + classic_pivot: f32, + brightness: f32, + contrast: f32, + first_curve: BrightnessCurve, + second_curve: BrightnessCurve, +} + +impl BrightnessContrastKernel { + fn new(brightness: f64, contrast: f64, use_classic: bool, classic_pivot: f64) -> Self { + let classic_pivot = (classic_pivot / 255.) as f32; + + // Each algorithm has its own slider range, matched here and in the Inspector + let (brightness_extent, contrast_minimum) = if use_classic { (100., -100.) } else { (150., -50.) }; + let brightness = (brightness as f32).clamp(-brightness_extent, brightness_extent); + let contrast = (contrast as f32).clamp(contrast_minimum, 100.) / 100.; + + // Beyond a magnitude of 100, the curve for 100 is applied first and the curve for the remainder after it + let magnitude = brightness.abs(); + let first_curve = BrightnessCurve::new(magnitude.min(100.)); + let second_curve = BrightnessCurve::new((magnitude - 100.).max(0.)); + + Self { + use_classic, + classic_pivot, + brightness, + contrast, + first_curve, + second_curve, + } + } + + fn apply(&self, color: Color) -> Color { + color.map_gamma_rgb(|c| { + 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 self.brightness >= 0. { + self.second_curve.forward(self.first_curve.forward(c)) + } else { + 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 * self.contrast * (2. * brightened - 1.) * brightened.min(1. - brightened); + contrasted.clamp(0., 1.) + }) + } +} + +// ====== +// Levels +// ====== + +/// 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 { + fn stage(&self, gamma: f32) -> LevelsStage { + LevelsStage { + curve: LevelsCurve::from_points(self.shadows as f32 * 2.55, self.highlights as f32 * 2.55, gamma), + output_minimum: self.output_minimums as f32 / 100., + output_maximum: self.output_maximums as f32 / 100., + } + } +} + +impl LevelsStage { + fn apply(&self, value: f32) -> f32 { + self.curve.apply(value) * (self.output_maximum - self.output_minimum) + self.output_minimum + } +} + +/// A channel's record followed by the composite record. +#[derive(Clone, Copy)] +struct LevelsChain { + first: LevelsStage, + second: LevelsStage, + two_stages: bool, +} + +impl LevelsChain { + fn new(channel: LevelsRecord, composite: LevelsRecord) -> Self { + // For PSD interop, two power functions with nothing between them (the composite's input points and the + // channel's output range at their defaults) merge into one curve with the product of the gammas, toe included + let at = |value: f64, default: f32| value as f32 == default; + let nothing_between = at(composite.shadows, 0.) && at(composite.highlights, 100.) && at(channel.output_minimums, 0.) && at(channel.output_maximums, 100.); + if nothing_between { + let merged = LevelsRecord { + output_minimums: composite.output_minimums, + output_maximums: composite.output_maximums, + ..channel + }; + let stage = merged.stage(channel.midtones as f32 * composite.midtones as f32); + Self { + first: stage, + second: stage, + two_stages: false, + } + } else { + Self { + first: channel.stage(channel.midtones as f32), + second: composite.stage(composite.midtones as f32), + two_stages: true, + } + } + } + + fn apply(&self, value: f32) -> f32 { + let value = self.first.apply(value); + if self.two_stages { self.second.apply(value) } else { value } + } +} + +struct LevelsKernel { + red: LevelsChain, + green: LevelsChain, + blue: LevelsChain, + alpha: LevelsStage, +} + +impl LevelsKernel { + fn new(settings: &LevelsSettings) -> Self { + Self { + red: LevelsChain::new(settings.red, settings.composite), + green: LevelsChain::new(settings.green, settings.composite), + blue: LevelsChain::new(settings.blue, settings.composite), + // Alpha stands apart from the composite record that the three color channels pass through + alpha: settings.alpha.stage(settings.alpha.midtones as f32), + } + } + + fn apply(&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(self.red.apply(r), self.green.apply(g), self.blue.apply(b), self.alpha.apply(a)) + } +} + +// ====== +// Curves +// ====== + +struct CurvesKernel { + composite: TransferCurveEvaluator, + red: TransferCurveEvaluator, + green: TransferCurveEvaluator, + blue: TransferCurveEvaluator, + alpha: TransferCurveEvaluator, +} + +impl CurvesKernel { + fn new(settings: &CurvesSettings) -> Self { + Self { + composite: settings.composite.evaluator(), + red: settings.red.evaluator(), + green: settings.green.evaluator(), + blue: settings.blue.evaluator(), + alpha: settings.alpha.evaluator(), + } + } + + fn map(&self, channel: &TransferCurveEvaluator, value: f32) -> f32 { + self.composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32 + } + + fn apply(&self, color: Color) -> Color { + // Curves math operates in gamma space + let [r, g, b, a] = color.to_gamma_srgb_channels(); + + // Alpha stands apart from the composite curve that the three color channels pass through + let a = self.alpha.evaluate(a as f64).clamp(0., 1.) as f32; + + Color::from_gamma_srgb_channels(self.map(&self.red, r), self.map(&self.green, g), self.map(&self.blue, b), a) + } +} + +// ============= +// Black & White +// ============= + +struct BlackAndWhiteKernel { + tint: Option, + reds: f32, + yellows: f32, + greens: f32, + cyans: f32, + blues: f32, + magentas: f32, +} + +impl BlackAndWhiteKernel { + fn new(settings: &BlackAndWhiteSettings) -> Self { + let weight = |percent: f64| (percent / 100.) as f32; + + Self { + tint: settings.use_tint.then_some(settings.tint), + reds: weight(settings.reds), + yellows: weight(settings.yellows), + greens: weight(settings.greens), + cyans: weight(settings.cyans), + blues: weight(settings.blues), + magentas: weight(settings.magentas), + } + } + + fn apply(&self, color: Color) -> Color { + // Black & White channel weights are tuned for gamma-space values + let [r, g, b, alpha_part] = color.to_gamma_srgb_channels(); + + let gray_base = r.min(g).min(b); + + let red_part = r - gray_base; + let green_part = g - gray_base; + let blue_part = b - gray_base; + + let additional = if red_part == 0. { + let cyan_part = green_part.min(blue_part); + cyan_part * self.cyans + (green_part - cyan_part) * self.greens + (blue_part - cyan_part) * self.blues + } else if green_part == 0. { + let magenta_part = red_part.min(blue_part); + magenta_part * self.magentas + (red_part - magenta_part) * self.reds + (blue_part - magenta_part) * self.blues + } else { + let yellow_part = red_part.min(green_part); + yellow_part * self.yellows + (red_part - yellow_part) * self.reds + (green_part - yellow_part) * self.greens + }; + + let luminance = (gray_base + additional).clamp(0., 1.); + let Some(tint) = self.tint else { + return Color::from_gamma_srgb_channels(luminance, luminance, luminance, alpha_part); + }; + + // The tint takes on the gray's luminosity the way the Luminosity blend mode would + let [tint_r, tint_g, tint_b, _] = tint.to_gamma_srgb_channels(); + let tint_luma = luma_rec_601_fixed_point(tint_r, tint_g, tint_b); + 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) + } +} + +// ============== +// Hue/Saturation +// ============== + +/// HSL of gamma-encoded channels: hue in degrees, saturation and lightness in 0..1. +fn gamma_rgb_to_hsl(r: f32, g: f32, b: f32) -> [f32; 3] { + let maximum = r.max(g).max(b); + let minimum = r.min(g).min(b); + let chroma = maximum - minimum; + let lightness = (maximum + minimum) / 2.; + if chroma <= 0. { + return [0., 0., lightness]; + } + + let saturation = chroma / (1. - (2. * lightness - 1.).abs()).max(1e-6); + [hexagon_hue_degrees(r, g, b), saturation.min(1.), lightness] +} + +/// Hexagon hue in degrees of three channels in any encoding, 0 for gray. +fn hexagon_hue_degrees(r: f32, g: f32, b: f32) -> f32 { + let maximum = r.max(g).max(b); + let chroma = maximum - r.min(g).min(b); + if chroma <= 0. { + return 0.; + } + + let sector = if maximum == r { + wrap_positive((g - b) / chroma, 6.) + } else if maximum == g { + (b - r) / chroma + 2. + } else { + (r - g) / chroma + 4. + }; + sector * 60. +} + +/// `value` wrapped into the range from 0 to `modulus`. +fn wrap_positive(value: f32, modulus: f32) -> f32 { + value - (value / modulus).floor() * modulus +} + +/// Gamma-encoded channels from a hue in degrees and saturation and lightness in 0..1. +fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let chroma = (1. - (2. * lightness - 1.).abs()) * saturation; + let sector = wrap_positive(hue, 360.) / 60.; + let x = chroma * (1. - (wrap_positive(sector, 2.) - 1.).abs()); + let (r, g, b) = if sector < 1. { + (chroma, x, 0.) + } else if sector < 2. { + (x, chroma, 0.) + } else if sector < 3. { + (0., chroma, x) + } else if sector < 4. { + (0., x, chroma) + } else if sector < 5. { + (x, 0., chroma) + } else { + (chroma, 0., x) + }; + let m = lightness - chroma / 2.; + + [(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 HueSaturationAmounts { + hue: f32, + saturation: f32, + lightness: f32, +} + +impl From for HueSaturationAmounts { + fn from(sliders: HueSaturationSliders) -> Self { + Self { + hue: sliders.hue as f32, + saturation: sliders.saturation as f32 / 100., + lightness: sliders.lightness as f32 / 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 HueRangeKernel { + falloff_start: f32, + range_start: f32, + range_end: f32, + falloff_end: f32, + amounts: HueSaturationAmounts, +} + +impl From for HueRangeKernel { + fn from(range: HueRangeSettings) -> Self { + // For PSD interop, each edge rounds to 1536 hue units per turn over 359 rather than 360 degrees, landing up to a degree late + let edge = |degrees: f64| (degrees as f32 * 1536. / 359.).round() * 360. / 1536.; + + Self { + falloff_start: edge(range.falloff_start), + range_start: edge(range.range_start), + range_end: edge(range.range_end), + falloff_end: edge(range.falloff_end), + amounts: range.sliders.into(), + } + } +} + +impl HueRangeKernel { + fn weight(&self, hue: f32) -> f32 { + let distance = |from: f32, to: f32| wrap_positive(to - from, 360.); + if distance(self.range_start, hue) <= distance(self.range_start, self.range_end) { + return 1.; + } + let start_falloff = distance(self.falloff_start, self.range_start); + let end_falloff = distance(self.range_end, self.falloff_end); + if distance(self.falloff_start, hue) < start_falloff { + return distance(self.falloff_start, hue) / start_falloff; + } + if distance(self.range_end, hue) < end_falloff { + return 1. - distance(self.range_end, hue) / end_falloff; + } + 0. + } +} + +/// The six ranges' combined effect on one pixel, gathered before the master sliders apply. +struct HueSaturationRangeEffect { + hue_shift: f32, + saturation_factor: f32, + fully_saturate: bool, + rgb: [f32; 3], +} + +impl HueSaturationRangeEffect { + fn apply(&mut self, range: &HueRangeKernel, original_hue: f32, original_saturation: f32) { + // Range weights come from the original hue, and grays belong to no range + let weight = if original_saturation > 0. { range.weight(original_hue) } else { 0. }; + if weight <= 0. { + return; + } + + self.hue_shift += range.amounts.hue * weight; + // For PSD interop, +100 saturates fully from the very edge of the falloff rather than scaling with the weight + if range.amounts.saturation >= 1. { + self.fully_saturate = true; + } else { + self.saturation_factor *= 1. + (saturation_gain(range.amounts.saturation) - 1.) * weight; + } + self.rgb = lightness_toward_max_or_min(self.rgb, range.amounts.lightness * weight); + } +} + +/// The factor a saturation amount in -1..1 applies to HSL saturation, quantized for PSD interop: 1 - trunc(256 a) / 256 below zero and floor(65280 / (255 - trunc(254 a))) / 256 above. +fn saturation_gain(amount: f32) -> f32 { + if amount < 0. { + 1. - (-amount * 256.).trunc() / 256. + } else { + (65280. / (255. - (amount * 254.).trunc())).floor() / 256. + } +} + +/// Blends toward white for a positive amount and toward black for a negative one, as the master lightness slider does. +fn lightness_toward_white_or_black(value: f32, amount: f32) -> f32 { + if amount >= 0. { value + (1. - value) * amount } else { value * (1. + amount) } +} + +/// A range's lightness moves the channels toward the color's own maximum (positive) or minimum (negative) instead. +fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { + let maximum = rgb[0].max(rgb[1]).max(rgb[2]); + let minimum = rgb[0].min(rgb[1]).min(rgb[2]); + let toward = if amount >= 0. { maximum } else { minimum }; + let blend = |value: f32| value + (toward - value) * amount.abs(); + [blend(rgb[0]), blend(rgb[1]), blend(rgb[2])] +} + +struct HueSaturationKernel { + master: HueSaturationAmounts, + colorize: Option, + ranges: [HueRangeKernel; 6], +} + +impl HueSaturationKernel { + fn new(settings: &HueSaturationSettings) -> Self { + Self { + master: settings.master.into(), + colorize: settings.colorize.then(|| settings.colorize_sliders.into()), + ranges: [settings.reds, settings.yellows, settings.greens, settings.cyans, settings.blues, settings.magentas].map(Into::into), + } + } + + fn apply(&self, color: Color) -> Color { + let [r, g, b, alpha] = color.to_gamma_srgb_channels(); + + if let Some(colorize) = self.colorize { + let [_, _, lightness] = gamma_rgb_to_hsl(r, g, b); + let lightness = lightness_toward_white_or_black(lightness, colorize.lightness); + let [r, g, b] = hsl_to_gamma_rgb(colorize.hue, colorize.saturation, lightness); + return Color::from_gamma_srgb_channels(r, g, b, alpha); + } + + // Each range weights its sliders by its falloff around the original hue: hue shifts add and saturation gains multiply + let [original_hue, original_saturation, _] = gamma_rgb_to_hsl(r, g, b); + let mut effect = HueSaturationRangeEffect { + hue_shift: self.master.hue, + saturation_factor: 1., + fully_saturate: false, + rgb: [r, g, b], + }; + for range in &self.ranges { + effect.apply(range, original_hue, original_saturation); + } + let HueSaturationRangeEffect { + hue_shift, + mut saturation_factor, + mut fully_saturate, + rgb, + } = effect; + if self.master.saturation >= 1. { + fully_saturate = true; + } else { + saturation_factor *= saturation_gain(self.master.saturation); + } + + // The master lightness blends toward white or black before the hue and saturation, which work in HSL of the gamma channels + let rgb = rgb.map(|channel| lightness_toward_white_or_black(channel, self.master.lightness)); + let [hue, saturation, lightness] = gamma_rgb_to_hsl(rgb[0], rgb[1], rgb[2]); + let saturation = if saturation <= 0. { + 0. + } else if fully_saturate { + 1. + } else { + (saturation * saturation_factor).min(1.) + }; + let [r, g, b] = hsl_to_gamma_rgb(hue + hue_shift, saturation, lightness); + + Color::from_gamma_srgb_channels(r, g, b, alpha) + } +} + +// ======== +// Vibrance +// ======== + +/// Vibrance then saturation, both in linear light, which equals applying each alone in turn. +fn apply_vibrance(color: Color, vibrance: f32, saturation_scale: f32) -> Color { + 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 after_vibrance = Color::from_rgbaf32_unchecked( + scale_about_maximum(r, maximum, chroma_factor, brightness_factor), + scale_about_maximum(g, maximum, chroma_factor, brightness_factor), + scale_about_maximum(b, maximum, chroma_factor, brightness_factor), + color.a(), + ); + + // 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.)) +} + +fn scale_about_maximum(channel: f32, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> f32 { + (brightness_factor * (maximum + chroma_factor * (channel - maximum))).clamp(0., 1.) +} + +/// Share of the vibrance boost the skin-tone protection removes at full weight, a fitted constant. +const VIBRANCE_PROTECTION_LOSS: f32 = 0.4857; + +/// Vibrance on linear SDR channels as `[chroma factor about the max, brightness multiply]` for an amount in -1..1, both fading out toward black. +/// Negative desaturates and darkens low-chroma colors most. Positive boosts them, brightens a little, and spares reds. +fn vibrance_factors(r: f32, g: f32, b: f32, amount: f32) -> [f32; 2] { + let maximum = r.max(g).max(b); + let minimum = r.min(g).min(b); + if maximum <= 0. { + return [1., 1.]; + } + let ratio = minimum / maximum; + let saturation = 1. - ratio; + let q = ratio * saturation; + let toe = 1. - (16. * maximum).min(1.); + let rolloff = 1. - toe * toe; + let brightness = (2. * q - q * q) * (1. - maximum) * rolloff; + + if amount < 0. { + let amount = -amount; + let chroma_factor = (1. - amount / 4.) * (1. - amount * (1. - rolloff * saturation * (1. + saturation) / 2.)); + return [chroma_factor, 1. - amount * brightness]; + } + + let protection = skin_tone_window(hexagon_hue_degrees(r, g, b)) * (1. - saturation * saturation); + let amount = amount * (1. - protection * (1. - amount)); + let boost = (5. / 6.) * (1. - VIBRANCE_PROTECTION_LOSS * protection) * amount * ratio * (1. - minimum) * rolloff; + [1. / (1. - boost), 1. + amount * brightness / 4.] +} + +/// How fully a hue falls under the skin-tone protection: all of it from red to 30 degrees, fading out by 45, and back in from 300. +fn skin_tone_window(hue: f32) -> f32 { + if hue < 45. { + ((45. - hue) / 15.).min(1.) + } else if hue >= 300. { + (hue - 300.) / 60. + } else { + 0. + } +} + +// ============= +// Channel Mixer +// ============= + +/// A row's weights and constant in 10-bit fixed point truncated toward zero, which PSD interop depends on. +#[derive(Clone, Copy)] +struct ChannelMixerWeights([f32; 4]); + +impl From for ChannelMixerWeights { + fn from(row: ChannelMixerRow) -> Self { + let weight = |percent: f64| (percent as f32 * 1024. / 100.).trunc() / 1024.; + Self([weight(row.red), weight(row.green), weight(row.blue), weight(row.constant)]) + } +} + +impl ChannelMixerWeights { + fn mix(&self, r: f32, g: f32, b: f32) -> f32 { + let [red, green, blue, constant] = self.0; + (r * red + g * green + b * blue + constant).clamp(0., 1.) + } +} + +struct ChannelMixerKernel { + monochrome: Option, + red: ChannelMixerWeights, + green: ChannelMixerWeights, + blue: ChannelMixerWeights, +} + +impl ChannelMixerKernel { + fn new(settings: &ChannelMixerSettings) -> Self { + Self { + monochrome: settings.monochrome.then(|| settings.monochrome_mix.into()), + red: settings.red.into(), + green: settings.green.into(), + blue: settings.blue.into(), + } + } + + fn apply(&self, color: Color) -> Color { + let [r, g, b, a] = color.to_gamma_srgb_channels(); + + let (out_r, out_g, out_b) = match &self.monochrome { + Some(monochrome) => { + let gray = monochrome.mix(r, g, b); + (gray, gray, gray) + } + None => (self.red.mix(r, g, b), self.green.mix(r, g, b), self.blue.mix(r, g, b)), + }; + + Color::from_gamma_srgb_channels(out_r, out_g, out_b, a) + } +} + +// =============== +// Selective Color +// =============== + +/// The groups of colors that Selective Color adjusts, in the order of its settings. +#[derive(Clone, Copy)] +enum SelectiveColorGroup { + Reds, + Yellows, + Greens, + Cyans, + Blues, + Magentas, + Whites, + Neutrals, + Blacks, +} + +struct SelectiveColorKernel { + mode: RelativeAbsolute, + /// Each group with its cyan, magenta, yellow, and black offsets, leaving out the groups left at zero. + groups: Vec<(SelectiveColorGroup, [f32; 4])>, +} + +impl SelectiveColorKernel { + fn new(settings: &SelectiveColorSettings) -> Self { + let groups = [ + (SelectiveColorGroup::Reds, settings.reds), + (SelectiveColorGroup::Yellows, settings.yellows), + (SelectiveColorGroup::Greens, settings.greens), + (SelectiveColorGroup::Cyans, settings.cyans), + (SelectiveColorGroup::Blues, settings.blues), + (SelectiveColorGroup::Magentas, settings.magentas), + (SelectiveColorGroup::Whites, settings.whites), + (SelectiveColorGroup::Neutrals, settings.neutrals), + (SelectiveColorGroup::Blacks, settings.blacks), + ]; + let groups = groups + .into_iter() + .map(|(group, inks)| (group, [inks.cyan as f32, inks.magenta as f32, inks.yellow as f32, inks.black as f32])) + .filter(|(_, inks)| inks.iter().any(|&ink| ink != 0.)) + .collect(); + + Self { mode: settings.mode, groups } + } + + fn apply(&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); + let max = |a: f32, b: f32, c: f32| a.max(b).max(c); + let med = |a: f32, b: f32, c: f32| a + b + c - min(a, b, c) - max(a, b, c); + + let max_channel = max(r, g, b); + let min_channel = min(r, g, b); + + let pixel_color_range = |group| match group { + SelectiveColorGroup::Reds => max_channel == r, + SelectiveColorGroup::Yellows => min_channel == b, + SelectiveColorGroup::Greens => max_channel == g, + SelectiveColorGroup::Cyans => min_channel == r, + SelectiveColorGroup::Blues => max_channel == b, + SelectiveColorGroup::Magentas => min_channel == g, + SelectiveColorGroup::Whites => r > 0.5 && g > 0.5 && b > 0.5, + // Every pixel, since the neutrals scale factor already vanishes at black, white, and fully saturated colors + SelectiveColorGroup::Neutrals => true, + SelectiveColorGroup::Blacks => r < 0.5 && g < 0.5 && b < 0.5, + }; + + let color_parameter_group_scale_factor_rgb = max(r, g, b) - med(r, g, b); + 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 self.mode { + RelativeAbsolute::Relative => (r - 1., g - 1., b - 1.), + RelativeAbsolute::Absolute => (-1., -1., -1.), + }; + + let mut sum = Vec3::ZERO; + for &(group, [c, m, y, k]) in &self.groups { + // Skip this group if this pixel's color isn't in the range it affects + if !pixel_color_range(group) { + continue; + } + + let color_parameter_group_scale_factor = match group { + SelectiveColorGroup::Reds | SelectiveColorGroup::Greens | SelectiveColorGroup::Blues => color_parameter_group_scale_factor_rgb, + SelectiveColorGroup::Cyans | SelectiveColorGroup::Magentas | SelectiveColorGroup::Yellows => color_parameter_group_scale_factor_cmy, + SelectiveColorGroup::Whites => min(r, g, b) * 2. - 1., + SelectiveColorGroup::Neutrals => 1. - ((max(r, g, b) - 0.5).abs() + (min(r, g, b) - 0.5).abs()), + SelectiveColorGroup::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 self.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 = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; + let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; + let offset_b = f32::clamp(ink(y) * slope_b, -b, -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); + } + + let rgb = Vec3::new(r, g, b); + let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE); + + Color::from_gamma_srgb_channels(out.x, out.y, out.z, a) + } +} + +// ======== +// Exposure +// ======== + +struct ExposureKernel { + exposure: f32, + offset: f32, + gamma_correction: f32, +} + +impl ExposureKernel { + fn adjust_channel(&self, c: f32) -> 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 linear = decode(c) * 2_f32.powf(self.exposure) + self.offset; + encode(linear.max(0.).powf(1. / self.gamma_correction).min(1.)) + } + + fn apply(&self, color: Color) -> Color { + let [r, g, b, a] = color.to_gamma_srgb_channels(); + Color::from_gamma_srgb_channels(self.adjust_channel(r), self.adjust_channel(g), self.adjust_channel(b), a) + } +} + +// ============= +// Color Balance +// ============= + +/// 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 { + fn new(black: i32, white: i32, gamma: f32) -> Self { + Self::from_points(black as f32, white as f32, gamma) + } + + /// `gamma` is the Levels dialog value (pixel exponent 1/gamma). + fn from_points(black: f32, white: f32, gamma: f32) -> Self { + let gamma = gamma.max(0.01); + let black = black.min(white - 1.); + let exponent = 1. / gamma; + + let mut curve = Self { + black, + white, + exponent, + toe_end: 0., + toe_value: 0., + toe_slope_start: 0., + toe_slope_end: 0., + }; + if gamma > 1. { + let slope = 2_f32.powf(gamma); + let intersection = 255. * slope.powf(-1. / (1. - exponent)); + // The cubic toe joins the power curve at twice the intersection + if intersection > 1e-3 { + let toe_end = 2. * intersection; + curve.toe_end = toe_end; + curve.toe_value = 255. * (toe_end / 255.).powf(exponent); + curve.toe_slope_start = slope; + curve.toe_slope_end = exponent * (toe_end / 255.).powf(exponent - 1.); + } + } + curve + } + + /// Maps one gamma-space channel value in 0..1. + fn apply(&self, value: f32) -> f32 { + let t = ((value * 255. - self.black) * 255. / (self.white - self.black)).clamp(0., 255.); + let y = if t < self.toe_end { + let u = t / self.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; + hermite_start * self.toe_end * self.toe_slope_start + hermite_end_value * self.toe_value + hermite_end_slope * self.toe_end * self.toe_slope_end + } else { + 255. * (t / 255.).powf(self.exponent) + }; + y / 255. + } +} + +/// One channel's Levels parameters from its own slider values, and (with preserve_luminosity) the slider +/// extremes across all three channels. The halvings truncate toward zero, as PSD interop requires. +#[allow(clippy::too_many_arguments)] +fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i32, h_min: i32, preserve_luminosity: bool) -> LevelsCurve { + let (black, white, tone) = if preserve_luminosity { + (s_max - s, 255 - (h - h_min), m - (m_max + m_min) / 2) + } else { + (0.max(-s), 255 - 0.max(h), (s + h) / 2 + m) + }; + + // Rounding the derived gamma to hundredths, the precision of a PSD Levels record, is needed for compatible results + let gamma = (2_f32.powf(tone as f32 / 100.) * 100.).round() / 100.; + LevelsCurve::new(black, white, gamma) +} + +struct ColorBalanceKernel { + red: LevelsCurve, + green: LevelsCurve, + blue: LevelsCurve, +} + +impl ColorBalanceKernel { + fn new(settings: &ColorBalanceSettings) -> Self { + // The derivation below is integer arithmetic, so the sliders round to whole percentages first + let sliders = |shifts: ColorBalanceShifts| [shifts.cyan_red, shifts.magenta_green, shifts.yellow_blue].map(|value| (value as f32).round() as i32); + let [s_r, s_g, s_b] = sliders(settings.shadows); + let [m_r, m_g, m_b] = sliders(settings.midtones); + let [h_r, h_g, h_b] = sliders(settings.highlights); + + let s_max = s_r.max(s_g).max(s_b); + let m_max = m_r.max(m_g).max(m_b); + let m_min = m_r.min(m_g).min(m_b); + let h_min = h_r.min(h_g).min(h_b); + let preserve_luminosity = settings.preserve_luminosity; + + Self { + red: color_balance_curve(s_r, m_r, h_r, s_max, m_max, m_min, h_min, preserve_luminosity), + green: color_balance_curve(s_g, m_g, h_g, s_max, m_max, m_min, h_min, preserve_luminosity), + blue: color_balance_curve(s_b, m_b, h_b, s_max, m_max, m_min, h_min, preserve_luminosity), + } + } + + fn apply(&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(self.red.apply(r), self.green.apply(g), self.blue.apply(b), a) + } +} + +// ============ +// Photo Filter +// ============ + +struct PhotoFilterKernel { + factor: [f32; 3], + preserve_luminosity: bool, +} + +impl PhotoFilterKernel { + fn new(color: Color, density: f64, preserve_luminosity: bool) -> Self { + let density = (density / 100.) as f32; + + // The image is multiplied in XYZ by the filter color normalized to the white point, with density easing that multiplier toward 1 + let filter_xyz = multiply_matrix(&SRGB_TO_XYZ_D50, [color.r(), color.g(), color.b()]); + let factor = [ + 1. + density * (filter_xyz[0] / WHITE_XYZ_D50[0] - 1.), + 1. + density * (filter_xyz[1] / WHITE_XYZ_D50[1] - 1.), + 1. + density * (filter_xyz[2] / WHITE_XYZ_D50[2] - 1.), + ]; + + Self { factor, preserve_luminosity } + } + + fn apply(&self, pixel: Color) -> Color { + 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] * self.factor[0], xyz[1] * self.factor[1], xyz[2] * self.factor[2]]); + let mut r = linear_to_srgb(filtered[0].clamp(0., 1.)); + let mut g = linear_to_srgb(filtered[1].clamp(0., 1.)); + let mut b = linear_to_srgb(filtered[2].clamp(0., 1.)); + + if self.preserve_luminosity { + let luma_in = luma_rec_601_fixed_point(r_in.clamp(0., 1.), g_in.clamp(0., 1.), b_in.clamp(0., 1.)); + [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed_point(r, g, b), luma_in); + } + + Color::from_gamma_srgb_channels(r, g, b, alpha) + } +} + +// sRGB colorants adapted to D50 as in the sRGB IEC61966-2.1 ICC profile, row major, and their inverse +pub const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.43607, 0.38515, 0.14307], [0.22249, 0.71687, 0.06061], [0.01392, 0.09708, 0.71410]]; +pub const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.134096, -1.6174, -0.490638], [-0.978793, 1.916295, 0.033454], [0.071971, -0.228987, 1.40538]]; +pub const WHITE_XYZ_D50: [f32; 3] = [0.96420, 1., 0.82491]; + +pub fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] { + [ + matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2], + matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2], + matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2], + ] +} + +/// The Rec. 601 luma in the 14-bit fixed point that PSD interop depends on. +fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { + (4915. * r + 9667. * g + 1802. * b) / 16384. +} diff --git a/node-graph/libraries/graphic-types/src/adjustment_tables.rs b/node-graph/libraries/graphic-types/src/adjustment_tables.rs new file mode 100644 index 00000000000..070754819f2 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/adjustment_tables.rs @@ -0,0 +1,125 @@ +//! Bakes runs of adjustments into the lookup tables the GPU carries them out with, sampling the CPU kernels so the two agree by construction. + +use crate::adjustment::Adjustment; +use core_types::Color; + +/// Entries in a one-dimensional table, enough that interpolating between them strays less than a level from even a steep curve. +pub const CURVE_TABLE_SIZE: usize = 4096; + +/// Grid points along each axis of a color cube, enough that interpolating within its tetrahedra stays within a few levels of the smooth kernels. +pub const CUBE_SIZE: usize = 65; + +/// Grid points along each axis of a draft color cube, an eighth the work of [`CUBE_SIZE`] for settings changing every frame, at about three times its error. +pub const DRAFT_CUBE_SIZE: usize = 33; + +/// One pass of the GPU's adjustment shader, with the tables it looks colors up in. Values are gamma-encoded. +#[derive(Clone, Debug, PartialEq)] +pub enum AdjustmentPass { + /// The red, green, blue, and alpha channels each through their own curve, sampled at [`CURVE_TABLE_SIZE`] values from 0 to 1. + Curves(Vec<[f32; 4]>), + /// Colors through a cube of `size` samples along each of red, green, and blue, with red varying fastest and blue slowest. + /// Alpha passes through its own curve of [`CURVE_TABLE_SIZE`] samples. + Cube { size: usize, colors: Vec<[f32; 3]>, alpha: Vec }, + /// Each color replaced by the entry of [`CURVE_TABLE_SIZE`] its luma (the weighted sum of its channels) picks, keeping its alpha. + Luma { weights: [f32; 3], colors: Vec<[f32; 3]> }, + /// Vibrance, whose steep toe near black no table captures, computed directly from its amounts in -1..1 and its saturation scale. + Vibrance { vibrance: f32, saturation_scale: f32 }, +} + +impl AdjustmentPass { + /// The passes that carry out `adjustments` in order, merging each run of those a table can capture into one pass, with cubes of `cube_size` along each side. + pub fn plan(adjustments: &[Adjustment], cube_size: usize) -> Vec { + let mut passes = Vec::new(); + let mut run: Vec<&Adjustment> = Vec::new(); + + for adjustment in adjustments { + let own_pass = match adjustment { + Adjustment::Vibrance { vibrance, saturation } => Some(Self::Vibrance { + vibrance: (vibrance / 100.) as f32, + saturation_scale: (1. + saturation / 100.) as f32, + }), + // A step in luma, which a table of luma keeps sharp where a cube would blur it + Adjustment::Threshold { .. } => Some(Self::Luma { + weights: [4915., 9667., 1802.].map(|weight| weight / 16384.), + colors: luma_table(adjustment, [4915., 9667., 1802.].map(|weight| weight / 16384.)), + }), + // A gradient's hard stops are steps too + Adjustment::GradientMap { .. } => Some(Self::Luma { + weights: [0.3, 0.59, 0.11], + colors: luma_table(adjustment, [0.3, 0.59, 0.11]), + }), + _ => None, + }; + + match own_pass { + Some(pass) => { + passes.extend(Self::from_run(&run, cube_size)); + run.clear(); + passes.push(pass); + } + None => run.push(adjustment), + } + } + passes.extend(Self::from_run(&run, cube_size)); + + passes + } + + /// Whether any of the passes looks colors up in a cube, the only table whose size varies. + pub fn any_cube(passes: &[Self]) -> bool { + passes.iter().any(|pass| matches!(pass, Self::Cube { .. })) + } + + /// One pass carrying out a run of adjustments in order, as four curves if each acts on the channels separately, or else as a cube. + fn from_run(run: &[&Adjustment], cube_size: usize) -> Option { + if run.is_empty() { + return None; + } + let apply_run = |colors: &mut [Color]| run.iter().for_each(|adjustment| adjustment.apply(colors)); + + // The color channels never read alpha, and alpha depends on alpha alone, so one ramp feeds every channel at once + let mut ramp: Vec = (0..CURVE_TABLE_SIZE) + .map(|index| { + let value = index as f32 / (CURVE_TABLE_SIZE - 1) as f32; + Color::from_gamma_srgb_channels(value, value, value, value) + }) + .collect(); + apply_run(&mut ramp); + let ramp: Vec<[f32; 4]> = ramp.iter().map(Color::to_gamma_srgb_channels).collect(); + + if run.iter().all(|adjustment| adjustment.is_per_channel()) { + return Some(Self::Curves(ramp)); + } + + let step = |index: usize| index as f32 / (cube_size - 1) as f32; + let mut cube = Vec::with_capacity(cube_size.pow(3)); + for blue in 0..cube_size { + for green in 0..cube_size { + for red in 0..cube_size { + cube.push(Color::from_gamma_srgb_channels(step(red), step(green), step(blue), 1.)); + } + } + } + apply_run(&mut cube); + + Some(Self::Cube { + size: cube_size, + colors: cube.iter().map(|color| color.to_gamma_srgb_channels()).map(|[red, green, blue, _]| [red, green, blue]).collect(), + alpha: ramp.iter().map(|channels| channels[3]).collect(), + }) + } +} + +/// The adjustment's results for grays of each luma, which stand for every color of that luma when the adjustment reads only its luma. +fn luma_table(adjustment: &Adjustment, weights: [f32; 3]) -> Vec<[f32; 3]> { + let total: f32 = weights.iter().sum(); + let mut grays: Vec = (0..CURVE_TABLE_SIZE) + .map(|index| { + let value = index as f32 / (CURVE_TABLE_SIZE - 1) as f32 / total; + Color::from_gamma_srgb_channels(value, value, value, 1.) + }) + .collect(); + adjustment.apply(&mut grays); + + grays.iter().map(|color| color.to_gamma_srgb_channels()).map(|[red, green, blue, _]| [red, green, blue]).collect() +} diff --git a/node-graph/nodes/raster/src/color_lookup_table.rs b/node-graph/libraries/graphic-types/src/color_lookup_table.rs similarity index 88% rename from node-graph/nodes/raster/src/color_lookup_table.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table.rs index 837f6c3beeb..63606d6024a 100644 --- a/node-graph/nodes/raster/src/color_lookup_table.rs +++ b/node-graph/libraries/graphic-types/src/color_lookup_table.rs @@ -6,37 +6,40 @@ mod format_cube; mod format_icc; mod format_look; -use crate::adjustments::{SRGB_TO_XYZ_D50, WHITE_XYZ_D50, XYZ_D50_TO_SRGB, multiply_matrix}; +use crate::adjustment_kernels::{SRGB_TO_XYZ_D50, WHITE_XYZ_D50, XYZ_D50_TO_SRGB, multiply_matrix}; +use core_types::color::{linear_to_srgb, srgb_to_linear}; use graphene_resource::{Resource, ResourceHash}; -use no_std_types::color::{linear_to_srgb, srgb_to_linear}; use std::sync::{Arc, Mutex, PoisonError}; pub const LUT_FILE_EXTENSIONS: &[&str] = &["cube", "3dl", "look", "csp", "icc", "icm"]; +/// How many files keep their parse results after the adjustments that read them move on to other files. +const MAX_PARSED_FILES: usize = 8; + /// A file's parse result, in the form the cache shares out. type ParsedLut = Result, LutParseError>; -/// The last file a node parsed, kept as the node's own data so the file is not parsed anew each time the node runs again. -#[derive(Debug, Clone, Default)] -pub struct LutCache(Arc>>); - -impl LutCache { - /// [`Lut::parse`] for a resource, reusing the last result while the file's content hash stays the same. - pub fn parse(&self, resource: &Resource) -> ParsedLut { - // A lock poisoned by a panic elsewhere still guards a usable cache - let mut cached = self.0.lock().unwrap_or_else(PoisonError::into_inner); - - let hash = resource.hash(); - if let Some((cached_hash, result)) = cached.as_ref() - && *cached_hash == hash - { - return result.clone(); - } +/// The files parsed most recently, least recent first, shared by every adjustment that reads a lookup table. +static PARSED_FILES: Mutex> = Mutex::new(Vec::new()); + +/// [`Lut::parse`] for a resource, reusing the result while recent adjustments keep reading a file with the same content hash. +pub fn parse_cached(resource: &Resource) -> ParsedLut { + // A lock poisoned by a panic elsewhere still guards a usable cache + let mut parsed_files = PARSED_FILES.lock().unwrap_or_else(PoisonError::into_inner); - let result = Lut::parse(resource).map(Arc::new); - *cached = Some((hash, result.clone())); - result + let hash = resource.hash(); + if let Some(index) = parsed_files.iter().position(|(parsed_hash, _)| *parsed_hash == hash) { + let entry = parsed_files.remove(index); + let result = entry.1.clone(); + parsed_files.push(entry); + return result; } + + let result = Lut::parse(resource).map(Arc::new); + parsed_files.push((hash, result.clone())); + let excess = parsed_files.len().saturating_sub(MAX_PARSED_FILES); + parsed_files.drain(..excess); + result } /// Why a file could not be read as a lookup table. @@ -419,18 +422,16 @@ mod tests { } #[test] - fn a_cache_parses_a_file_once_until_the_file_changes() { - let file = Resource::new(b"LUT_1D_SIZE 2\n0 0 0\n1 1 1\n".to_vec()); - let cache = LutCache::default(); - let first = cache.parse(&file).unwrap(); - assert!(Arc::ptr_eq(&first, &cache.parse(&file).unwrap())); - - // A cloned node shares its cache - assert!(Arc::ptr_eq(&first, &cache.clone().parse(&file).unwrap())); - - // Another file takes its place, a failed one included - assert!(cache.parse(&Resource::new(b"hello world".to_vec())).is_err()); - assert!(!Arc::ptr_eq(&first, &cache.parse(&file).unwrap())); + fn the_cache_parses_a_file_once_while_it_stays_recent() { + let file = Resource::new(b"LUT_1D_SIZE 2\n0 0 0\n0.5 1 1\n".to_vec()); + let first = parse_cached(&file).unwrap(); + assert!(Arc::ptr_eq(&first, &parse_cached(&file).unwrap())); + + // Enough other files push it out, failed ones included + for index in 0..MAX_PARSED_FILES { + assert!(parse_cached(&Resource::new(format!("not a table {index}").into_bytes())).is_err()); + } + assert!(!Arc::ptr_eq(&first, &parse_cached(&file).unwrap())); } #[test] diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs b/node-graph/libraries/graphic-types/src/color_lookup_table/format_3dl.rs similarity index 100% rename from node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table/format_3dl.rs diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs b/node-graph/libraries/graphic-types/src/color_lookup_table/format_csp.rs similarity index 100% rename from node-graph/nodes/raster/src/color_lookup_table/format_csp.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table/format_csp.rs diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_cube.rs b/node-graph/libraries/graphic-types/src/color_lookup_table/format_cube.rs similarity index 100% rename from node-graph/nodes/raster/src/color_lookup_table/format_cube.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table/format_cube.rs diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_icc.rs b/node-graph/libraries/graphic-types/src/color_lookup_table/format_icc.rs similarity index 100% rename from node-graph/nodes/raster/src/color_lookup_table/format_icc.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table/format_icc.rs diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_look.rs b/node-graph/libraries/graphic-types/src/color_lookup_table/format_look.rs similarity index 100% rename from node-graph/nodes/raster/src/color_lookup_table/format_look.rs rename to node-graph/libraries/graphic-types/src/color_lookup_table/format_look.rs diff --git a/node-graph/libraries/graphic-types/src/effects.rs b/node-graph/libraries/graphic-types/src/effects.rs new file mode 100644 index 00000000000..1496fcbda90 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/effects.rs @@ -0,0 +1,684 @@ +//! An item's effects: an ordered list of image operations on the pixels the item renders to, stored in the `ATTR_EFFECTS` attribute. +//! Each row is an [`EffectKind`] whose parameters ride its item as attributes, read as their defaults when absent. +//! Each renderer decodes the rows into [`EffectOperation`]s and carries them out its own way, at the resolution of each render. + +use crate::Graphic; +use crate::adjustment::Adjustment; +use crate::parameters::{ParameterVisitor, ReadParameters, WriteParameters}; +use core_types::ATTR_TRANSFORM; +use core_types::Color; +use core_types::blending::BlendMode; +use core_types::color::SRGBA8; +use core_types::graphene_hash::CacheHash; +use core_types::list::{ATTR_EFFECTS, Item, List}; +use dyn_any::DynAny; +use glam::{DAffine2, DMat2, DVec2, DVec3}; +use std::fmt::Debug; +use vector_types::vector::style::StrokeAlign; + +/// The image operation an [`Effect`] carries out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)] +pub enum EffectKind { + /// Softens the content by blurring it. + #[default] + Blur, + /// Casts the content's silhouette as a soft shadow beneath it. + DropShadow, + /// Casts a soft shadow inward from the content's edges, as if it were cut out of the surface. + InnerShadow, + /// Surrounds the content with a soft glow of its silhouette. + OuterGlow, + /// Glows inward from the content's edges, or outward from its center. + InnerGlow, + /// Shades the content's interior with soft bands from its silhouette cast two ways. + Satin, + /// Draws a band of color along the content's silhouette. + Outline, + /// Lights the content as if raised from the surface or pressed into it, with highlights and shadows along its edges. + BevelAndEmboss, + /// Paints over the content within its silhouette, mixing with its colors by a blend mode. A solid color paint is the color overlay adjustment. + PaintOverlay, + // The adjustments, which change each color of the content on its own, as their nodes of the same names do + Desaturate, + GammaCorrection, + MakeOpaque, + BrightnessContrast, + Levels, + Curves, + BlackAndWhite, + HueSaturation, + Invert, + Threshold, + GradientMap, + Vibrance, + ChannelMixer, + SelectiveColor, + Posterize, + Exposure, + ColorBalance, + PhotoFilter, + ColorLookup, +} + +impl std::fmt::Display for EffectKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Blur => "Blur", + Self::DropShadow => "Drop Shadow", + Self::InnerShadow => "Inner Shadow", + Self::OuterGlow => "Outer Glow", + Self::InnerGlow => "Inner Glow", + Self::Satin => "Satin", + Self::Outline => "Outline", + Self::BevelAndEmboss => "Bevel & Emboss", + Self::PaintOverlay => "Paint Overlay", + Self::Desaturate => "Desaturate", + Self::GammaCorrection => "Gamma Correction", + Self::MakeOpaque => "Make Opaque", + Self::BrightnessContrast => "Brightness/Contrast", + Self::Levels => "Levels", + Self::Curves => "Curves", + Self::BlackAndWhite => "Black & White", + Self::HueSaturation => "Hue/Saturation", + Self::Invert => "Invert", + Self::Threshold => "Threshold", + Self::GradientMap => "Gradient Map", + Self::Vibrance => "Vibrance", + Self::ChannelMixer => "Channel Mixer", + Self::SelectiveColor => "Selective Color", + Self::Posterize => "Posterize", + Self::Exposure => "Exposure", + Self::ColorBalance => "Color Balance", + Self::PhotoFilter => "Photo Filter", + Self::ColorLookup => "Color Lookup", + }) + } +} + +/// Where an inner glow starts from. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum GlowSource { + /// Glows inward from the edges. + #[default] + Edge, + /// Glows outward from the center. + Center, +} + +/// Which slopes a bevel shapes from the content's edges. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum BevelStyle { + /// Slopes up from the edges into the content. + #[default] + InnerBevel, + /// Slopes down from the edges away from the content. + OuterBevel, + /// Slopes across the edges, half inside the content and half outside. + Emboss, + /// Slopes away from the edges both ways, as if pressed into the surface along them. + PillowEmboss, +} + +/// Whether a bevel rises from the surface or sinks into it. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum BevelDirection { + /// Raised from the surface. + #[default] + Up, + /// Pressed into the surface. + Down, +} + +/// The deviation of the blur that rounds a bevel's slopes of `size`, in the same units. +pub fn bevel_smoothing(size: f64) -> f64 { + size.max(0.) / 4. +} + +/// The unit vector toward a bevel's light from its `angle` and `altitude` in degrees, with `lengths_to_target` mapping document directions into the y-down space it's lit in. +pub fn bevel_light(angle: f64, altitude: f64, lengths_to_target: DMat2) -> DVec3 { + let (angle, altitude) = (angle.to_radians(), altitude.clamp(0., 90.).to_radians()); + let across = (lengths_to_target * DVec2::new(angle.cos(), -angle.sin())).try_normalize().unwrap_or(DVec2::X); + (across * altitude.cos()).extend(altitude.sin()) +} + +/// An overlay's paint with its unit square fitted to `content_bounds`, the content's extent in the space it's drawn in, if it's a gradient or image without its own placement. +pub fn fitted_paint(paint: &Graphic, content_bounds: [DVec2; 2]) -> Option { + let unplaced = match paint { + Graphic::Gradient(item) => item.attribute::(ATTR_TRANSFORM).is_none(), + Graphic::RasterCPU(item) => item.attribute::(ATTR_TRANSFORM).is_none(), + Graphic::RasterGPU(item) => item.attribute::(ATTR_TRANSFORM).is_none(), + _ => false, + }; + if !unplaced { + return None; + } + + let fit = DAffine2::from_translation(content_bounds[0]) * DAffine2::from_scale(content_bounds[1] - content_bounds[0]); + let mut paint = paint.clone(); + match &mut paint { + Graphic::Gradient(item) => item.set_attribute(ATTR_TRANSFORM, fit), + Graphic::RasterCPU(item) => item.set_attribute(ATTR_TRANSFORM, fit), + Graphic::RasterGPU(item) => item.set_attribute(ATTR_TRANSFORM, fit), + _ => {} + } + Some(paint) +} + +/// One image operation of an [`Effects`] list: an [`EffectKind`] plus its parameters, carried as attributes on the inner item. +/// Each parameter's attribute is named after the input of the node that stamps the kind. Attributes the kind doesn't take are ignored. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct Effect(pub Item); + +impl Effect { + /// Creates an effect row carrying out the given operation, stamping only the parameters that differ from their implicit defaults. + pub fn new(operation: &EffectOperation) -> Self { + let mut item = Item::new_from_element(operation.kind()); + operation.clone().visit_parameters(&mut WriteParameters(&mut item)); + Self(item) + } + + /// This effect's kind. + pub fn kind(&self) -> EffectKind { + *self.0.element() + } + + /// Decodes the parameters into an [`EffectOperation`], falling back to the default for any absent attribute. + pub fn operation(&self) -> EffectOperation { + let mut operation = EffectOperation::placeholder(self.kind()); + operation.visit_parameters(&mut ReadParameters(&|key| self.0.attributes().get_any(key))); + + // A solid paint mixes the same into every pixel, so the renderers carry it out with the other adjustments + match operation { + EffectOperation::PaintOverlay { + paint: Graphic::Color(color), + blend_mode, + opacity, + } => EffectOperation::Adjustment(Adjustment::ColorOverlay { + color: *color.element(), + blend_mode, + opacity, + }), + operation => operation, + } + } +} + +/// An item's ordered list of effects. A blur acts on the result of those before it, while shadows and glows are cast from the content and stack in order beneath it (or within it, for an inner shadow). +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct Effects(pub List); + +impl Effects { + /// Creates the list of rows carrying out the given operations in order. + pub fn from_operations(operations: impl IntoIterator) -> Self { + let mut effects = Self::default(); + for operation in operations { + effects.0.push(Item::new_from_element(Effect::new(&operation))); + } + effects + } + + /// The effects in the order they're carried out. + pub fn effects(&self) -> impl Iterator { + self.0.iter_element_values() + } + + /// The effects after the first `count`. + pub fn after(&self, count: usize) -> Self { + let mut remaining = Self::default(); + for effect in self.effects().skip(count) { + remaining.0.push(Item::new_from_element(effect.clone())); + } + remaining + } + + /// The effects decoded into the operations renderers carry out, in order. + pub fn operations(&self) -> impl Iterator + '_ { + self.effects().map(Effect::operation) + } + + /// How far past the content the effects together can paint, in document pixels. + pub fn reach(&self) -> f64 { + self.operations().map(|operation| operation.reach()).sum() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// How far an outline of `weight` reaches outside the silhouette's edge and inside it, by where it's aligned. +pub fn outline_extents(weight: f64, align: StrokeAlign) -> (f64, f64) { + match align { + StrokeAlign::Outside => (weight, 0.), + StrokeAlign::Inside => (0., weight), + StrokeAlign::Center => (weight / 2., weight / 2.), + } +} + +/// Appends an effect to the item's list, after those already applied to it. +pub fn stamp_effect(item: &mut Item, effect: Effect) { + item.attribute_mut_or_insert_default::(ATTR_EFFECTS).0.push(Item::new_from_element(effect)); +} + +/// An effect's parameters decoded from its row, the form that renderers carry it out from. Lengths are in document pixels. +#[derive(Clone, Debug, PartialEq)] +pub enum EffectOperation { + Blur { + /// The standard deviation of the Gaussian, or the half width of the box. + radius: f64, + /// Whether to average a box of pixels rather than weighting them by a Gaussian. + box_blur: bool, + /// Whether to blend the colors with their gamma encoding rather than in linear light. + gamma: bool, + }, + DropShadow { + color: Color, + offset: DVec2, + blur: f64, + spread: f64, + }, + InnerShadow { + color: Color, + offset: DVec2, + blur: f64, + spread: f64, + }, + OuterGlow { + color: Color, + blur: f64, + spread: f64, + }, + InnerGlow { + color: Color, + blur: f64, + /// How far the glow grows into the content before it softens. + choke: f64, + source: GlowSource, + }, + Satin { + color: Color, + /// How far each of the two casts of the silhouette is moved, in opposite directions. + offset: DVec2, + blur: f64, + invert: bool, + }, + Outline { + color: Color, + weight: f64, + align: StrokeAlign, + }, + BevelAndEmboss { + style: BevelStyle, + /// How steep the slopes are, as a percentage. + depth: f64, + direction: BevelDirection, + /// How wide the slopes are. + size: f64, + /// How much the highlights and shadows are blurred. + soften: f64, + /// The direction the light comes from, in degrees counterclockwise from the right. + angle: f64, + /// How high the light is above the surface, in degrees from flat to straight overhead. + altitude: f64, + highlight_color: Color, + shadow_color: Color, + }, + /// Paints over the content with a paint other than a solid color, since a solid one decodes into the color overlay adjustment instead. + PaintOverlay { + /// Placed in the space the content's item is, or fitted to the content's bounds if it's a gradient or image without its own placement. + paint: Graphic, + blend_mode: BlendMode, + /// Percent. + opacity: f64, + }, + Adjustment(Adjustment), +} + +impl EffectOperation { + /// The operation an effect of this kind carries out when its row gives none of its parameters, matching the defaults of the node that stamps it. + pub fn default_for(kind: EffectKind) -> Self { + Effect(Item::new_from_element(kind)).operation() + } + + pub fn kind(&self) -> EffectKind { + match self { + Self::Blur { .. } => EffectKind::Blur, + Self::DropShadow { .. } => EffectKind::DropShadow, + Self::InnerShadow { .. } => EffectKind::InnerShadow, + Self::OuterGlow { .. } => EffectKind::OuterGlow, + Self::InnerGlow { .. } => EffectKind::InnerGlow, + Self::Satin { .. } => EffectKind::Satin, + Self::Outline { .. } => EffectKind::Outline, + Self::BevelAndEmboss { .. } => EffectKind::BevelAndEmboss, + Self::PaintOverlay { .. } => EffectKind::PaintOverlay, + Self::Adjustment(adjustment) => adjustment.kind(), + } + } + + /// How far past the content this operation can paint, or reads from, in document pixels. + pub fn reach(&self) -> f64 { + // A Gaussian is negligible past three standard deviations + match self { + Self::Blur { radius, .. } => 3. * radius.max(0.), + Self::DropShadow { offset, blur, spread, .. } => offset.length() + spread.max(0.) + 3. * blur.max(0.), + Self::InnerShadow { offset, blur, .. } => offset.length() + 3. * blur.max(0.), + Self::OuterGlow { blur, spread, .. } => spread.max(0.) + 3. * blur.max(0.), + Self::InnerGlow { blur, choke, .. } => choke.max(0.) + 3. * blur.max(0.), + Self::Satin { offset, blur, .. } => offset.length() + 3. * blur.max(0.), + Self::Outline { weight, .. } => weight.max(0.), + Self::BevelAndEmboss { size, soften, .. } => size.max(0.) + 3. * (soften.max(0.) + bevel_smoothing(*size)), + Self::PaintOverlay { .. } | Self::Adjustment(_) => 0., + } + } + + /// An operation of this kind with arbitrary parameters, for a visitor to fill in. + fn placeholder(kind: EffectKind) -> Self { + match kind { + EffectKind::Blur => Self::Blur { + radius: 0., + box_blur: false, + gamma: false, + }, + EffectKind::DropShadow => Self::DropShadow { + color: Color::TRANSPARENT, + offset: DVec2::ZERO, + blur: 0., + spread: 0., + }, + EffectKind::InnerShadow => Self::InnerShadow { + color: Color::TRANSPARENT, + offset: DVec2::ZERO, + blur: 0., + spread: 0., + }, + EffectKind::OuterGlow => Self::OuterGlow { + color: Color::TRANSPARENT, + blur: 0., + spread: 0., + }, + EffectKind::InnerGlow => Self::InnerGlow { + color: Color::TRANSPARENT, + blur: 0., + choke: 0., + source: GlowSource::default(), + }, + EffectKind::Satin => Self::Satin { + color: Color::TRANSPARENT, + offset: DVec2::ZERO, + blur: 0., + invert: false, + }, + EffectKind::Outline => Self::Outline { + color: Color::TRANSPARENT, + weight: 0., + align: StrokeAlign::default(), + }, + EffectKind::BevelAndEmboss => Self::BevelAndEmboss { + style: BevelStyle::default(), + depth: 0., + direction: BevelDirection::default(), + size: 0., + soften: 0., + angle: 0., + altitude: 0., + highlight_color: Color::TRANSPARENT, + shadow_color: Color::TRANSPARENT, + }, + EffectKind::PaintOverlay => Self::PaintOverlay { + paint: Graphic::default(), + blend_mode: BlendMode::default(), + opacity: 0., + }, + EffectKind::Desaturate => Self::Adjustment(Adjustment::Desaturate { method: Default::default() }), + EffectKind::GammaCorrection => Self::Adjustment(Adjustment::GammaCorrection { gamma: 0., inverse: false }), + EffectKind::MakeOpaque => Self::Adjustment(Adjustment::MakeOpaque), + EffectKind::BrightnessContrast => Self::Adjustment(Adjustment::BrightnessContrast { + brightness: 0., + contrast: 0., + use_classic: false, + classic_pivot: 0., + }), + EffectKind::Levels => Self::Adjustment(Adjustment::Levels(Default::default())), + EffectKind::Curves => Self::Adjustment(Adjustment::Curves(Default::default())), + EffectKind::BlackAndWhite => Self::Adjustment(Adjustment::BlackAndWhite(Default::default())), + EffectKind::HueSaturation => Self::Adjustment(Adjustment::HueSaturation(Default::default())), + EffectKind::Invert => Self::Adjustment(Adjustment::Invert), + EffectKind::Threshold => Self::Adjustment(Adjustment::Threshold { min_luminance: 0., max_luminance: 0. }), + EffectKind::GradientMap => Self::Adjustment(Adjustment::GradientMap { + gradient: Default::default(), + reverse: false, + }), + EffectKind::Vibrance => Self::Adjustment(Adjustment::Vibrance { vibrance: 0., saturation: 0. }), + EffectKind::ChannelMixer => Self::Adjustment(Adjustment::ChannelMixer(Default::default())), + EffectKind::SelectiveColor => Self::Adjustment(Adjustment::SelectiveColor(Default::default())), + EffectKind::Posterize => Self::Adjustment(Adjustment::Posterize { levels: 0 }), + EffectKind::Exposure => Self::Adjustment(Adjustment::Exposure { + exposure: 0., + offset: 0., + gamma_correction: 0., + }), + EffectKind::ColorBalance => Self::Adjustment(Adjustment::ColorBalance(Default::default())), + EffectKind::PhotoFilter => Self::Adjustment(Adjustment::PhotoFilter { + color: Color::TRANSPARENT, + density: 0., + preserve_luminosity: false, + }), + EffectKind::ColorLookup => Self::Adjustment(Adjustment::ColorLookup { lut_file: Default::default() }), + } + } + + /// Visits each parameter with the attribute it rides and its implicit default, the one listing that writing and reading rows share. + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor) { + match self { + Self::Blur { radius, box_blur, gamma } => { + visitor.parameter("radius", radius, 0.); + visitor.parameter("box_blur", box_blur, false); + visitor.parameter("gamma", gamma, false); + } + Self::DropShadow { color, offset, blur, spread } | Self::InnerShadow { color, offset, blur, spread } => { + visitor.parameter("color", color, Color::from(SRGBA8::new(0, 0, 0, 0x80))); + visitor.parameter("offset", offset, DVec2::splat(4.)); + visitor.parameter("blur", blur, 4.); + visitor.parameter("spread", spread, 0.); + } + Self::OuterGlow { color, blur, spread } => { + visitor.parameter("color", color, Color::from(SRGBA8::new(0xff, 0xff, 0xbe, 0xbf))); + visitor.parameter("blur", blur, 8.); + visitor.parameter("spread", spread, 0.); + } + Self::InnerGlow { color, blur, choke, source } => { + visitor.parameter("color", color, Color::from(SRGBA8::new(0xff, 0xff, 0xbe, 0xbf))); + visitor.parameter("blur", blur, 8.); + visitor.parameter("choke", choke, 0.); + visitor.parameter("source", source, GlowSource::default()); + } + Self::Satin { color, offset, blur, invert } => { + visitor.parameter("color", color, Color::from(SRGBA8::new(0, 0, 0, 0x80))); + visitor.parameter("offset", offset, DVec2::new(10., 4.)); + visitor.parameter("blur", blur, 14.); + visitor.parameter("invert", invert, true); + } + Self::Outline { color, weight, align } => { + visitor.parameter("color", color, Color::BLACK); + visitor.parameter("weight", weight, 3.); + visitor.parameter("align", align, StrokeAlign::Outside); + } + Self::BevelAndEmboss { + style, + depth, + direction, + size, + soften, + angle, + altitude, + highlight_color, + shadow_color, + } => { + visitor.parameter("style", style, BevelStyle::default()); + visitor.parameter("depth", depth, 100.); + visitor.parameter("direction", direction, BevelDirection::default()); + visitor.parameter("size", size, 5.); + visitor.parameter("soften", soften, 0.); + visitor.parameter("angle", angle, 120.); + visitor.parameter("altitude", altitude, 30.); + visitor.parameter("highlight_color", highlight_color, Color::from(SRGBA8::new(0xff, 0xff, 0xff, 0xbf))); + visitor.parameter("shadow_color", shadow_color, Color::from(SRGBA8::new(0, 0, 0, 0xbf))); + } + Self::PaintOverlay { paint, blend_mode, opacity } => { + visitor.parameter("paint", paint, Graphic::from(Color::BLACK)); + visitor.parameter("blend_mode", blend_mode, BlendMode::default()); + visitor.parameter("opacity", opacity, 100.); + } + Self::Adjustment(adjustment) => adjustment.visit_parameters(visitor), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vector_types::{Gradient, GradientStop}; + + #[test] + fn rows_round_trip_their_operations_eliding_defaults() { + let operations = [ + EffectOperation::Blur { + radius: 6., + box_blur: true, + gamma: false, + }, + EffectOperation::default_for(EffectKind::DropShadow), + EffectOperation::InnerShadow { + color: Color::RED, + offset: DVec2::new(-2., 3.), + blur: 1., + spread: 5., + }, + EffectOperation::OuterGlow { + color: Color::BLUE, + blur: 8., + spread: 2., + }, + EffectOperation::InnerGlow { + color: Color::GREEN, + blur: 3., + choke: 1., + source: GlowSource::Center, + }, + EffectOperation::Satin { + color: Color::BLACK, + offset: DVec2::new(5., -6.), + blur: 2., + invert: false, + }, + EffectOperation::Outline { + color: Color::RED, + weight: 7., + align: StrokeAlign::Center, + }, + EffectOperation::BevelAndEmboss { + style: BevelStyle::PillowEmboss, + depth: 250., + direction: BevelDirection::Down, + size: 12., + soften: 2., + angle: -45., + altitude: 60., + highlight_color: Color::YELLOW, + shadow_color: Color::BLUE, + }, + EffectOperation::PaintOverlay { + paint: Graphic::from(Gradient::new([0., 1.].map(|position| GradientStop { + position, + midpoint: 0.5, + color: Color::from_rgbaf32_unchecked(position as f32, 0., 1., 1.), + }))), + blend_mode: BlendMode::Multiply, + opacity: 60., + }, + EffectOperation::Adjustment(Adjustment::ColorOverlay { + color: Color::GREEN, + blend_mode: BlendMode::Screen, + opacity: 100., + }), + ]; + + for operation in operations { + assert_eq!(Effect::new(&operation).operation(), operation); + } + + // A solid paint decodes into the color overlay adjustment, which the Paint Overlay row also carries + let solid = EffectOperation::PaintOverlay { + paint: Graphic::from(Color::RED), + blend_mode: BlendMode::Normal, + opacity: 50., + }; + let solid_row = Effect::new(&solid); + assert_eq!(solid_row.kind(), EffectKind::PaintOverlay); + assert_eq!( + solid_row.operation(), + EffectOperation::Adjustment(Adjustment::ColorOverlay { + color: Color::RED, + blend_mode: BlendMode::Normal, + opacity: 50., + }) + ); + + let default_row = Effect::new(&EffectOperation::default_for(EffectKind::DropShadow)); + assert_eq!(default_row.0.attributes().iter_any().count(), 0, "a default operation stamps no parameters"); + } + + #[test] + fn adjustment_rows_elide_their_defaults_and_name_grouped_parameters() { + use EffectKind::*; + let kinds = [ + Desaturate, + GammaCorrection, + MakeOpaque, + BrightnessContrast, + Levels, + Curves, + BlackAndWhite, + HueSaturation, + Invert, + Threshold, + GradientMap, + Vibrance, + ChannelMixer, + SelectiveColor, + Posterize, + Exposure, + ColorBalance, + PhotoFilter, + ColorLookup, + PaintOverlay, + ]; + for kind in kinds { + let operation = EffectOperation::default_for(kind); + assert_eq!(operation.kind(), kind); + assert_eq!(Effect::new(&operation).0.attributes().iter_any().count(), 0, "{kind} stamps no defaults"); + } + + // A parameter in one of the node's groups rides the attribute named with the group's prefix + let mut levels = EffectOperation::default_for(Levels); + if let EffectOperation::Adjustment(Adjustment::Levels(settings)) = &mut levels { + settings.blue.midtones = 1.5; + } + let row = Effect::new(&levels); + assert_eq!(row.0.attribute::("blue_midtones"), Some(&1.5)); + assert_eq!(row.operation(), levels); + } +} diff --git a/node-graph/libraries/graphic-types/src/generator.rs b/node-graph/libraries/graphic-types/src/generator.rs new file mode 100644 index 00000000000..271718446b6 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/generator.rs @@ -0,0 +1,442 @@ +//! Procedural patterns described by their settings, rendered at whatever resolution is needed of whatever part is seen. + +use crate::parameters::{ParameterVisitor, ReadParameters, StampParameters}; +use core_types::Color; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::graphene_hash::CacheHash; +use core_types::list::Item; +use core_types::render_complexity::RenderComplexity; +use dyn_any::DynAny; +use glam::{DAffine2, DVec2, IVec2}; +use raster_types::Image; +use raster_types::parallel_rows::fill_rows; +use std::any::Any; + +/// Style of noise pattern. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum NoiseType { + #[default] + Perlin, + #[label("OpenSimplex2")] + OpenSimplex2, + #[label("OpenSimplex2S")] + OpenSimplex2S, + Cellular, + ValueCubic, + Value, + WhiteNoise, +} + +/// Style of layered levels of the noise pattern. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +pub enum FractalType { + #[default] + None, + #[label("Fractional Brownian Motion")] + FBm, + Ridged, + PingPong, + #[label("Progressive (Domain Warp Only)")] + DomainWarpProgressive, + #[label("Independent (Domain Warp Only)")] + DomainWarpIndependent, +} + +/// Distance function used by the cellular noise. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +pub enum CellularDistanceFunction { + #[default] + Euclidean, + #[label("Euclidean Squared (Faster)")] + EuclideanSq, + Manhattan, + Hybrid, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +pub enum CellularReturnType { + CellValue, + #[default] + #[label("Nearest (F1)")] + Nearest, + #[label("Next Nearest (F2)")] + NextNearest, + #[label("Average (F1 / 2 + F2 / 2)")] + Average, + #[label("Difference (F2 - F1)")] + Difference, + #[label("Product (F2 * F1 / 2)")] + Product, + #[label("Division (F1 / F2)")] + Division, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum DomainWarpType { + #[default] + None, + #[label("OpenSimplex2")] + OpenSimplex2, + #[label("OpenSimplex2 Reduced")] + OpenSimplex2Reduced, + BasicGrid, +} + +/// The settings of a noise pattern, whose lengths are measured in the document units of the space it's sampled in. +#[derive(Debug, Clone, Default, PartialEq, CacheHash)] +pub struct NoiseSettings { + pub seed: i64, + pub scale: f64, + pub noise_type: NoiseType, + pub domain_warp_type: DomainWarpType, + pub domain_warp_amplitude: f64, + pub fractal_type: FractalType, + pub fractal_octaves: i64, + pub fractal_lacunarity: f64, + pub fractal_gain: f64, + pub fractal_weighted_strength: f64, + pub fractal_ping_pong_strength: f64, + pub cellular_distance_function: CellularDistanceFunction, + pub cellular_return_type: CellularReturnType, + pub cellular_jitter: f64, +} + +/// A procedural pattern, a function of position in its own space that renders across any area at any resolution. +#[derive(Debug, Clone, PartialEq, CacheHash)] +pub enum GeneratorPattern { + Noise(NoiseSettings), + /// The Mandelbrot set, with the unit square spanning -2 to 1 along the real axis and -1.5 to 1.5 along the imaginary axis. + Mandelbrot, +} + +/// A procedural pattern filling the whole plane, which renderers draw at whatever resolution they need across whatever part they see. +/// The item's transform places the pattern's own space, and a noise pattern's settings ride the item as attributes named after the Noise Pattern node's parameters. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny)] +pub enum Generator { + #[default] + Noise, + /// The Mandelbrot set, with the unit square spanning -2 to 1 along the real axis and -1.5 to 1.5 along the imaginary axis. + Mandelbrot, +} + +impl std::fmt::Display for Generator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Noise => "Noise", + Self::Mandelbrot => "Mandelbrot", + }) + } +} + +impl BoundingBox for Generator { + fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + match transform.matrix2.determinant() == 0. { + true => RenderBoundingBox::None, + false => RenderBoundingBox::Infinite, + } + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + self.bounding_box(transform, include_stroke) + } +} + +impl RenderComplexity for Generator { + fn render_complexity(&self) -> usize { + 1 + } +} + +impl NoiseSettings { + /// Stamps every setting onto the generator's item, whatever its value, so which attributes the item carries doesn't depend on them. + pub fn stamp(&self, item: &mut Item) { + self.clone().visit_parameters(&mut StampParameters(item)); + } + + /// Visits each setting with the attribute it rides, named after the Noise Pattern node's parameter, and its default, which is the node's. + fn visit_parameters(&mut self, visitor: &mut impl ParameterVisitor) { + visitor.parameter("seed", &mut self.seed, 0); + visitor.parameter("scale", &mut self.scale, 10.); + visitor.parameter("noise_type", &mut self.noise_type, NoiseType::default()); + visitor.parameter("domain_warp_type", &mut self.domain_warp_type, DomainWarpType::default()); + visitor.parameter("domain_warp_amplitude", &mut self.domain_warp_amplitude, 100.); + visitor.parameter("fractal_type", &mut self.fractal_type, FractalType::default()); + visitor.parameter("fractal_octaves", &mut self.fractal_octaves, 3); + visitor.parameter("fractal_lacunarity", &mut self.fractal_lacunarity, 2.); + visitor.parameter("fractal_gain", &mut self.fractal_gain, 0.5); + visitor.parameter("fractal_weighted_strength", &mut self.fractal_weighted_strength, 0.); + visitor.parameter("fractal_ping_pong_strength", &mut self.fractal_ping_pong_strength, 2.); + visitor.parameter("cellular_distance_function", &mut self.cellular_distance_function, CellularDistanceFunction::default()); + visitor.parameter("cellular_return_type", &mut self.cellular_return_type, CellularReturnType::default()); + visitor.parameter("cellular_jitter", &mut self.cellular_jitter, 1.); + } +} + +impl GeneratorPattern { + /// The pattern a generator draws, reading a noise pattern's settings with `attribute`, which looks one of the item's attributes up by key. + /// Each setting the item lacks takes its default. + pub fn decode<'a>(generator: Generator, attribute: &'a dyn Fn(&str) -> Option<&'a dyn Any>) -> Self { + match generator { + Generator::Noise => { + let mut settings = NoiseSettings::default(); + settings.visit_parameters(&mut ReadParameters(attribute)); + Self::Noise(settings) + } + Generator::Mandelbrot => Self::Mandelbrot, + } + } + + /// The pattern a generator item draws. + pub fn of_item(item: &Item) -> Self { + Self::decode(*item.element(), &|key| item.attributes().get_any(key)) + } +} + +/// The most iterations a point is tested for escaping the Mandelbrot set, beyond which it counts as inside. +const MANDELBROT_ITERATIONS: u32 = 255; + +impl GeneratorPattern { + /// The pattern across `area` of its own space, from its minimum to its maximum corner, sampled at the centers of a `width` by `height` grid of pixels. + /// The rows are rendered in parallel on native targets. + pub fn render(&self, area: [DVec2; 2], width: u32, height: u32) -> Image { + let mut image = Image::new(width, height, Color::TRANSPARENT); + let pixel_size = (area[1] - area[0]) / DVec2::new(width as f64, height as f64); + let pixel_center = |x: usize, y: usize| area[0] + (DVec2::new(x as f64, y as f64) + 0.5) * pixel_size; + + let sample = self.sampler(); + fill_rows(&mut image.data, width as usize, |y, row| { + row.iter_mut().enumerate().for_each(|(x, pixel)| *pixel = sample(pixel_center(x, y))); + }); + + image + } + + /// Samples the pattern's color at points in its own space, after the setup shared by every sample. + pub fn sampler(&self) -> impl Fn(DVec2) -> Color + Sync + use<> { + let noise = match self { + Self::Noise(settings) => Some(NoiseSampler::new(settings)), + Self::Mandelbrot => None, + }; + + move |point| match &noise { + Some(noise) => Color::from_luminance(noise.sample(point)), + None => { + let value = mandelbrot_escape_iterations(point * 3. - DVec2::new(2., 1.5)) as f32 / MANDELBROT_ITERATIONS as f32; + Color::from_rgbaf32_unchecked(value, value, value, 1.) + } + } + } +} + +/// Samples a noise pattern's luminance, from 0 to 1, at positions in its space. +struct NoiseSampler { + noise: fastnoise_lite::FastNoiseLite, + white_noise_seed: Option, + domain_warp: bool, +} + +impl NoiseSampler { + fn new(settings: &NoiseSettings) -> Self { + let mut noise = fastnoise_lite::FastNoiseLite::with_seed(settings.seed as i32); + noise.set_frequency(Some(1. / (settings.scale as f32).max(f32::EPSILON))); + + // Domain warp + let domain_warp_type = match settings.domain_warp_type { + DomainWarpType::None => None, + DomainWarpType::OpenSimplex2 => Some(fastnoise_lite::DomainWarpType::OpenSimplex2), + DomainWarpType::OpenSimplex2Reduced => Some(fastnoise_lite::DomainWarpType::OpenSimplex2Reduced), + DomainWarpType::BasicGrid => Some(fastnoise_lite::DomainWarpType::BasicGrid), + }; + let domain_warp = domain_warp_type.is_some() && settings.domain_warp_amplitude > 0.; + noise.set_domain_warp_type(domain_warp_type); + noise.set_domain_warp_amp(Some(settings.domain_warp_amplitude as f32)); + + // Noise and fractal + let noise_type = match settings.noise_type { + NoiseType::Perlin => fastnoise_lite::NoiseType::Perlin, + NoiseType::OpenSimplex2 => fastnoise_lite::NoiseType::OpenSimplex2, + NoiseType::OpenSimplex2S => fastnoise_lite::NoiseType::OpenSimplex2S, + NoiseType::Cellular => fastnoise_lite::NoiseType::Cellular, + NoiseType::ValueCubic => fastnoise_lite::NoiseType::ValueCubic, + NoiseType::Value => fastnoise_lite::NoiseType::Value, + NoiseType::WhiteNoise => { + return Self { + noise, + white_noise_seed: Some(settings.seed as i32), + domain_warp: false, + }; + } + }; + noise.set_noise_type(Some(noise_type)); + let fractal_type = match settings.fractal_type { + FractalType::None => fastnoise_lite::FractalType::None, + FractalType::FBm => fastnoise_lite::FractalType::FBm, + FractalType::Ridged => fastnoise_lite::FractalType::Ridged, + FractalType::PingPong => fastnoise_lite::FractalType::PingPong, + FractalType::DomainWarpProgressive => fastnoise_lite::FractalType::DomainWarpProgressive, + FractalType::DomainWarpIndependent => fastnoise_lite::FractalType::DomainWarpIndependent, + }; + noise.set_fractal_type(Some(fractal_type)); + noise.set_fractal_octaves(Some(settings.fractal_octaves as i32)); + noise.set_fractal_lacunarity(Some(settings.fractal_lacunarity as f32)); + noise.set_fractal_gain(Some(settings.fractal_gain as f32)); + noise.set_fractal_weighted_strength(Some(settings.fractal_weighted_strength as f32)); + noise.set_fractal_ping_pong_strength(Some(settings.fractal_ping_pong_strength as f32)); + + // Cellular + let cellular_distance_function = match settings.cellular_distance_function { + CellularDistanceFunction::Euclidean => fastnoise_lite::CellularDistanceFunction::Euclidean, + CellularDistanceFunction::EuclideanSq => fastnoise_lite::CellularDistanceFunction::EuclideanSq, + CellularDistanceFunction::Manhattan => fastnoise_lite::CellularDistanceFunction::Manhattan, + CellularDistanceFunction::Hybrid => fastnoise_lite::CellularDistanceFunction::Hybrid, + }; + let cellular_return_type = match settings.cellular_return_type { + CellularReturnType::CellValue => fastnoise_lite::CellularReturnType::CellValue, + CellularReturnType::Nearest => fastnoise_lite::CellularReturnType::Distance, + CellularReturnType::NextNearest => fastnoise_lite::CellularReturnType::Distance2, + CellularReturnType::Average => fastnoise_lite::CellularReturnType::Distance2Add, + CellularReturnType::Difference => fastnoise_lite::CellularReturnType::Distance2Sub, + CellularReturnType::Product => fastnoise_lite::CellularReturnType::Distance2Mul, + CellularReturnType::Division => fastnoise_lite::CellularReturnType::Distance2Div, + }; + noise.set_cellular_distance_function(Some(cellular_distance_function)); + noise.set_cellular_return_type(Some(cellular_return_type)); + noise.set_cellular_jitter(Some(settings.cellular_jitter as f32)); + + Self { + noise, + white_noise_seed: None, + domain_warp, + } + } + + fn sample(&self, position: DVec2) -> f32 { + // White noise gives each square of one document unit its own random value + if let Some(seed) = self.white_noise_seed { + return white_noise(seed, position.floor().as_ivec2()); + } + + let (mut x, mut y) = (position.x as f32, position.y as f32); + if self.domain_warp { + (x, y) = self.noise.domain_warp_2d(x, y); + } + (self.noise.get_noise_2d(x, y) + 1.) * 0.5 + } +} + +/// A random value from 0 to 1 for each cell of a grid, the same for the same seed and cell, matching the GPU's. +fn white_noise(seed: i32, cell: IVec2) -> f32 { + let [hash, _, _] = pcg3d([cell.x as u32, cell.y as u32, seed as u32]); + + // The top 24 bits, which an f32 holds exactly + (hash >> 8) as f32 / (1_u32 << 24) as f32 +} + +/// PCG3D from Jarzynski and Olano's "Hash Functions for GPU Rendering", which spreads every input bit across the result. +fn pcg3d(input: [u32; 3]) -> [u32; 3] { + let mix = |[x, y, z]: [u32; 3]| { + let x = x.wrapping_add(y.wrapping_mul(z)); + let y = y.wrapping_add(z.wrapping_mul(x)); + let z = z.wrapping_add(x.wrapping_mul(y)); + [x, y, z] + }; + + let v = mix(input.map(|value| value.wrapping_mul(1_664_525).wrapping_add(1_013_904_223))); + mix(v.map(|value| value ^ (value >> 16))) +} + +/// How many iterations `c` takes to escape the Mandelbrot set, or the iteration limit if it doesn't. +fn mandelbrot_escape_iterations(c: DVec2) -> u32 { + let mut z = DVec2::ZERO; + for iteration in 0..MANDELBROT_ITERATIONS { + z = DVec2::new(z.x * z.x - z.y * z.y, 2. * z.x * z.y) + c; + if z.length_squared() > 4. { + return iteration; + } + } + MANDELBROT_ITERATIONS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_of_overlapping_areas_agree_where_they_overlap() { + let noise = GeneratorPattern::Noise(NoiseSettings { + seed: 7, + scale: 10., + noise_type: NoiseType::OpenSimplex2, + domain_warp_type: DomainWarpType::None, + domain_warp_amplitude: 0., + fractal_type: FractalType::FBm, + fractal_octaves: 3, + fractal_lacunarity: 2., + fractal_gain: 0.5, + fractal_weighted_strength: 0., + fractal_ping_pong_strength: 2., + cellular_distance_function: CellularDistanceFunction::Euclidean, + cellular_return_type: CellularReturnType::Nearest, + cellular_jitter: 1., + }); + + // One pixel per unit across 0 to 32, and again across 16 to 48, as two neighboring tiles of a render would sample it + let whole = noise.render([DVec2::ZERO, DVec2::splat(32.)], 32, 32); + let shifted = noise.render([DVec2::splat(16.), DVec2::splat(48.)], 32, 32); + for y in 0..16 { + for x in 0..16 { + assert_eq!(whole.data[(y + 16) * 32 + x + 16], shifted.data[y * 32 + x]); + } + } + } + + #[test] + fn white_noise_is_the_same_wherever_and_however_finely_it_is_sampled() { + let white_noise = |seed| { + GeneratorPattern::Noise(NoiseSettings { + seed, + scale: 10., + noise_type: NoiseType::WhiteNoise, + domain_warp_type: DomainWarpType::None, + domain_warp_amplitude: 0., + fractal_type: FractalType::None, + fractal_octaves: 1, + fractal_lacunarity: 2., + fractal_gain: 0.5, + fractal_weighted_strength: 0., + fractal_ping_pong_strength: 2., + cellular_distance_function: CellularDistanceFunction::Euclidean, + cellular_return_type: CellularReturnType::Nearest, + cellular_jitter: 1., + }) + }; + + // Each unit cell keeps its value when sampled four times as finely + let coarse = white_noise(3).render([DVec2::ZERO, DVec2::splat(4.)], 4, 4); + let fine = white_noise(3).render([DVec2::ZERO, DVec2::splat(4.)], 16, 16); + for y in 0..16 { + for x in 0..16 { + assert_eq!(fine.data[y * 16 + x], coarse.data[(y / 4) * 4 + x / 4]); + } + } + + // The values spread across the whole range, and another seed gives others + let luminances: Vec = coarse.data.iter().map(|color| color.r()).collect(); + assert!(luminances.iter().any(|&value| value < 0.5) && luminances.iter().any(|&value| value > 0.5)); + assert_ne!(white_noise(4).render([DVec2::ZERO, DVec2::splat(4.)], 4, 4).data, coarse.data); + } +} diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index d0e2f96929e..9a1da87d040 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,4 +1,5 @@ use crate::appearance::{Appearance, Cover, Coverage}; +use crate::generator::Generator; use brush_types::Stroke; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; @@ -27,6 +28,7 @@ pub enum Graphic { RasterGPU(Item>), Color(Item), Gradient(Item), + Generator(Item), Text(Item), NoneList(List), GraphicList(List), @@ -35,6 +37,7 @@ pub enum Graphic { RasterGPUList(List>), ColorList(List), GradientList(List), + GeneratorList(List), TextList(List), StrokeList(List), } @@ -138,6 +141,23 @@ impl From> for Graphic { } } +// Generator +impl From for Graphic { + fn from(generator: Generator) -> Self { + Graphic::Generator(Item::new_from_element(generator)) + } +} +impl From> for Graphic { + fn from(generator: Item) -> Self { + Graphic::Generator(generator) + } +} +impl From> for Graphic { + fn from(generator: List) -> Self { + Graphic::GeneratorList(generator) + } +} + // Stroke impl From for Graphic { fn from(stroke: Stroke) -> Self { @@ -313,12 +333,14 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterCPU(item) => bake_item_transform(item, transform), Graphic::RasterGPU(item) => bake_item_transform(item, transform), Graphic::Gradient(item) => bake_item_transform(item, transform), + Graphic::Generator(item) => bake_item_transform(item, transform), Graphic::Text(item) => bake_item_transform(item, transform), Graphic::GraphicList(list) => bake_list_transform(list, transform), Graphic::VectorList(list) => bake_list_transform(list, transform), Graphic::RasterCPUList(list) => bake_list_transform(list, transform), Graphic::RasterGPUList(list) => bake_list_transform(list, transform), Graphic::GradientList(list) => bake_list_transform(list, transform), + Graphic::GeneratorList(list) => bake_list_transform(list, transform), Graphic::TextList(list) => bake_list_transform(list, transform), Graphic::StrokeList(list) => bake_list_transform(list, transform), // A color has no spatial extent, so there is no placement for a transform to move @@ -381,6 +403,16 @@ impl TryFromGraphic for Gradient { } } +impl TryFromGraphic for Generator { + fn try_from_graphic(graphic: Graphic) -> Option> { + match graphic { + Graphic::Generator(item) => Some(List::new_from_item(item)), + Graphic::GeneratorList(list) => Some(list), + _ => None, + } + } +} + impl TryFromGraphic for String { fn try_from_graphic(graphic: Graphic) -> Option> { match graphic { @@ -448,6 +480,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::GeneratorList(self)) + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path: NodeIdPath = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); @@ -561,6 +599,7 @@ impl Graphic { Graphic::RasterGPU(item) => item_clipped(item), Graphic::Color(item) => item_clipped(item), Graphic::Gradient(item) => item_clipped(item), + Graphic::Generator(item) => item_clipped(item), Graphic::Text(item) => item_clipped(item), Graphic::NoneList(list) => all_clipped(list), Graphic::VectorList(list) => all_clipped(list), @@ -569,6 +608,7 @@ impl Graphic { Graphic::RasterGPUList(list) => all_clipped(list), Graphic::ColorList(list) => all_clipped(list), Graphic::GradientList(list) => all_clipped(list), + Graphic::GeneratorList(list) => all_clipped(list), Graphic::TextList(list) => all_clipped(list), Graphic::StrokeList(list) => all_clipped(list), } @@ -607,6 +647,7 @@ impl Graphic { } Graphic::RasterCPU(_) | Graphic::RasterCPUList(_) => false, Graphic::RasterGPU(_) | Graphic::RasterGPUList(_) => false, + Graphic::Generator(_) | Graphic::GeneratorList(_) => false, Graphic::Color(item) => item_opacity_is_full(item) && item.element().is_opaque(), Graphic::ColorList(list) => !list.is_empty() && every_item_has_full_opacity(list) && list.iter_element_values().all(|color| color.is_opaque()), // A `Clear` spread cuts off to transparency past the ends, leaving the rest of the region unpainted @@ -653,9 +694,11 @@ impl Graphic { // Their content is never inspected, so zeroed opacity is the only invisibility these can report Graphic::RasterCPU(item) => item_opacity_is_zero(item), Graphic::RasterGPU(item) => item_opacity_is_zero(item), + Graphic::Generator(item) => item_opacity_is_zero(item), Graphic::Text(item) => item_opacity_is_zero(item), Graphic::RasterCPUList(list) => every_item_has_zero_opacity(list), Graphic::RasterGPUList(list) => every_item_has_zero_opacity(list), + Graphic::GeneratorList(list) => every_item_has_zero_opacity(list), Graphic::TextList(list) => every_item_has_zero_opacity(list), Graphic::StrokeList(list) => every_item_has_zero_opacity(list), } @@ -671,11 +714,12 @@ impl Graphic { match self { // A leaf always holds exactly one element, so only the none-typed content is truly empty Graphic::None(_) | Graphic::NoneList(_) => true, - Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::Text(_) => false, + Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::Generator(_) | Graphic::Text(_) => false, Graphic::GraphicList(list) => list.is_empty(), Graphic::VectorList(list) => list.is_empty(), Graphic::ColorList(list) => list.is_empty(), Graphic::GradientList(list) => list.is_empty(), + Graphic::GeneratorList(list) => list.is_empty(), Graphic::RasterCPUList(list) => list.is_empty(), Graphic::RasterGPUList(list) => list.is_empty(), Graphic::TextList(list) => list.is_empty(), @@ -832,6 +876,7 @@ impl BoundingBox for Graphic { Graphic::RasterGPU(item) => item.bounding_box(transform, include_stroke), Graphic::Color(item) => item.bounding_box(transform, include_stroke), Graphic::Gradient(item) => item.bounding_box(transform, include_stroke), + Graphic::Generator(item) => item.bounding_box(transform, include_stroke), Graphic::Text(item) => item.bounding_box(transform, include_stroke), Graphic::VectorList(list) => vector_list_bounding_box(list, transform, include_stroke), Graphic::RasterCPUList(list) => list.bounding_box(transform, include_stroke), @@ -839,6 +884,7 @@ impl BoundingBox for Graphic { Graphic::GraphicList(list) => list.bounding_box(transform, include_stroke), Graphic::ColorList(list) => list.bounding_box(transform, include_stroke), Graphic::GradientList(list) => list.bounding_box(transform, include_stroke), + Graphic::GeneratorList(list) => list.bounding_box(transform, include_stroke), Graphic::TextList(list) => list.bounding_box(transform, include_stroke), Graphic::StrokeList(list) => list.bounding_box(transform, include_stroke), } @@ -853,6 +899,7 @@ impl BoundingBox for Graphic { Graphic::RasterGPU(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::Generator(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::VectorList(vector) => vector_list_bounding_box(vector, transform, include_stroke), Graphic::RasterCPUList(raster) => raster.thumbnail_bounding_box(transform, include_stroke), @@ -860,6 +907,7 @@ impl BoundingBox for Graphic { Graphic::GraphicList(list) => list.thumbnail_bounding_box(transform, include_stroke), Graphic::ColorList(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::GradientList(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::GeneratorList(generator) => generator.thumbnail_bounding_box(transform, include_stroke), Graphic::TextList(list) => list.thumbnail_bounding_box(transform, include_stroke), Graphic::StrokeList(list) => list.thumbnail_bounding_box(transform, include_stroke), } @@ -876,6 +924,7 @@ impl RenderComplexity for Graphic { Self::RasterGPU(item) => item.render_complexity(), Self::Color(item) => item.render_complexity(), Self::Gradient(item) => item.render_complexity(), + Self::Generator(item) => item.render_complexity(), Self::Text(item) => item.render_complexity(), Self::GraphicList(list) => list.render_complexity(), Self::VectorList(list) => list.render_complexity(), @@ -883,6 +932,7 @@ impl RenderComplexity for Graphic { Self::RasterGPUList(list) => list.render_complexity(), Self::ColorList(list) => list.render_complexity(), Self::GradientList(list) => list.render_complexity(), + Self::GeneratorList(list) => list.render_complexity(), Self::TextList(list) => list.render_complexity(), Self::StrokeList(list) => list.render_complexity(), } diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 82a375bdbdb..2ac6ad50f4b 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -1,6 +1,15 @@ +pub mod adjustment; +pub mod adjustment_kernels; +pub mod adjustment_tables; pub mod appearance; pub mod artboard; +pub mod color_lookup_table; +pub mod effects; +pub mod generator; pub mod graphic; +pub(crate) mod parameters; +pub mod raster_effects; +pub mod value_effects; // Re-export all transitive dependencies so downstream crates only need to depend on graphic-types pub use core_types; @@ -8,9 +17,14 @@ pub use raster_types; pub use vector_types; // Re-export commonly used types at the crate root +pub use adjustment::Adjustment; pub use appearance::{Appearance, Cover, CoverPlacement, Coverage, FillAndStroke, stamp_coverage}; pub use artboard::Artboard; +pub use effects::{BevelDirection, BevelStyle, Effect, EffectKind, EffectOperation, Effects, GlowSource, fitted_paint, stamp_effect}; +pub use generator::Generator; pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector}; +pub use raster_effects::evaluate_raster_effects; +pub use value_effects::{evaluate_color_effects, evaluate_gradient_effects}; pub mod migrations { use crate::Vector; diff --git a/node-graph/libraries/graphic-types/src/parameters.rs b/node-graph/libraries/graphic-types/src/parameters.rs new file mode 100644 index 00000000000..8400c31f623 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/parameters.rs @@ -0,0 +1,44 @@ +//! Parameters riding an item as attributes, each read as its default when absent, which one listing per operation both writes and reads. + +use core_types::graphene_hash::CacheHash; +use core_types::list::Item; +use std::any::Any; +use std::fmt::Debug; + +/// What a value needs to ride an attribute. +pub(crate) trait ParameterValue: Clone + Send + Sync + Default + Debug + PartialEq + CacheHash + 'static {} +impl ParameterValue for T {} + +/// Handles each parameter of an operation, given the attribute key it rides and its default. +pub(crate) trait ParameterVisitor { + fn parameter(&mut self, key: &str, value: &mut T, default: T); +} + +/// Stamps each parameter that differs from its default onto an item, for rows whose attributes are part of their value. +pub(crate) struct WriteParameters<'a, T>(pub &'a mut Item); + +impl ParameterVisitor for WriteParameters<'_, T> { + fn parameter(&mut self, key: &str, value: &mut U, default: U) { + if *value != default { + self.0.set_attribute(key, value.clone()); + } + } +} + +/// Stamps every parameter onto an item, for an item on a wire, whose attributes mustn't come and go with their values. +pub(crate) struct StampParameters<'a, T>(pub &'a mut Item); + +impl ParameterVisitor for StampParameters<'_, T> { + fn parameter(&mut self, key: &str, value: &mut U, _default: U) { + self.0.set_attribute(key, value.clone()); + } +} + +/// Reads each parameter from the attribute that the lookup finds by key, or its default where there's none. +pub(crate) struct ReadParameters<'a>(pub &'a dyn Fn(&str) -> Option<&'a dyn Any>); + +impl ParameterVisitor for ReadParameters<'_> { + fn parameter(&mut self, key: &str, value: &mut T, default: T) { + *value = (self.0)(key).and_then(|value| value.downcast_ref::()).cloned().unwrap_or(default); + } +} diff --git a/node-graph/libraries/graphic-types/src/raster_effects.rs b/node-graph/libraries/graphic-types/src/raster_effects.rs new file mode 100644 index 00000000000..4eef7c41268 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/raster_effects.rs @@ -0,0 +1,484 @@ +//! Carries out a raster's effects on the CPU at the raster's own resolution, for the nodes that read its pixels. +//! Each step mirrors the renderer's GPU passes, working on premultiplied gamma-encoded channels as they do. + +use crate::Graphic; +use crate::adjustment::Adjustment; +use crate::adjustment_kernels::overlay_paint; +use crate::effects::{BevelDirection, BevelStyle, EffectOperation, Effects, GlowSource, bevel_light, bevel_smoothing, fitted_paint, outline_extents}; +use crate::generator::GeneratorPattern; +use core_types::FallibleVec2Operations; +use core_types::blending::BlendMode; +use core_types::color::{linear_to_srgb, srgb_to_linear}; +use core_types::list::{ATTR_EFFECTS, ATTR_GRADIENT_END_CURVATURE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_START_CURVATURE, Item}; +use core_types::{ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color}; +use glam::{DAffine2, DMat2, DVec2, DVec3}; +use raster_types::effect_kernels::{BlurKernel, NEGLIGIBLE_LENGTH, Plane}; +use raster_types::parallel_rows::fill_rows; +use raster_types::{CPU, Image, Raster}; +use vector_types::gradient::{Gradient, GradientForm, GradientSettings, LINEAR_DIALS, RADIAL_DIALS, wrapped_dial}; + +/// The most pixels an image may grow to with the margin its effects cast into, beyond which the margin is cut short. +const MAX_PIXELS_WITH_MARGIN: f64 = (1 << 24) as f64; + +/// The raster with its effects carried out into its pixels, grown to hold what they cast past its edges, and with the effects and the fill opacity they used removed. +/// A raster without the effects attribute is returned as it is. +pub fn evaluate_raster_effects(item: Item>) -> Item> { + let Some(operations) = item.attribute::(ATTR_EFFECTS).map(|effects| effects.operations().collect::>()) else { + return item; + }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let fill = item.attribute_cloned_or::(ATTR_OPACITY_FILL, 1.) as f32; + + let (raster, attributes) = item.into_parts(); + let (raster, transform) = match evaluate(&raster, transform, &operations, fill) { + Some((image, grown_transform)) => (Raster::new_cpu(image), grown_transform), + None => (raster, transform), + }; + + // Which attributes come out depends only on whether the effects attribute came in, not on what it holds + let mut item = Item::from_parts(raster, attributes); + item.remove_attribute::(ATTR_EFFECTS); + item.remove_attribute::(ATTR_OPACITY_FILL); + item.set_attribute(ATTR_TRANSFORM, transform); + item +} + +/// The raster with `adjustments` carried out on its colors, even those of transparent pixels, as for the adjustments leading its effects. +/// The result is kept with the raster's pixels, so adjusting them the same way again reuses it, ID and all. +pub fn adjusted_raster(raster: &Raster, adjustments: &[Adjustment]) -> Raster { + let mut hasher = std::hash::DefaultHasher::new(); + adjustments.iter().for_each(|adjustment| graphene_hash::CacheHash::cache_hash(adjustment, &mut hasher)); + let key = std::hash::Hasher::finish(&hasher); + + raster.derived(key, |raster| { + let mut image = raster.data().clone(); + adjustments.iter().for_each(|adjustment| adjustment.apply(&mut image.data)); + Raster::new_cpu(image) + }) +} + +/// The image of the effects carried out on `raster`, which `transform` places in the document, with the transform placing that larger image. +/// `None` when there's nothing to carry out, or the image is empty or its transform can't be inverted. +fn evaluate(raster: &Raster, transform: DAffine2, operations: &[EffectOperation], fill: f32) -> Option<(Image, DAffine2)> { + let (width, height) = (raster.width as usize, raster.height as usize); + if (operations.is_empty() && fill >= 1.) || width == 0 || height == 0 || transform.matrix2.determinant() == 0. { + return None; + } + let size = DVec2::new(width as f64, height as f64); + + // Unless a fill fades them first, leading adjustments change the image's own colors, keeping even transparent pixels' colors + let leading_adjustments = operations.iter().take_while(|_| fill == 1.).map_while(|operation| match operation { + EffectOperation::Adjustment(adjustment) => Some(adjustment), + _ => None, + }); + let mut colors = std::borrow::Cow::Borrowed(raster.data.as_slice()); + let mut remaining = operations; + for adjustment in leading_adjustments { + adjustment.apply(colors.to_mut()); + remaining = &remaining[1..]; + } + if remaining.is_empty() { + let data = colors.iter().map(|color| color.with_alpha(color.a() * fill)).collect(); + return Some(( + Image { + width: raster.width, + height: raster.height, + data, + }, + transform, + )); + } + let operations = remaining; + + // Document lengths become pixels along each of the image's axes, and offsets map through the inverse of its pixel placement + let pixels_to_document = transform.matrix2 * DMat2::from_diagonal(size.recip()); + let document_to_pixels = pixels_to_document.inverse(); + let pixels_per_length = DVec2::new(pixels_to_document.x_axis.length(), pixels_to_document.y_axis.length()).recip(); + + // A margin around the image to hold the blur and shadows cast past its edges, cut short for an image that would grow too large + let reach: f64 = operations.iter().map(EffectOperation::reach).sum(); + let mut margin = (reach * pixels_per_length).ceil(); + let grown_size = size + 2. * margin; + if grown_size.x * grown_size.y > MAX_PIXELS_WITH_MARGIN { + let shrunk_size = grown_size * (MAX_PIXELS_WITH_MARGIN / (grown_size.x * grown_size.y)).sqrt(); + margin = ((shrunk_size - size) / 2.).max(DVec2::ZERO).floor(); + } + let (margin_x, margin_y) = (margin.x as usize, margin.y as usize); + + // The content at full strength, which casts every silhouette, and the content faded by the fill, which shows in the result + let mut full = Plane::<[f32; 4]>::new(width + 2 * margin_x, height + 2 * margin_y); + for (y, row) in colors.chunks_exact(width).enumerate() { + let start = (y + margin_y) * full.width + margin_x; + full.data[start..start + width] + .iter_mut() + .zip(row) + .for_each(|(target, &color)| *target = premultiplied_gamma_channels(color)); + } + let mut filled = (fill < 1.).then(|| full.map(|pixel| pixel.map(|channel| channel * fill))); + // The shadows and glows stacked beneath the content + let mut beneath: Option> = None; + + for operation in operations { + match *operation { + EffectOperation::Blur { radius, box_blur, gamma } => { + let radius = radius * pixels_per_length; + if radius.max_element() < NEGLIGIBLE_LENGTH { + continue; + } + + // What lies beneath blurs together with the content + if let Some(beneath) = beneath.take() { + filled = filled.map(|filled| composite_beneath(&filled, &beneath, &full)); + full = composite_beneath(&full, &beneath, &full); + } + + let kernels = match box_blur { + true => (BlurKernel::Box { radius: radius.x }, BlurKernel::Box { radius: radius.y }), + false => (BlurKernel::Gaussian { sigma: radius.x }, BlurKernel::Gaussian { sigma: radius.y }), + }; + full = blur(full, kernels, !gamma); + filled = filled.map(|filled| blur(filled, kernels, !gamma)); + } + EffectOperation::DropShadow { color, offset, blur, spread } => { + let coverage = cast(&full, document_to_pixels * offset, blur * pixels_per_length, spread * pixels_per_length, false); + beneath = Some(accumulate(beneath, &coverage, color)); + } + EffectOperation::OuterGlow { color, blur, spread } => { + let coverage = cast(&full, DVec2::ZERO, blur * pixels_per_length, spread * pixels_per_length, false); + beneath = Some(accumulate(beneath, &coverage, color)); + } + EffectOperation::InnerShadow { color, offset, blur, spread } => { + let coverage = cast(&full, document_to_pixels * offset, blur * pixels_per_length, spread * pixels_per_length, true); + filled = filled.map(|filled| composite_within(&filled, &coverage, &full, color)); + full = composite_within(&full, &coverage, &full, color); + } + EffectOperation::InnerGlow { color, blur, choke, source } => { + let mut coverage = cast(&full, DVec2::ZERO, blur * pixels_per_length, choke * pixels_per_length, true); + if source == GlowSource::Center { + coverage = coverage.map(|coverage| 1. - coverage); + } + filled = filled.map(|filled| composite_within(&filled, &coverage, &full, color)); + full = composite_within(&full, &coverage, &full, color); + } + EffectOperation::Satin { color, offset, blur, invert } => { + let offset = document_to_pixels * offset; + let forward = cast(&full, offset, blur * pixels_per_length, DVec2::ZERO, false); + let backward = cast(&full, -offset, blur * pixels_per_length, DVec2::ZERO, false); + let coverage = forward.zip(&backward, |forward, backward| satin_coverage(forward, backward, invert)); + + filled = filled.map(|filled| composite_within(&filled, &coverage, &full, color)); + full = composite_within(&full, &coverage, &full, color); + } + EffectOperation::Outline { color, weight, align } => { + // Straight-line distances can't stretch along one axis, so an image scaled unevenly takes the mean of its two scales + let pixels_per_length = (pixels_per_length.x * pixels_per_length.y).sqrt(); + let (outside, inside) = outline_extents(weight * pixels_per_length, align); + let silhouette = full.map(|[_, _, _, alpha]| alpha); + + if outside >= NEGLIGIBLE_LENGTH { + beneath = Some(accumulate(beneath, &silhouette.edge_band(outside, false), color)); + } + if inside >= NEGLIGIBLE_LENGTH { + let coverage = silhouette.edge_band(inside, true); + filled = filled.map(|filled| composite_within(&filled, &coverage, &full, color)); + full = composite_within(&full, &coverage, &full, color); + } + } + EffectOperation::BevelAndEmboss { + style, + depth, + direction, + size, + soften, + angle, + altitude, + highlight_color, + shadow_color, + } => { + let pixels_per_length = (pixels_per_length.x * pixels_per_length.y).sqrt(); + let (size, soften) = (size * pixels_per_length, soften * pixels_per_length); + if size < NEGLIGIBLE_LENGTH { + continue; + } + + // The surface's height, rounded by a blur, lit from the light's direction into its highlights and shadows + let height = soften_by(bevel_height(&full.map(|[_, _, _, alpha]| alpha), size, style), bevel_smoothing(size)); + let slope = depth / 100. * size * if direction == BevelDirection::Down { -1. } else { 1. }; + let light = bevel_light(angle, altitude, document_to_pixels); + + // Their parts outside the content lie beneath it, and those inside are placed over it + for (shadow, color) in [(false, highlight_color), (true, shadow_color)] { + let coverage = soften_by(bevel_shading(&height, slope, light, shadow), soften); + beneath = Some(accumulate(beneath, &coverage, color)); + filled = filled.map(|filled| composite_within(&filled, &coverage, &full, color)); + full = composite_within(&full, &coverage, &full, color); + } + } + EffectOperation::PaintOverlay { ref paint, blend_mode, opacity } => { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|corner| transform.transform_point2(corner)); + let content_bounds = [corners.into_iter().reduce(DVec2::min).unwrap(), corners.into_iter().reduce(DVec2::max).unwrap()]; + let fitted = fitted_paint(paint, content_bounds); + let Some(sample_paint) = paint_sampler(fitted.as_ref().unwrap_or(paint)) else { continue }; + + // The paint at each pixel's center, in the space the image is placed in + let pixels_to_placement = transform * DAffine2::from_scale(size.recip()) * DAffine2::from_translation(-DVec2::new(margin_x as f64, margin_y as f64)); + let mut paint_colors = vec![Color::TRANSPARENT; full.data.len()]; + fill_rows(&mut paint_colors, full.width, |y, row| { + for (x, color) in row.iter_mut().enumerate() { + *color = sample_paint(pixels_to_placement.transform_point2(DVec2::new(x as f64 + 0.5, y as f64 + 0.5))); + } + }); + + let opacity = (opacity / 100.) as f32; + filled = filled.map(|filled| overlay(&filled, &paint_colors, blend_mode, opacity)); + full = overlay(&full, &paint_colors, blend_mode, opacity); + } + EffectOperation::Adjustment(ref adjustment) => { + full = adjust(&full, adjustment); + filled = filled.map(|filled| adjust(&filled, adjustment)); + } + } + } + + let result = match (filled, beneath) { + (filled, Some(beneath)) => composite_beneath(filled.as_ref().unwrap_or(&full), &beneath, &full), + (Some(filled), None) => filled, + (None, None) => full, + }; + let image = Image { + width: result.width as u32, + height: result.height as u32, + data: result.data.into_iter().map(straight_linear_color).collect(), + }; + + // The grown image spans the margin on each side of the original's unit square + let unit_margin = DVec2::new(margin_x as f64, margin_y as f64) / size; + let grown_transform = transform * DAffine2::from_translation(-unit_margin) * DAffine2::from_scale(DVec2::ONE + 2. * unit_margin); + + Some((image, grown_transform)) +} + +/// A color as the premultiplied, gamma-encoded channels the effects work with. +fn premultiplied_gamma_channels(color: Color) -> [f32; 4] { + let [red, green, blue, alpha] = color.to_gamma_srgb_channels(); + [red * alpha, green * alpha, blue * alpha, alpha] +} + +/// Premultiplied, gamma-encoded channels back as a color. +fn straight_linear_color([red, green, blue, alpha]: [f32; 4]) -> Color { + if alpha <= 0. { + return Color::TRANSPARENT; + } + Color::from_gamma_srgb_channels(red / alpha, green / alpha, blue / alpha, alpha.min(1.)) +} + +/// Adjusts premultiplied gamma-encoded pixels by way of their straight colors, leaving fully transparent pixels empty since they keep no color to adjust. +fn adjust(plane: &Plane<[f32; 4]>, adjustment: &Adjustment) -> Plane<[f32; 4]> { + let mut colors: Vec = plane.data.iter().copied().map(straight_linear_color).collect(); + adjustment.apply(&mut colors); + + let data = plane + .data + .iter() + .zip(colors) + .map(|(pixel, color)| if pixel[3] <= 0. { [0.; 4] } else { premultiplied_gamma_channels(color) }) + .collect(); + Plane { + width: plane.width, + height: plane.height, + data, + } +} + +/// Premultiplied channels with `transfer` applied to their straight color. +fn transferred(transfer: fn(f32) -> f32) -> impl Fn([f32; 4]) -> [f32; 4] + Sync { + move |[red, green, blue, alpha]| { + if alpha <= 0. { + return [0.; 4]; + } + let channel = |value: f32| transfer((value / alpha).max(0.)) * alpha; + [channel(red), channel(green), channel(blue), alpha] + } +} + +/// Blurs premultiplied gamma-encoded pixels by a kernel along each axis, in linear light if `linear`. +fn blur(plane: Plane<[f32; 4]>, (horizontal, vertical): (BlurKernel, BlurKernel), linear: bool) -> Plane<[f32; 4]> { + match linear { + true => plane.map(transferred(srgb_to_linear)).blurred(horizontal, vertical).map(transferred(linear_to_srgb)), + false => plane.blurred(horizontal, vertical), + } +} + +/// The coverage of the content's silhouette (or of the area outside it, if `inverse`), cast by `offset`, grown by `spread` and softened by a Gaussian of deviation `blur`, all in pixels along each axis. +fn cast(content: &Plane<[f32; 4]>, offset: DVec2, blur: DVec2, spread: DVec2, inverse: bool) -> Plane { + let mut coverage = content.map(|[_, _, _, alpha]| alpha).shifted(offset.x, offset.y); + if inverse { + coverage = coverage.map(|alpha| 1. - alpha); + } + + if spread.max_element() >= NEGLIGIBLE_LENGTH { + coverage = coverage.dilated(spread.x, spread.y); + } + if blur.max_element() >= NEGLIGIBLE_LENGTH { + coverage = coverage.blurred(BlurKernel::Gaussian { sigma: blur.x }, BlurKernel::Gaussian { sigma: blur.y }); + } + coverage +} + +/// Stacks a silhouette's coverage, tinted `color`, over what lies `beneath` the content so far. +fn accumulate(beneath: Option>, coverage: &Plane, color: Color) -> Plane<[f32; 4]> { + let color = premultiplied_gamma_channels(color); + let tinted = coverage.map(|coverage| color.map(|channel| channel * coverage)); + + match beneath { + Some(beneath) => tinted.zip(&beneath, |tinted, beneath| std::array::from_fn(|channel| tinted[channel] + beneath[channel] * (1. - tinted[3]))), + None => tinted, + } +} + +/// The image with a silhouette's coverage, tinted `color`, placed over it within the full-strength content. +fn composite_within(image: &Plane<[f32; 4]>, coverage: &Plane, full_content: &Plane<[f32; 4]>, color: Color) -> Plane<[f32; 4]> { + let color = premultiplied_gamma_channels(color); + let coverages = coverage.zip(full_content, |coverage, content| [coverage, content[3], 0., 0.]); + + image.zip(&coverages, |image, [coverage, content_alpha, _, _]| { + let tinted = color.map(|channel| channel * coverage); + std::array::from_fn(|channel| tinted[channel] * content_alpha + image[channel] * (1. - tinted[3])) + }) +} + +/// The image with the shadows and glows `beneath` placed under it, knocked out by the full-strength content. +fn composite_beneath(image: &Plane<[f32; 4]>, beneath: &Plane<[f32; 4]>, full_content: &Plane<[f32; 4]>) -> Plane<[f32; 4]> { + let knocked_out = beneath.zip(full_content, |beneath, content| beneath.map(|channel| channel * (1. - content[3]))); + image.zip(&knocked_out, |image, beneath| std::array::from_fn(|channel| image[channel] + beneath[channel])) +} + +/// Satin's shading where the two casts of the silhouette cover `forward` and `backward`: how much one covers without the other, or how much they agree if `invert`. +fn satin_coverage(forward: f32, backward: f32, invert: bool) -> f32 { + let difference = (forward - backward).abs(); + if invert { 1. - difference } else { difference } +} + +/// The plane blurred by a Gaussian of deviation `sigma` pixels, or left as it is for one too small to see. +fn soften_by(plane: Plane, sigma: f64) -> Plane { + match sigma >= NEGLIGIBLE_LENGTH { + true => plane.blurred(BlurKernel::Gaussian { sigma }, BlurKernel::Gaussian { sigma }), + false => plane, + } +} + +/// A bevel's height from 0 to 1, from each pixel's signed distance to the silhouette's edge (positive inside), shaped by the `style` over `size` pixels. +fn bevel_height(silhouette: &Plane, size: f64, style: BevelStyle) -> Plane { + let to_inside = silhouette.distances_across_edge(size, false); + let to_outside = silhouette.distances_across_edge(size, true); + + // The pixels inside the silhouette are those at no distance from it, and the edge lies about half a pixel short of the nearest pixel across it + to_inside.zip(&to_outside, |to_inside, to_outside| { + let signed_distance = if to_inside == 0. { to_outside - 0.5 } else { 0.5 - to_inside }; + bevel_profile(signed_distance, size as f32, style) + }) +} + +/// The height from 0 to 1 at a signed distance from the edge (positive inside), rising smoothly across the `style`'s slope `size` pixels wide. +fn bevel_profile(signed_distance: f32, size: f32, style: BevelStyle) -> f32 { + let progress = match style { + BevelStyle::InnerBevel => signed_distance / size, + BevelStyle::OuterBevel => (signed_distance + size) / size, + BevelStyle::Emboss => (signed_distance + size / 2.) / size, + BevelStyle::PillowEmboss => signed_distance.abs() / (size / 2.), + }; + let progress = progress.clamp(0., 1.); + progress * progress * (3. - 2. * progress) +} + +/// How far the `light` brightens the bevel's surface past a flat surface's brightness, or if `shadow`, how far it falls short, from 0 to 1. +/// `slope` scales the height's gradient into the surface's steepness, and is negative for a bevel pressed into the surface. O(width × height). +fn bevel_shading(height: &Plane, slope: f64, light: DVec3, shadow: bool) -> Plane { + let (width, rows) = (height.width as isize, height.height as isize); + let at = |x: isize, y: isize| height.data[(y.clamp(0, rows - 1) * width + x.clamp(0, width - 1)) as usize]; + let (slope, light) = (slope as f32, light.as_vec3()); + + let mut shading = Plane::new(height.width, height.height); + fill_rows(&mut shading.data, height.width, |y, row| { + for (x, value) in row.iter_mut().enumerate() { + let (x, y) = (x as isize, y as isize); + let gradient = glam::Vec2::new(at(x + 1, y) - at(x - 1, y), at(x, y + 1) - at(x, y - 1)) / 2.; + let shade = (-slope * gradient).extend(1.).normalize().dot(light); + + *value = match shadow { + true => (light.z - shade) / light.z.max(1e-4), + false => (shade - light.z) / (1. - light.z).max(1e-4), + } + .clamp(0., 1.); + } + }); + shading +} + +/// Mixes the paint's color at each premultiplied gamma-encoded pixel over it by the blend mode, keeping the pixel's alpha. +fn overlay(plane: &Plane<[f32; 4]>, paint: &[Color], blend_mode: BlendMode, opacity: f32) -> Plane<[f32; 4]> { + let data = plane + .data + .iter() + .zip(paint) + .map(|(&pixel, &paint)| match pixel[3] > 0. { + true => premultiplied_gamma_channels(overlay_paint(straight_linear_color(pixel), paint, blend_mode, opacity)), + false => [0.; 4], + }) + .collect(); + Plane { + width: plane.width, + height: plane.height, + data, + } +} + +/// Samples the paint's color at points in the space its placement maps into, for the paints the CPU can draw: colors, images, generators, and linear or radial gradients. +fn paint_sampler(paint: &Graphic) -> Option Color + Sync + '_>> { + let inverse = |placement: DAffine2| (placement.matrix2.determinant() != 0.).then(|| placement.inverse()); + + match paint { + Graphic::Color(color) => { + let color = *color.element(); + Some(Box::new(move |_| color)) + } + Graphic::RasterCPU(item) => { + let image = item.element(); + let to_pixels = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64)) * inverse(item.attribute_cloned_or_default(ATTR_TRANSFORM))?; + Some(Box::new(move |point| image.sample_bilinear(to_pixels.transform_point2(point)))) + } + Graphic::Generator(item) => { + let to_pattern = inverse(item.attribute_cloned_or_default(ATTR_TRANSFORM))?; + let sample = GeneratorPattern::of_item(item).sampler(); + Some(Box::new(move |point| sample(to_pattern.transform_point2(point)))) + } + Graphic::Gradient(item) => { + let position = gradient_position(item)?; + let evaluator = item.element().evaluator(GradientSettings::from_item_attributes(item)); + Some(Box::new(move |point| evaluator.evaluate(position(point)))) + } + _ => None, + } +} + +/// How far along a linear or radial gradient each point lies, or `None` for the other shapes its curvature dials make, which only the renderers draw. +fn gradient_position(item: &Item) -> Option f64 + Sync>> { + let placement: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let preset = item.attribute_cloned_or_default::(ATTR_GRADIENT_FORM).dials(); + let dial = |key: &str, preset: f64| wrapped_dial(item.attribute_cloned_or(key, preset), preset); + + match [dial(ATTR_GRADIENT_START_CURVATURE, preset[0]), dial(ATTR_GRADIENT_END_CURVATURE, preset[1])] { + // Measured along the line across the bands, which stay parallel to the placement's sheared y axis as the renderers draw them + LINEAR_DIALS => { + let axis = placement.matrix2.x_axis; + let line = axis.try_project_onto(placement.matrix2.y_axis.perp()).unwrap_or(axis); + let origin = placement.translation; + (line.length_squared() > 0.).then(|| Box::new(move |point: DVec2| (point - origin).dot(line) / line.length_squared()) as Box f64 + Sync>) + } + RADIAL_DIALS => { + let to_unit = (placement.matrix2.determinant() != 0.).then(|| placement.inverse())?; + Some(Box::new(move |point| to_unit.transform_point2(point).length())) + } + _ => None, + } +} diff --git a/node-graph/libraries/graphic-types/src/value_effects.rs b/node-graph/libraries/graphic-types/src/value_effects.rs new file mode 100644 index 00000000000..c8805471713 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/value_effects.rs @@ -0,0 +1,115 @@ +//! Carries out the adjustments among a color's or gradient's effects, for the nodes and paints that read the value rather than render it. +//! A value read on its own has no pixels for the image operations among the effects (like blurs and shadows) to act on, so those are skipped. + +use crate::adjustment::Adjustment; +use crate::effects::{EffectOperation, Effects}; +use core_types::Color; +use core_types::graphene_hash::CacheHash; +use core_types::list::{ATTR_EFFECTS, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; +use std::hash::{DefaultHasher, Hasher}; +use std::sync::{Mutex, PoisonError}; +use vector_types::{Gradient, GradientInterpolation, GradientSettings, GradientSpace}; + +/// How many adjusted gradients stay cached after the renders that used them, since paints read the same ones every render. +const MAX_CACHED_GRADIENTS: usize = 32; + +/// The gradients adjusted most recently, least recent first, keyed by the hash of what they were adjusted from. +static ADJUSTED_GRADIENTS: Mutex> = Mutex::new(Vec::new()); + +/// The adjustments among the effects, in order. +pub fn adjustments(effects: &Effects) -> Vec { + effects + .operations() + .filter_map(|operation| match operation { + EffectOperation::Adjustment(adjustment) => Some(adjustment), + _ => None, + }) + .collect() +} + +/// The color with `adjustments` carried out on it in order. +pub fn adjusted_color(color: Color, adjustments: &[Adjustment]) -> Color { + adjustments.iter().fold(color, |color, adjustment| adjustment.apply_to_color(color)) +} + +/// The gradient with `adjustments` carried out along its ramp, with the settings to read the result with, or `None` if there are no adjustments. +/// A stepped ramp holds each stop's color, so adjusting the stops is exact. Elsewhere an adjustment can bend the ramp between stops, +/// so the adjusted ramp is resampled into stops that interpolate linearly in gamma. +pub fn adjusted_gradient(gradient: &Gradient, settings: GradientSettings, adjustments: &[Adjustment]) -> Option<(Gradient, GradientSettings)> { + if adjustments.is_empty() { + return None; + } + let adjust = |color: Color| adjusted_color(color, adjustments); + + if settings.interpolation == GradientInterpolation::Stepped { + return Some((gradient.map_colors(|color| adjust(*color)), settings)); + } + + // Resampling takes thousands of evaluations for a steep adjustment, so recent results are reused + let mut hasher = DefaultHasher::new(); + gradient.cache_hash(&mut hasher); + settings.cache_hash(&mut hasher); + adjustments.iter().for_each(|adjustment| adjustment.cache_hash(&mut hasher)); + let key = hasher.finish(); + + // A lock poisoned by a panic elsewhere still guards a usable cache + let mut cached = ADJUSTED_GRADIENTS.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(index) = cached.iter().position(|(cached_key, ..)| *cached_key == key) { + let entry = cached.remove(index); + let result = (entry.1.clone(), entry.2); + cached.push(entry); + return Some(result); + } + + let resampled = gradient.map_ramp(settings, adjust); + let resampled_settings = GradientSettings { + space: GradientSpace::RgbGamma, + interpolation: GradientInterpolation::Linear, + hue_direction: Default::default(), + ..settings + }; + + cached.push((key, resampled.clone(), resampled_settings)); + let excess = cached.len().saturating_sub(MAX_CACHED_GRADIENTS); + cached.drain(..excess); + + Some((resampled, resampled_settings)) +} + +/// The color with the adjustments among its effects carried out and the effects consumed, for nodes that read the color. +/// A color without the effects attribute is returned as it is. +pub fn evaluate_color_effects(item: Item) -> Item { + let Some(adjustments) = item.attribute::(ATTR_EFFECTS).map(adjustments) else { + return item; + }; + + let mut item = item; + *item.element_mut() = adjusted_color(*item.element(), &adjustments); + item.remove_attribute::(ATTR_EFFECTS); + item +} + +/// The gradient with the adjustments among its effects carried out along its ramp and the effects consumed, for nodes that read its colors. +/// A gradient without the effects attribute is returned as it is. +pub fn evaluate_gradient_effects(item: Item) -> Item { + let Some(adjustments) = item.attribute::(ATTR_EFFECTS).map(adjustments) else { + return item; + }; + let settings = GradientSettings::from_item_attributes(&item); + + let mut item = item; + let settings = match adjusted_gradient(item.element(), settings, &adjustments) { + Some((gradient, adjusted_settings)) => { + *item.element_mut() = gradient; + adjusted_settings + } + None => settings, + }; + + // Which attributes come out depends only on whether the effects attribute came in, not on what it holds + item.remove_attribute::(ATTR_EFFECTS); + item.set_attribute(ATTR_GRADIENT_SPACE, settings.space); + item.set_attribute(ATTR_GRADIENT_INTERPOLATION, settings.interpolation); + item.set_attribute(ATTR_GRADIENT_HUE_DIRECTION, settings.hue_direction); + item +} diff --git a/node-graph/libraries/raster-types/src/effect_kernels.rs b/node-graph/libraries/raster-types/src/effect_kernels.rs new file mode 100644 index 00000000000..ee7e13af54b --- /dev/null +++ b/node-graph/libraries/raster-types/src/effect_kernels.rs @@ -0,0 +1,423 @@ +//! The image operations that effects are built from, on the CPU: blurring, growing shapes, and shifting. +//! They mirror the renderer's GPU passes, so a raster's effects read the same in a node that needs its pixels as on the canvas. + +use crate::parallel_rows::fill_rows; + +/// Blurs and spreads shorter than this, in pixels, leave no visible trace. +pub const NEGLIGIBLE_LENGTH: f64 = 0.01; + +/// The largest Gaussian standard deviation, in pixels, convolved on the image itself rather than a halved copy. +pub const MAX_DIRECT_GAUSSIAN_SIGMA: f64 = 8.; + +/// The largest box half width, in pixels, convolved on the image itself rather than a halved copy. +pub const MAX_DIRECT_BOX_RADIUS: f64 = 24.; + +/// The farthest texel from the center a kernel weighs, which fits both direct limits. +pub const MAX_KERNEL_REACH: usize = 24; + +/// The most times an image is halved for a blur, by which point it is one pixel across. +const MAX_LEVELS: u32 = 16; + +/// A blur's kernel along one axis, sized in pixels. +#[derive(Clone, Copy, Debug)] +pub enum BlurKernel { + Gaussian { sigma: f64 }, + Box { radius: f64 }, +} + +impl BlurKernel { + /// How many times the image is halved before the kernel runs, so the kernel stays within [`MAX_KERNEL_REACH`]. + pub fn levels(self) -> u32 { + let (length, direct_limit) = match self { + Self::Gaussian { sigma } => (sigma, MAX_DIRECT_GAUSSIAN_SIGMA), + Self::Box { radius } => (radius, MAX_DIRECT_BOX_RADIUS), + }; + + (length / direct_limit).log2().ceil().clamp(0., MAX_LEVELS as f64) as u32 + } + + /// The weights of the texels from the center outward for an image halved `levels` times, normalized so the symmetric kernel sums to 1. + pub fn weights(self, levels: u32) -> Vec { + let level_scale = (1 << levels) as f64; + + let weights: Vec = match self { + Self::Gaussian { sigma } => { + // Halving the image and doubling it back blur it too, so the kernel makes up only the rest of the variance + let pyramid_variance = (4_f64.powi(levels as i32) - 1.) / 3.; + let sigma = (sigma * sigma - pyramid_variance).max(0.).sqrt() / level_scale; + if sigma < NEGLIGIBLE_LENGTH { + return vec![1.]; + } + + let reach = ((3. * sigma).ceil() as usize).min(MAX_KERNEL_REACH); + (0..=reach).map(|i| (-((i * i) as f64) / (2. * sigma * sigma)).exp()).collect() + } + // The texels at the ends weigh only the fraction of them the box covers, so the blur grows smoothly with its radius + Self::Box { radius } => { + let radius = radius / level_scale; + let reach = (radius.ceil() as usize).min(MAX_KERNEL_REACH); + (0..=reach).map(|i| (radius + 1. - i as f64).clamp(0., 1.)).collect() + } + }; + + let total = weights[0] + 2. * weights[1..].iter().sum::(); + weights.into_iter().map(|weight| weight / total).collect() + } +} + +/// A pixel value the operations can weigh, sum, and compare: one coverage channel, or premultiplied color channels. +pub trait Sample: Copy + Default + Send + Sync { + fn scaled(self, factor: f32) -> Self; + fn plus(self, other: Self) -> Self; + fn max(self, other: Self) -> Self; +} + +impl Sample for f32 { + fn scaled(self, factor: f32) -> Self { + self * factor + } + fn plus(self, other: Self) -> Self { + self + other + } + fn max(self, other: Self) -> Self { + f32::max(self, other) + } +} + +impl Sample for [f32; 4] { + fn scaled(self, factor: f32) -> Self { + self.map(|channel| channel * factor) + } + fn plus(self, other: Self) -> Self { + std::array::from_fn(|channel| self[channel] + other[channel]) + } + fn max(self, other: Self) -> Self { + std::array::from_fn(|channel| self[channel].max(other[channel])) + } +} + +/// An image of [`Sample`]s in rows, which the operations read and write. +#[derive(Clone, Debug, PartialEq)] +pub struct Plane { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +impl Plane { + pub fn new(width: usize, height: usize) -> Self { + Self { + width, + height, + data: vec![T::default(); width * height], + } + } + + /// A plane of the same size whose pixels are `map` of this one's. + pub fn map(&self, map: impl Fn(T) -> U + Sync) -> Plane { + let mut mapped = Plane::new(self.width, self.height); + fill_rows(&mut mapped.data, self.width, |y, row| { + let source = &self.data[y * self.width..(y + 1) * self.width]; + row.iter_mut().zip(source).for_each(|(target, &value)| *target = map(value)); + }); + mapped + } + + /// Combines this plane with `other`, of the same size, pixel by pixel. + pub fn zip(&self, other: &Plane, combine: impl Fn(T, U) -> V + Sync) -> Plane { + let mut combined = Plane::new(self.width, self.height); + fill_rows(&mut combined.data, self.width, |y, row| { + let range = y * self.width..(y + 1) * self.width; + let pairs = self.data[range.clone()].iter().zip(&other.data[range]); + row.iter_mut().zip(pairs).for_each(|(target, (&a, &b))| *target = combine(a, b)); + }); + combined + } + + /// The plane with its rows and columns swapped, so a horizontal operation can work vertically. O(width × height). + fn transposed(&self) -> Self { + let mut transposed = Self::new(self.height, self.width); + fill_rows(&mut transposed.data, self.height, |x, row| { + row.iter_mut().enumerate().for_each(|(y, target)| *target = self.data[y * self.width + x]); + }); + transposed + } + + /// Runs `operation` on each row, which it reads in full and writes as a new row of `width` pixels. + fn map_rows(&self, width: usize, operation: impl Fn(&[T], &mut [T]) + Sync) -> Self { + let mut result = Self::new(width, self.height); + fill_rows(&mut result.data, width, |y, row| operation(&self.data[y * self.width..(y + 1) * self.width], row)); + result + } + + /// Runs `along_rows` on the plane's rows, then on its columns by way of one transpose there and back. + fn along_both_axes

(self, horizontal: P, vertical: P, along_rows: impl Fn(Self, P) -> Self) -> Self { + let plane = along_rows(self, horizontal); + along_rows(plane.transposed(), vertical).transposed() + } + + /// Blurs the plane by a kernel along each axis. Kernels too wide to convolve directly run on a copy halved along their axis enough times + /// for them to fit, then doubled back. O(width × height) per pass, with a pass for each halving and doubling. + pub fn blurred(self, horizontal: BlurKernel, vertical: BlurKernel) -> Self { + self.along_both_axes(horizontal, vertical, |mut plane, kernel| { + let levels = kernel.levels(); + let length = plane.width; + let level_length = |level: u32| length.div_ceil(1 << level); + + for level in 1..=levels { + plane = plane.map_rows(level_length(level), halve_row); + } + + let weights = kernel.weights(levels); + plane = plane.map_rows(level_length(levels), |source, target| convolve_row(source, target, &weights)); + + for level in (0..levels).rev() { + plane = plane.map_rows(level_length(level), double_row); + } + plane + }) + } + + /// Grows the shapes by a distance in pixels along each axis, in a square like SVG's `feMorphology`. + pub fn dilated(self, horizontal: f64, vertical: f64) -> Self { + self.along_both_axes(horizontal, vertical, |mut plane, distance| { + let length = plane.width; + + // Steps that double, then the remainder, so each step grows the shapes by up to as much as all the steps before it + let (mut covered, mut step) = (0., 1.); + while covered + step <= distance { + plane = plane.map_rows(length, |source, target| dilate_row(source, target, step)); + covered += step; + step *= 2.; + } + if distance - covered >= NEGLIGIBLE_LENGTH { + plane = plane.map_rows(length, |source, target| dilate_row(source, target, distance - covered)); + } + plane + }) + } + + /// Moves the image by a fractional offset in pixels, interpolating between pixels and leaving transparency where it uncovers the edges. + pub fn shifted(self, offset_x: f64, offset_y: f64) -> Self { + self.along_both_axes(offset_x, offset_y, |plane, offset| plane.map_rows(plane.width, |source, target| shift_row(source, target, offset))) + } +} + +impl Plane { + /// The coverage of the band reaching `width` pixels from the silhouette's edge, outward or (if `inward`) inward, measured in straight lines so it rounds corners. + pub fn edge_band(&self, width: f64, inward: bool) -> Self { + // The edge lies about half a pixel short of the nearest pixel across it, and the band fades out over one pixel + let distances = self.distances_across_edge(width, inward); + let width = width as f32; + distances.map(|distance| (width + 1. - distance).clamp(0., 1.)) + } + + /// Each pixel's distance to the nearest pixel across the silhouette's edge: outside it if `inward`, or inside it otherwise, with those pixels at zero. + /// Jump flooding finds them in a pass for each halving of a step that starts past `reach`, so farther pixels may find none and lie at infinity. O(width × height × log(reach)). + pub fn distances_across_edge(&self, reach: f64, inward: bool) -> Self { + // The side of the edge that distances are measured from, whose pixels are their own nearest + let is_seed = |coverage: f32| (coverage >= 0.5) != inward; + let mut nearest: Vec> = self.data.iter().map(|&coverage| is_seed(coverage).then_some([0, 0])).collect(); + + let (columns, rows) = (self.width as i32, self.height as i32); + let length_squared = |[x, y]: [i32; 2]| x * x + y * y; + let mut step = (reach.max(0.).ceil() as u32 + 1).next_power_of_two(); + while step >= 1 { + let previous = nearest.clone(); + let step_length = step as i32; + + // Each pixel keeps the nearest of the seeds that it and its eight neighbors a step away have found so far + fill_rows(&mut nearest, self.width, |y, row| { + for (x, best) in row.iter_mut().enumerate() { + for neighbor_y in -1..=1 { + for neighbor_x in -1..=1 { + let (x, y) = (x as i32 + neighbor_x * step_length, y as i32 + neighbor_y * step_length); + if x < 0 || y < 0 || x >= columns || y >= rows { + continue; + } + let Some([offset_x, offset_y]) = previous[(y * columns + x) as usize] else { continue }; + + let candidate = [offset_x + neighbor_x * step_length, offset_y + neighbor_y * step_length]; + if best.is_none_or(|best| length_squared(candidate) < length_squared(best)) { + *best = Some(candidate); + } + } + } + } + }); + step /= 2; + } + + let data = nearest + .into_iter() + .map(|offset| offset.map_or(f32::INFINITY, |offset| (length_squared(offset) as f32).sqrt())) + .collect(); + Self { + width: self.width, + height: self.height, + data, + } + } +} + +/// The row's value at a position measured in pixels from the first pixel's center, interpolating linearly and holding the end pixels beyond the ends. +fn sample_row(row: &[T], position: f64) -> T { + let last = row.len() as f64 - 1.; + let position = position.clamp(0., last.max(0.)); + let index = position.floor() as usize; + let fraction = (position - index as f64) as f32; + + match row.get(index + 1) { + Some(&next) if fraction > 0. => row[index].scaled(1. - fraction).plus(next.scaled(fraction)), + _ => row[index], + } +} + +/// Halves the row by averaging each pair of pixels, the last pixel of an odd row pairing with itself. +fn halve_row(source: &[T], target: &mut [T]) { + for (index, value) in target.iter_mut().enumerate() { + let first = source[2 * index]; + let second = source.get(2 * index + 1).copied().unwrap_or(first); + *value = first.plus(second).scaled(0.5); + } +} + +/// Doubles a halved row back to its length, interpolating at each pixel's center within the pair it was averaged into. +fn double_row(source: &[T], target: &mut [T]) { + for (index, value) in target.iter_mut().enumerate() { + *value = sample_row(source, index as f64 / 2. - 0.25); + } +} + +/// Weighs each pixel's neighbors by the symmetric kernel `weights`, from the center outward, holding the end pixels beyond the ends. +fn convolve_row(source: &[T], target: &mut [T], weights: &[f64]) { + let last = source.len() as isize - 1; + let at = |index: isize| source[index.clamp(0, last) as usize]; + + for (index, value) in target.iter_mut().enumerate() { + let index = index as isize; + let mut sum = at(index).scaled(weights[0] as f32); + for (distance, &weight) in weights.iter().enumerate().skip(1) { + let pair = at(index - distance as isize).plus(at(index + distance as isize)); + sum = sum.plus(pair.scaled(weight as f32)); + } + *value = sum; + } +} + +/// Takes each pixel's maximum with the row's values `step` pixels to either side. +fn dilate_row(source: &[T], target: &mut [T], step: f64) { + for (index, value) in target.iter_mut().enumerate() { + let position = index as f64; + *value = source[index].max(sample_row(source, position - step)).max(sample_row(source, position + step)); + } +} + +/// Moves the row by `offset` pixels, with nothing beyond its ends. +fn shift_row(source: &[T], target: &mut [T], offset: f64) { + let length = source.len() as f64; + for (index, value) in target.iter_mut().enumerate() { + let position = index as f64 - offset; + let floor = position.floor(); + let fraction = (position - floor) as f32; + + let at = |index: f64| if index >= 0. && index < length { source[index as usize] } else { T::default() }; + *value = at(floor).scaled(1. - fraction).plus(at(floor + 1.).scaled(fraction)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_weights_sum_to_one() { + for kernel in [ + BlurKernel::Gaussian { sigma: 3. }, + BlurKernel::Gaussian { sigma: 40. }, + BlurKernel::Box { radius: 5.5 }, + BlurKernel::Box { radius: 100. }, + ] { + let levels = kernel.levels(); + let weights = kernel.weights(levels); + let total = weights[0] + 2. * weights[1..].iter().sum::(); + + assert!((total - 1.).abs() < 1e-9, "{kernel:?} weights sum to {total}"); + assert!(weights.len() <= MAX_KERNEL_REACH + 1, "{kernel:?} reaches {} texels", weights.len()); + } + } + + #[test] + fn small_kernels_run_without_halving() { + assert_eq!(BlurKernel::Gaussian { sigma: MAX_DIRECT_GAUSSIAN_SIGMA }.levels(), 0); + assert_eq!( + BlurKernel::Gaussian { + sigma: MAX_DIRECT_GAUSSIAN_SIGMA * 1.01 + } + .levels(), + 1 + ); + assert_eq!(BlurKernel::Box { radius: 1. }.levels(), 0); + } + + #[test] + fn gaussian_blur_spreads_a_point_with_the_requested_deviation() { + // A single bright pixel blurs into the Gaussian itself, whose variance the pyramid must preserve at every level count + for sigma in [2., 20.] { + let size = 401; + let mut plane = Plane::::new(size, 1); + plane.data[size / 2] = 1.; + + let blurred = plane.blurred(BlurKernel::Gaussian { sigma }, BlurKernel::Gaussian { sigma: 0. }); + let total: f32 = blurred.data.iter().sum(); + let variance: f64 = blurred.data.iter().enumerate().map(|(x, &value)| value as f64 * (x as f64 - (size / 2) as f64).powi(2)).sum::() / total as f64; + + assert!((total - 1.).abs() < 1e-3, "sigma {sigma} keeps the total at {total}"); + assert!((variance.sqrt() - sigma).abs() / sigma < 0.05, "sigma {sigma} spreads to a deviation of {}", variance.sqrt()); + } + } + + #[test] + fn dilation_grows_shapes_by_the_distance() { + let mut plane = Plane::::new(21, 21); + plane.data[10 * 21 + 10] = 1.; + + let dilated = plane.dilated(3., 3.); + let covered = dilated.data.iter().filter(|&&value| value >= 0.999).count(); + + assert_eq!(covered, 7 * 7, "a point grows into a 7 by 7 square"); + } + + #[test] + fn edge_bands_round_the_corners_they_grow_around() { + // A single pixel grows into a disk the band's width across, rather than a square + let mut point = Plane::::new(21, 21); + point.data[10 * 21 + 10] = 1.; + let outward = point.edge_band(5., false); + let at = |plane: &Plane, x: usize, y: usize| plane.data[y * 21 + x]; + + assert_eq!(at(&outward, 15, 10), 1., "the full width along an axis"); + assert_eq!(at(&outward, 17, 10), 0., "past the width"); + assert_eq!(at(&outward, 15, 14), 0., "past the width diagonally, where a square would still reach"); + + // Inward, only the pixels near the edge are covered + let mut square = Plane::::new(21, 21); + for y in 2..19 { + square.data[y * 21 + 2..y * 21 + 19].fill(1.); + } + let inward = square.edge_band(3., true); + + assert_eq!(at(&inward, 2, 10), 1., "at the edge"); + assert_eq!(at(&inward, 10, 10), 0., "deep inside"); + } + + #[test] + fn shifting_moves_by_fractions_of_a_pixel() { + let mut plane = Plane::::new(4, 1); + plane.data[1] = 1.; + + let shifted = plane.shifted(0.25, 0.); + + assert_eq!(shifted.data, vec![0., 0.75, 0.25, 0.]); + } +} diff --git a/node-graph/libraries/raster-types/src/image.rs b/node-graph/libraries/raster-types/src/image.rs index 2533d117da2..511a1feae02 100644 --- a/node-graph/libraries/raster-types/src/image.rs +++ b/node-graph/libraries/raster-types/src/image.rs @@ -56,10 +56,6 @@ pub struct Image { pub height: u32, #[cfg_attr(feature = "serde", serde(serialize_with = "base64_serde::as_base64", deserialize_with = "base64_serde::from_base64"))] pub data: Vec

, - /// Optional: Stores a base64 string representation of the image which can be used to speed up the conversion - /// to an svg string. This is used as a cache in order to not have to encode the data on every graph evaluation. - #[cfg_attr(feature = "serde", serde(skip))] - pub base64_string: Option, // TODO: Add an `origin` field to store where in the local space the image is anchored. // TODO: Currently it is always anchored at the top left corner at (0, 0). The bottom right corner of the new origin field would correspond to (1, 1). } @@ -136,27 +132,64 @@ impl Image

{ width, height, data: vec![color; (width * height) as usize], - base64_string: None, } } } impl Image { + /// The color at a point in pixel coordinates, interpolating the four nearest pixel centers by their alpha, with transparency outside the image. + pub fn sample_bilinear(&self, point: DVec2) -> Color { + let corner = (point - 0.5).floor(); + let fraction = (point - 0.5 - corner).as_vec2(); + + let premultiplied_at = |offset_x: i64, offset_y: i64| { + let (x, y) = (corner.x as i64 + offset_x, corner.y as i64 + offset_y); + if x < 0 || y < 0 || x >= self.width as i64 || y >= self.height as i64 { + return [0.; 4]; + } + let color = self.data[(y * self.width as i64 + x) as usize]; + [color.r() * color.a(), color.g() * color.a(), color.b() * color.a(), color.a()] + }; + let lerp = |a: [f32; 4], b: [f32; 4], t: f32| std::array::from_fn::(|channel| a[channel] + (b[channel] - a[channel]) * t); + + let top = lerp(premultiplied_at(0, 0), premultiplied_at(1, 0), fraction.x); + let bottom = lerp(premultiplied_at(0, 1), premultiplied_at(1, 1), fraction.x); + let [red, green, blue, alpha] = lerp(top, bottom, fraction.y); + + match alpha > 0. { + true => Color::from_rgbaf32_unchecked(red / alpha, green / alpha, blue / alpha, alpha), + false => Color::TRANSPARENT, + } + } + /// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array) pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self { let data = image_data.chunks_exact(4).map(|v| SRGBA8::new(v[0], v[1], v[2], v[3]).into()).collect(); - Image { - width, - height, - data, - base64_string: None, - } + Image { width, height, data } } /// Decodes an image file of any supported format. pub fn from_encoded(data: &[u8]) -> Option { let image = decode(data)?; + // Most files hold eight-bit samples, which decode through a table of all 256 values rather than a power function for each + if matches!(image.color(), ::image::ColorType::L8 | ::image::ColorType::La8 | ::image::ColorType::Rgb8 | ::image::ColorType::Rgba8) { + let image = image.to_rgba8(); + let data = image + .as_raw() + .as_chunks::<4>() + .0 + .iter() + .map(|&[red, green, blue, alpha]| SRGBA8::new(red, green, blue, alpha).into()) + .collect(); + + return Some(Image { + width: image.width(), + height: image.height(), + data, + }); + } + // Float samples hold linear light, as in HDR and EXR files, while integer samples are gamma encoded let linear = matches!(image.color(), ::image::ColorType::Rgb32F | ::image::ColorType::Rgba32F); // An EXR file stores its color multiplied by its alpha, unlike the other formats and unlike `Color` @@ -182,7 +215,6 @@ impl Image { width: image.width(), height: image.height(), data, - base64_string: None, }) } @@ -191,6 +223,42 @@ impl Image { decode(data).map(|image| (image.width(), image.height())) } + /// The image shrunk by half along each axis `times` times, averaging each block of pixels with their colors weighted by their alpha. + /// Blocks at the right and bottom edges average only the pixels they have. O(width × height). + pub fn halved(&self, times: u32) -> Self { + if times == 0 { + return self.clone(); + } + + let factor = 1_u32 << times.min(31); + let (width, height) = (self.width.div_ceil(factor), self.height.div_ceil(factor)); + + let mut data = Vec::with_capacity((width * height) as usize); + for block_y in 0..height { + for block_x in 0..width { + let (mut red, mut green, mut blue, mut alpha, mut count) = (0., 0., 0., 0., 0.); + + for y in (block_y * factor)..((block_y + 1) * factor).min(self.height) { + for x in (block_x * factor)..((block_x + 1) * factor).min(self.width) { + let pixel = self.data[(y * self.width + x) as usize]; + red += pixel.r() * pixel.a(); + green += pixel.g() * pixel.a(); + blue += pixel.b() * pixel.a(); + alpha += pixel.a(); + count += 1.; + } + } + + data.push(match alpha > 0. { + true => Color::from_rgbaf32_unchecked(red / alpha, green / alpha, blue / alpha, alpha / count), + false => Color::TRANSPARENT, + }); + } + } + + Image { width, height, data } + } + pub fn to_png(&self) -> Vec { use ::image::ImageEncoder; let (data, width, height) = self.to_flat_u8(); @@ -313,7 +381,6 @@ mod test { width: 2, height: 2, data: vec![Color::WHITE, Color::BLACK, Color::RED, Color::GREEN], - base64_string: None, }; let serialized = serde_json::to_string(&image).unwrap(); @@ -354,6 +421,45 @@ mod test { assert!(Image::from_encoded(b"not an image").is_none()); } + #[test] + fn halving_averages_blocks_weighted_by_alpha() { + use super::*; + + // An opaque red and a transparent green beside it average to red at half alpha, with no green showing through + let red = Color::from_rgbaf32_unchecked(1., 0., 0., 1.); + let clear_green = Color::from_rgbaf32_unchecked(0., 1., 0., 0.); + let image = Image { + width: 3, + height: 1, + data: vec![red, clear_green, red], + }; + + let halved = image.halved(1); + assert_eq!((halved.width, halved.height), (2, 1), "the odd column becomes a block of its own"); + assert_eq!(halved.data[0], Color::from_rgbaf32_unchecked(1., 0., 0., 0.5)); + assert_eq!(halved.data[1], red); + assert_eq!(image.halved(0), image); + } + + #[test] + fn eight_bit_decoding_matches_the_gamma_curve() { + use super::*; + + // Every channel value, as a gray ramp whose alpha runs the other way + let pixels: Vec = (0..=255_u8).flat_map(|value| [value, value, value, 255 - value]).collect(); + let mut encoded = Vec::new(); + ::image::RgbaImage::from_raw(256, 1, pixels) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut encoded), ::image::ImageFormat::Png) + .unwrap(); + + let image = Image::from_encoded(&encoded).unwrap(); + for (value, color) in image.data.iter().enumerate() { + let expected = Color::from_gamma_srgb_channels(value as f32 / 255., value as f32 / 255., value as f32 / 255., (255 - value) as f32 / 255.); + assert!((color.r() - expected.r()).abs() < 1e-6 && (color.a() - expected.a()).abs() < 1e-6, "value {value}"); + } + } + #[test] fn recognized_format_that_cannot_be_read_is_not_tried_as_tga() { use super::*; diff --git a/node-graph/libraries/raster-types/src/lib.rs b/node-graph/libraries/raster-types/src/lib.rs index 83340625d50..4b98c5009d9 100644 --- a/node-graph/libraries/raster-types/src/lib.rs +++ b/node-graph/libraries/raster-types/src/lib.rs @@ -1,4 +1,6 @@ +pub mod effect_kernels; pub mod image; +pub mod parallel_rows; pub mod raster_types; // Re-exports for convenience diff --git a/node-graph/libraries/raster-types/src/parallel_rows.rs b/node-graph/libraries/raster-types/src/parallel_rows.rs new file mode 100644 index 00000000000..7c56c73aae1 --- /dev/null +++ b/node-graph/libraries/raster-types/src/parallel_rows.rs @@ -0,0 +1,51 @@ +//! Spreads per-row image work across threads on platforms that have them. + +/// Below this many pixels, starting threads costs more than they save. +#[cfg(not(target_family = "wasm"))] +const MIN_PIXELS_FOR_THREADS: usize = 65_536; + +/// Calls `fill_row` with the index and pixels of each row of `pixels`, an image `width` pixels wide. +/// On native platforms, bands of rows run on separate threads, so `fill_row` must not depend on the order rows are filled in. +pub fn fill_rows(pixels: &mut [P], width: usize, fill_row: impl Fn(usize, &mut [P]) + Sync) { + if width == 0 { + return; + } + + #[cfg(not(target_family = "wasm"))] + if pixels.len() >= MIN_PIXELS_FOR_THREADS { + let threads = std::thread::available_parallelism().map_or(1, |threads| threads.get()); + let rows_per_band = (pixels.len() / width).div_ceil(threads).max(1); + + std::thread::scope(|scope| { + for (band_index, band) in pixels.chunks_mut(rows_per_band * width).enumerate() { + let fill_row = &fill_row; + scope.spawn(move || { + for (row_in_band, row) in band.chunks_mut(width).enumerate() { + fill_row(band_index * rows_per_band + row_in_band, row); + } + }); + } + }); + return; + } + + for (y, row) in pixels.chunks_mut(width).enumerate() { + fill_row(y, row); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_row_is_filled_with_its_own_index() { + // Large enough to run on threads where there are any, with a ragged count of rows per band + let (width, height) = (300, 301); + let mut pixels = vec![0_usize; width * height]; + + fill_rows(&mut pixels, width, |y, row| row.iter_mut().enumerate().for_each(|(x, pixel)| *pixel = y * width + x)); + + assert!(pixels.iter().enumerate().all(|(index, pixel)| *pixel == index)); + } +} diff --git a/node-graph/libraries/raster-types/src/raster_types.rs b/node-graph/libraries/raster-types/src/raster_types.rs index d210447182b..593a88b6112 100644 --- a/node-graph/libraries/raster-types/src/raster_types.rs +++ b/node-graph/libraries/raster-types/src/raster_types.rs @@ -75,21 +75,81 @@ pub use cpu::CPU; mod cpu { use super::*; use crate::raster_types::__private::Sealed; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + + /// Pixels in CPU memory, shared between clones and identified by an ID that changes whenever they are modified. + /// So cloning is a reference count increment, and hashing for caches reads the ID rather than every pixel. + #[derive(Clone, Debug, Default, DynAny)] + pub struct CPU(Arc); + + #[derive(Debug, Default)] + struct Buffer { + /// Zero until the ID is first read after the pixels last changed, so a run of modifications takes only one new ID. + id: AtomicU64, + image: Image, + /// The pixels encoded as a PNG data URL, made the first time an SVG embeds them. + png_data_url: OnceLock>, + /// The pixels most recently derived from these, with the key they were derived for. + derived: Mutex)>>, + } + + // A copy is made only to be modified, so it starts without an ID + impl Clone for Buffer { + fn clone(&self) -> Self { + Self { + id: AtomicU64::new(0), + image: self.image.clone(), + png_data_url: OnceLock::new(), + derived: Mutex::new(None), + } + } + } + + impl CPU { + /// The ID of these pixels, shared by every clone until one of them is modified. + pub fn buffer_id(&self) -> u64 { + // Counts from one, since zero marks an ID not yet taken + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + let id = self.0.id.load(Ordering::Relaxed); + if id != 0 { + return id; + } + + let new_id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + match self.0.id.compare_exchange(0, new_id, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => new_id, + // Another thread took one first + Err(existing_id) => existing_id, + } + } + } + + impl PartialEq for CPU { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) || self.0.image == other.0.image + } + } - #[derive(Clone, Debug, Default, PartialEq, core_types::CacheHash, DynAny)] - pub struct CPU(Image); + // Pixels can't change without taking a new ID, so the ID stands in for them + impl core_types::CacheHash for CPU { + fn cache_hash(&self, state: &mut H) { + core_types::CacheHash::cache_hash(&self.buffer_id(), state); + } + } impl Sealed for Raster {} impl Storage for Raster { fn is_empty(&self) -> bool { - self.0.height == 0 || self.0.width == 0 + self.height == 0 || self.width == 0 } } impl Raster { pub fn new_cpu(image: Image) -> Self { - Self::new(CPU(image)) + Self::new(CPU(Arc::new(Buffer { image, ..Default::default() }))) } pub fn data(&self) -> &Image { @@ -100,8 +160,41 @@ mod cpu { self } + /// The pixels, copied only if another clone shares them. pub fn into_data(self) -> Image { - self.storage.0 + Arc::try_unwrap(self.storage.0).map_or_else(|shared| shared.image.clone(), |buffer| buffer.image) + } + + /// The pixels encoded as a PNG `data:` URL, encoded once and shared by every clone. + pub fn png_data_url(&self) -> Arc { + self.0 + .png_data_url + .get_or_init(|| { + use base64::Engine; + + let preamble = "data:image/png;base64,"; + let png = self.to_png(); + let mut url = String::with_capacity(preamble.len() + png.len().div_ceil(3) * 4); + url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(png, &mut url); + url.into() + }) + .clone() + } + + /// Pixels derived from these by `derive` for `key`. + /// The latest are kept with these pixels and shared by every clone, so deriving them again for the same key reuses them, ID and all. + pub fn derived(&self, key: u64, derive: impl FnOnce(&Self) -> Raster) -> Raster { + let mut derived = self.0.derived.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((derived_key, raster)) = &*derived + && *derived_key == key + { + return raster.clone(); + } + + let raster = derive(self); + *derived = Some((key, raster.clone())); + raster } } @@ -109,13 +202,18 @@ mod cpu { type Target = Image; fn deref(&self) -> &Self::Target { - &self.0 + &self.0.image } } impl DerefMut for CPU { + /// Copies the pixels if another clone shares them, and drops their ID, PNG encoding, and derived pixels since they may change. fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + let buffer = Arc::make_mut(&mut self.0); + *buffer.id.get_mut() = 0; + buffer.png_data_url = OnceLock::new(); + *buffer.derived.get_mut().unwrap_or_else(PoisonError::into_inner) = None; + &mut buffer.image } } @@ -133,7 +231,61 @@ mod cpu { where S: serde::Serializer, { - self.0.serialize(serializer) + self.data().serialize(serializer) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn clones_share_pixels_until_one_is_modified() { + let original = Raster::new_cpu(Image::new(2, 2, Color::BLACK)); + let mut copy = original.clone(); + assert_eq!(copy.buffer_id(), original.buffer_id()); + assert!(Arc::ptr_eq(©.storage.0, &original.storage.0)); + + // Modifying the copy leaves the original's pixels and ID untouched + copy.data_mut().data[0] = Color::WHITE; + assert_ne!(copy.buffer_id(), original.buffer_id()); + assert_eq!(original.data().data[0], Color::BLACK); + assert_eq!(copy.data().data[0], Color::WHITE); + } + + #[test] + fn modifying_unshared_pixels_takes_a_new_id_in_place() { + let mut raster = Raster::new_cpu(Image::new(2, 2, Color::BLACK)); + let (id, pixels) = (raster.buffer_id(), raster.data().data.as_ptr()); + + raster.data_mut().data[0] = Color::WHITE; + assert_ne!(raster.buffer_id(), id, "caches keyed by the old ID must not see the new pixels"); + assert_eq!(raster.data().data.as_ptr(), pixels, "pixels nothing else shares are modified without a copy"); + } + + #[test] + fn derived_pixels_are_reused_for_the_same_key_until_the_pixels_change() { + let mut raster = Raster::new_cpu(Image::new(2, 2, Color::BLACK)); + let derive = |_: &Raster| Raster::new_cpu(Image::new(1, 1, Color::WHITE)); + + let first = raster.derived(1, derive); + let again = raster.clone().derived(1, |_| panic!("the pixels derived for this key are kept")); + assert_eq!(again.buffer_id(), first.buffer_id(), "reused pixels keep their ID"); + + let second = raster.derived(2, derive); + assert_ne!(second.buffer_id(), first.buffer_id(), "another key derives anew"); + + raster.data_mut().data[0] = Color::WHITE; + assert_ne!(raster.derived(2, derive).buffer_id(), second.buffer_id(), "modified pixels derive anew"); + } + + #[test] + fn equal_pixels_compare_equal_whatever_their_ids() { + let first = Raster::new_cpu(Image::new(2, 2, Color::BLACK)); + let second = Raster::new_cpu(Image::new(2, 2, Color::BLACK)); + + assert_ne!(first.buffer_id(), second.buffer_id()); + assert_eq!(first, second); } } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 8b6fe208564..bae0b7c77da 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -37,6 +37,7 @@ kurbo = { workspace = true } vector-types = { workspace = true } graphic-types = { workspace = true } vello_gpu = { workspace = true } +wgpu = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } diff --git a/node-graph/libraries/rendering/src/dial_gradient.rs b/node-graph/libraries/rendering/src/dial_gradient.rs new file mode 100644 index 00000000000..9cf177256f2 --- /dev/null +++ b/node-graph/libraries/rendering/src/dial_gradient.rs @@ -0,0 +1,541 @@ +//! Gradients whose two ends are each a contour dialed between a line, a circle, and a point, with the contours between +//! them filling in as the pencil of circles the two end contours span. +//! +//! Everything here is in gradient space, where the gradient line runs from the start point at the origin to the end point +//! at (1, 0). Each end's contour passes through its endpoint with its center on the line, and is described by its dial +//! `α ∈ [-1, 1]`: 0 is the line perpendicular to the gradient line, ±1 is the endpoint itself, and in between is a circle +//! bowing toward (positive) or away from (negative) the other endpoint. +//! +//! The contours between are the members of the pencil the end contours span. Each member is spaced by where it crosses +//! the gradient line, so a stop at 0.3 sits where the 0.3 contour crosses the line. This reproduces the linear and radial +//! forms exactly, since their contours cross the line at a distance in proportion to their place along the ramp. + +use glam::DVec2; +use std::f64::consts::FRAC_PI_2; + +pub use graphic_types::vector_types::gradient::{LINEAR_DIALS, RADIAL_DIALS}; + +/// Stands in for a t of infinity, on the contours whose crossing with the gradient line is at infinity. +const UNBOUNDED_T: f64 = 1e6; + +/// A generalized circle `a(x² + y²) + bx + c = 0` with its center on the gradient line: a circle, a line where `a` is zero, +/// or a point where it has zero radius. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AxisCircle { + pub a: f64, + pub b: f64, + pub c: f64, +} + +impl AxisCircle { + /// The contour through the start point at the origin, dialed by `alpha` toward the end point. + pub fn start(alpha: f64) -> Self { + let (sin, cos) = (alpha * FRAC_PI_2).sin_cos(); + Self { a: sin, b: -2. * cos, c: 0. } + } + + /// The contour through the end point at (1, 0), dialed by `alpha` toward the start point. + pub fn end(alpha: f64) -> Self { + let (sin, cos) = (alpha * FRAC_PI_2).sin_cos(); + Self { + a: sin, + b: 2. * cos - 2. * sin, + c: sin - 2. * cos, + } + } + + pub fn value(&self, point: DVec2) -> f64 { + self.a * point.length_squared() + self.b * point.x + self.c + } + + /// The points where it crosses the gradient line, in homogeneous form. + fn crossings(&self) -> [Homogeneous; 2] { + solve_quadratic(self.a, self.b, self.c) + } + + /// The member of the pencil `self·weight + other·other_weight`. + fn combine(&self, weight: f64, other: &Self, other_weight: f64) -> Self { + Self { + a: self.a * weight + other.a * other_weight, + b: self.b * weight + other.b * other_weight, + c: self.c * weight + other.c * other_weight, + } + } +} + +/// A point on the gradient line in homogeneous form, `numerator / denominator`, so the point at infinity is just a zero denominator. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Homogeneous { + numerator: f64, + denominator: f64, +} + +impl Homogeneous { + const INFINITY: Self = Self { numerator: 1., denominator: 0. }; + + fn value(&self) -> Option { + (self.denominator != 0.).then(|| self.numerator / self.denominator) + } + + /// Scaled so the larger of its two parts has a magnitude of 1, keeping products of points in range. + fn normalized(self) -> Self { + let scale = self.numerator.abs().max(self.denominator.abs()); + if scale == 0. { + return self; + } + Self { + numerator: self.numerator / scale, + denominator: self.denominator / scale, + } + } +} + +/// The roots of `a y² + b y + c = 0`, in homogeneous form so a vanishing `a` puts a root at infinity rather than dividing by zero. +fn solve_quadratic(a: f64, b: f64, c: f64) -> [Homogeneous; 2] { + let discriminant = (b * b - 4. * a * c).max(0.); + let sign = if b >= 0. { 1. } else { -1. }; + let q = -0.5 * (b + sign * discriminant.sqrt()); + + [Homogeneous { numerator: q, denominator: a }.normalized(), Homogeneous { numerator: c, denominator: q }.normalized()] +} + +/// How the crossing of a pixel's contour is told apart from the other place the same circle crosses the gradient line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CrossingSelection { + /// The end contours cross, so every member passes through the two points where they cross, and the gradient line is split by + /// the line through those points at `radical_x`. Each side of a member circle is its own contour, crossing the gradient line + /// on the same side of `radical_x` as the pixel. + SameSideAs { radical_x: f64 }, + /// The end contours don't cross, so the members nest around two limit points that split the gradient line into two arcs, and + /// every member crosses each arc once. The crossing on the arc holding the gradient's band is the one where + /// `quadratic[0] y² + quadratic[1] y + quadratic[2]` (with `y` in homogeneous form) is smallest. + OnArc { quadratic: [f64; 3] }, +} + +/// Where the arc holding the band passes through infinity, the crossings beyond infinity on its other side. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Wrap { + /// A point on the gradient line between the two limit points, which separates the arc's two sides of infinity. + pub reference_x: f64, + /// Which side of `reference_x` the band is on, `1` or `-1`. + pub band_side: f64, + /// The t of the crossings on the far side of infinity. + pub t: f64, +} + +/// Everything needed to find the t of any pixel in a dialed gradient. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PencilParameters { + pub start: AxisCircle, + pub end: AxisCircle, + pub selection: CrossingSelection, + pub wrap: Option, + /// The t of a crossing at `y` is `y * t_scale + t_offset`. + pub t_scale: f64, + pub t_offset: f64, +} + +impl PencilParameters { + /// The parameters of the gradient with these two dials, which must be finite and within -1 to 1. + pub fn new(start_dial: f64, end_dial: f64) -> Self { + let start = AxisCircle::start(start_dial); + let end = AxisCircle::end(end_dial); + + // The members of the pencil that are points have a zero discriminant, which as a quadratic form in the two members' + // weights has these coefficients. Its determinant says whether the pencil has two such points (it's hyperbolic), none + // (elliptic, where the end contours cross), or one (parabolic, where they touch). + let point_quadratic_start = start.b * start.b - 4. * start.a * start.c; + let point_quadratic_cross = start.b * end.b - 2. * (start.a * end.c + end.a * start.c); + let point_quadratic_end = end.b * end.b - 4. * end.a * end.c; + let determinant = point_quadratic_start * point_quadratic_end - point_quadratic_cross * point_quadratic_cross; + let scale = point_quadratic_start.abs().max(point_quadratic_end.abs()).max(point_quadratic_cross.abs()).max(f64::MIN_POSITIVE); + + if determinant > 1e-12 * scale * scale { + // The line through the two points where the end contours cross is the member with no curvature + let line = start.combine(end.a, &end, -start.a); + let radical_x = -line.c / line.b; + + return Self::with_band(start, end, CrossingSelection::SameSideAs { radical_x }, None, 0., 1.); + } + + // The limit points are where the point members sit, with a point member of no curvature being the point at infinity + let limit_points = point_member_weights(point_quadratic_start, point_quadratic_cross, point_quadratic_end).map(|(start_weight, end_weight)| { + let member = start.combine(start_weight, &end, end_weight); + let magnitude = member.a.abs().max(member.b.abs()).max(member.c.abs()); + if member.a.abs() <= 1e-12 * magnitude { + return Homogeneous::INFINITY; + } + + Homogeneous { + numerator: -member.b, + denominator: 2. * member.a, + } + .normalized() + }); + + // Each crossing's place relative to the limit points, negative between them and positive beyond them + let arc_quadratic = arc_quadratic(limit_points); + let arc_of = |point: Homogeneous| evaluate_homogeneous_quadratic(arc_quadratic, point.normalized()); + + // Find each end contour's crossing on each arc, where a point contour (a limit point itself) lies on both + let choose_on_arc = |crossings: [Homogeneous; 2], negative_arc: bool| { + crossings + .into_iter() + .min_by(|a, b| { + let (a, b) = (arc_of(*a), arc_of(*b)); + if negative_arc { a.total_cmp(&b) } else { b.total_cmp(&a) } + }) + .unwrap_or(Homogeneous::INFINITY) + }; + let candidates = [true, false].map(|negative_arc| { + let band_start = choose_on_arc(start.crossings(), negative_arc); + let band_end = choose_on_arc(end.crossings(), negative_arc); + (negative_arc, band_start, band_end) + }); + + // A band through infinity would give a contour within it an infinite t, so it's unusable. Among the rest, prefer the + // band that keeps the endpoints at t of 0 and 1. + let limit_values = limit_points.map(|point| point.value()); + let usable = |band_start: Homogeneous, band_end: Homogeneous| { + let (Some(band_start), Some(band_end)) = (band_start.value(), band_end.value()) else { + return false; + }; + match limit_values { + [Some(first), Some(second)] => { + let (low, high) = (first.min(second), first.max(second)); + let beyond = |y: f64| { + if y > high { + 1 + } else if y < low { + -1 + } else { + 0 + } + }; + // On the outer arc, crossings on opposite sides of the limit points lie across infinity from each other + beyond(band_start) * beyond(band_end) >= 0 + } + _ => true, + } + }; + let truthfulness = |band_start: Homogeneous, band_end: Homogeneous| { + let is = |point: Homogeneous, target: f64| point.value().is_some_and(|value| (value - target).abs() < 1e-9); + is(band_start, 0.) as u8 + is(band_end, 1.) as u8 + }; + let chosen = candidates + .into_iter() + .filter(|&(_, band_start, band_end)| usable(band_start, band_end)) + .max_by_key(|&(_, band_start, band_end)| truthfulness(band_start, band_end)) + .unwrap_or(candidates[1]); + let (negative_arc, band_start, band_end) = chosen; + + // Choosing the smallest value picks the crossing on the chosen arc + let sign = if negative_arc { 1. } else { -1. }; + let selection = CrossingSelection::OnArc { + quadratic: arc_quadratic.map(|coefficient| coefficient * sign), + }; + + let band_start = band_start.value().unwrap_or(0.); + let band_end = band_end.value().unwrap_or(1.); + + // An outer arc between two finite limit points passes through infinity, beyond which its crossings belong past the band's far end + let wrap = match (negative_arc, limit_values) { + (false, [Some(first), Some(second)]) if (first - second).abs() > 0. => { + let reference_x = (first + second) / 2.; + let band_side = if band_start > reference_x { 1. } else { -1. }; + let t_direction = (band_end - band_start).signum(); + Some(Wrap { + reference_x, + band_side, + t: band_side * t_direction * UNBOUNDED_T, + }) + } + _ => None, + }; + + Self::with_band(start, end, selection, wrap, band_start, band_end) + } + + fn with_band(start: AxisCircle, end: AxisCircle, selection: CrossingSelection, wrap: Option, band_start: f64, band_end: f64) -> Self { + let width = band_end - band_start; + let width = if width.abs() < 1e-12 { 1e-12_f64.copysign(width) } else { width }; + + Self { + start, + end, + selection, + wrap, + t_scale: 1. / width, + t_offset: -band_start / width, + } + } + + /// The gradient's t at `point` in gradient space, by the same steps the renderer's shader takes. + pub fn t(&self, point: DVec2) -> f64 { + let start_value = self.start.value(point); + let end_value = self.end.value(point); + let scale = start_value.abs().max(end_value.abs()); + if scale == 0. { + return 0.; + } + let (start_value, end_value) = (start_value / scale, end_value / scale); + + // The pixel's contour is the member where the end contours' values keep this ratio, which crosses the gradient line at the + // roots of this quadratic + let a = end_value * self.start.a - start_value * self.end.a; + let b = end_value * self.start.b - start_value * self.end.b; + let c = end_value * self.start.c - start_value * self.end.c; + let roots = solve_quadratic(a, b, c); + + let crossing = match self.selection { + CrossingSelection::SameSideAs { radical_x } => { + // A pixel on the dividing line itself is on the line member, whose crossing is the finite root + let side = |root: Homogeneous| (root.numerator - radical_x * root.denominator) * root.denominator * (point.x - radical_x); + let (first, second) = (side(roots[0]), side(roots[1])); + let first_wins = if first == second { + roots[0].denominator.abs() >= roots[1].denominator.abs() + } else { + first > second + }; + if first_wins { roots[0] } else { roots[1] } + } + CrossingSelection::OnArc { quadratic } => { + if evaluate_homogeneous_quadratic(quadratic, roots[0]) <= evaluate_homogeneous_quadratic(quadratic, roots[1]) { + roots[0] + } else { + roots[1] + } + } + }; + + if let Some(wrap) = self.wrap + && (crossing.numerator - wrap.reference_x * crossing.denominator) * crossing.denominator * wrap.band_side < 0. + { + return wrap.t; + } + + if crossing.denominator == 0. { + let direction = crossing.numerator * self.t_scale; + return if direction == 0. { 0. } else { direction.signum() * UNBOUNDED_T }; + } + crossing.numerator / crossing.denominator * self.t_scale + self.t_offset + } +} + +impl PencilParameters { + /// The same parameters for Vello GPU's pencil gradient paint. + pub fn to_vello(&self) -> vello_gpu::PencilGeometry { + let circle = |circle: &AxisCircle| [circle.a as f32, circle.b as f32, circle.c as f32]; + + vello_gpu::PencilGeometry { + start: circle(&self.start), + end: circle(&self.end), + selection: match self.selection { + CrossingSelection::SameSideAs { radical_x } => vello_gpu::PencilSelection::SameSideAs { radical_x: radical_x as f32 }, + CrossingSelection::OnArc { quadratic } => vello_gpu::PencilSelection::OnArc { + quadratic: quadratic.map(|coefficient| coefficient as f32), + }, + }, + wrap: self.wrap.map(|wrap| vello_gpu::PencilWrap { + reference_x: wrap.reference_x as f32, + band_side: wrap.band_side as f32, + t: wrap.t as f32, + }), + t_scale: self.t_scale as f32, + t_offset: self.t_offset as f32, + } + } + + /// The closest gradient SVG can express: a radial gradient from the start circle to the end circle when one nests inside + /// the other, or else the linear gradient along the gradient line. + pub fn svg_approximation(&self) -> SvgApproximation { + let (Some(start), Some(end)) = (self.start.center_and_radius(), self.end.center_and_radius()) else { + return SvgApproximation::Linear; + }; + + let distance = (start.0 - end.0).abs(); + let nested = distance <= (start.1 - end.1).abs(); + if !nested { + return SvgApproximation::Linear; + } + + SvgApproximation::Radial { + start_center: start.0, + start_radius: start.1, + end_center: end.0, + end_radius: end.1, + } + } +} + +/// A gradient SVG can draw in place of a dialed one, in gradient space. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SvgApproximation { + Linear, + /// From the start circle, as SVG's focal circle, out to the end circle, both centered on the gradient line. + Radial { + start_center: f64, + start_radius: f64, + end_center: f64, + end_radius: f64, + }, +} + +impl AxisCircle { + /// Where its center sits on the gradient line, and its radius, unless it's a line. + fn center_and_radius(&self) -> Option<(f64, f64)> { + if self.a.abs() < 1e-9 { + return None; + } + let center = -self.b / (2. * self.a); + let radius = (center * center - self.c / self.a).max(0.).sqrt(); + Some((center, radius)) + } +} + +/// The weights of the two members that are points, the roots of the homogeneous quadratic form `start s² + 2·cross·s·e + end e²`. +fn point_member_weights(start: f64, cross: f64, end: f64) -> [(f64, f64); 2] { + let discriminant = (cross * cross - start * end).max(0.).sqrt(); + + if start.abs() < 1e-12 && end.abs() < 1e-12 { + // Both end contours are points already + [(1., 0.), (0., 1.)] + } else if start.abs() >= end.abs() { + [((-cross + discriminant) / start, 1.), ((-cross - discriminant) / start, 1.)] + } else { + [(1., (-cross + discriminant) / end), (1., (-cross - discriminant) / end)] + } +} + +/// The quadratic in a homogeneous point that is negative between the two limit points and positive beyond them. +fn arc_quadratic([first, second]: [Homogeneous; 2]) -> [f64; 3] { + // (y·d₁ - n₁)(y·d₂ - n₂) with the denominators kept nonnegative, so its sign doesn't depend on how each point is scaled + let orient = |point: Homogeneous| { + if point.denominator < 0. { + Homogeneous { + numerator: -point.numerator, + denominator: -point.denominator, + } + } else { + point + } + }; + let (first, second) = (orient(first), orient(second)); + + [ + first.denominator * second.denominator, + -(first.denominator * second.numerator + first.numerator * second.denominator), + first.numerator * second.numerator, + ] +} + +fn evaluate_homogeneous_quadratic([a, b, c]: [f64; 3], point: Homogeneous) -> f64 { + let Homogeneous { numerator, denominator } = point; + a * numerator * numerator + b * numerator * denominator + c * denominator * denominator +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_close(actual: f64, expected: f64, context: &str) { + assert!((actual - expected).abs() < 1e-6, "{context}: expected {expected}, got {actual}"); + } + + #[test] + fn radial_dials_space_contours_by_radius() { + let parameters = PencilParameters::new(RADIAL_DIALS[0], RADIAL_DIALS[1]); + for radius in [0.1, 0.3, 0.75, 1., 1.5, 4.] { + for angle in [0., 0.7, 2., 3.1, 4.5] { + let point = DVec2::from_angle(angle) * radius; + assert_close(parameters.t(point), radius, &format!("radius {radius} at angle {angle}")); + } + } + } + + #[test] + fn linear_dials_space_contours_by_distance_along_the_line() { + let parameters = PencilParameters::new(LINEAR_DIALS[0], LINEAR_DIALS[1]); + for point in [DVec2::new(0.25, 0.), DVec2::new(0.6, 3.), DVec2::new(-2., -7.), DVec2::new(5., 0.5)] { + assert_close(parameters.t(point), point.x, &format!("point {point}")); + } + } + + #[test] + fn concentric_dials_space_contours_by_radius_between_the_ends() { + // The donut: a hole of radius 1 centered 1 before the start, and an end circle of radius 2 around the same center + let parameters = PencilParameters::new(-0.5, 0.5_f64.atan() / FRAC_PI_2); + let center = DVec2::new(-1., 0.); + for radius in [1., 1.25, 1.5, 2., 3.] { + for angle in [0., 1., 2.5, 4.] { + let point = center + DVec2::from_angle(angle) * radius; + assert_close(parameters.t(point), radius - 1., &format!("radius {radius} at angle {angle}")); + } + } + } + + #[test] + fn point_dials_space_contours_by_distance_ratio() { + let parameters = PencilParameters::new(1., 1.); + for point in [DVec2::new(0.5, 0.), DVec2::new(0.5, 2.), DVec2::new(0.2, 0.3), DVec2::new(3., -1.)] { + // The contours are the circles of Apollonius, each crossing the line between the points at y / (1 - y) = ratio + let ratio = point.length() / (point - DVec2::X).length(); + assert_close(parameters.t(point), ratio / (1. + ratio), &format!("point {point}")); + } + } + + #[test] + fn dials_near_a_preset_render_like_it() { + let radial = PencilParameters::new(RADIAL_DIALS[0], RADIAL_DIALS[1]); + let near_radial = PencilParameters::new(RADIAL_DIALS[0] - 1e-7, RADIAL_DIALS[1] + 1e-7); + let linear = PencilParameters::new(LINEAR_DIALS[0], LINEAR_DIALS[1]); + let near_linear = PencilParameters::new(LINEAR_DIALS[0] + 1e-7, LINEAR_DIALS[1] - 1e-7); + + for point in [DVec2::new(0.3, 0.2), DVec2::new(-0.5, 0.4), DVec2::new(0.9, -0.1)] { + assert!((radial.t(point) - near_radial.t(point)).abs() < 1e-4, "near radial at {point}"); + assert!((linear.t(point) - near_linear.t(point)).abs() < 1e-4, "near linear at {point}"); + } + } + + #[test] + fn focal_gradient_pads_everywhere_outside_its_end_circle() { + // The start point sits inside the end circle, off its center + let parameters = PencilParameters::new(1., 0.6); + + assert_close(parameters.t(DVec2::X), 1., "end point"); + let inside = parameters.t(DVec2::new(0.5, 0.)); + assert!(inside > 0. && inside < 1., "inside: {inside}"); + for point in [DVec2::new(-5., 0.), DVec2::new(-0.5, 0.), DVec2::new(5., 0.), DVec2::new(0.3, 4.), DVec2::new(-40., 30.)] { + let t = parameters.t(point); + assert!(t > 1., "outside at {point}: {t}"); + } + } + + #[test] + fn crossing_contours_fan_through_their_lens() { + let parameters = PencilParameters::new(0.6, 0.6); + assert!(matches!(parameters.selection, CrossingSelection::SameSideAs { .. })); + + for x in [0., 0.1, 0.25, 0.5, 0.8, 1.] { + assert_close(parameters.t(DVec2::new(x, 0.)), x, &format!("on the line at {x}")); + } + // Mirrored across the gradient line + assert_close(parameters.t(DVec2::new(0.3, 0.2)), parameters.t(DVec2::new(0.3, -0.2)), "mirror"); + } + + #[test] + fn every_dial_pair_gives_a_number_everywhere() { + for start_step in -10..=10 { + for end_step in -10..=10 { + let parameters = PencilParameters::new(start_step as f64 / 10., end_step as f64 / 10.); + for x in -8..=8 { + for y in -8..=8 { + let point = DVec2::new(x as f64 * 0.37, y as f64 * 0.41); + let t = parameters.t(point); + assert!(!t.is_nan(), "dials ({start_step}, {end_step}) at {point}"); + } + } + } + } + } +} diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 25365911c81..ffc35b5be10 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,4 +1,5 @@ pub mod convert_usvg_path; +pub mod dial_gradient; pub mod render_ext; mod renderer; pub mod to_peniko; diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 0527770bac9..8b239b3d403 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,14 +1,14 @@ use crate::renderer::{ - ItemRef, RenderParams, composite_paint_colors, faded_paint_color, format_transform_matrix, gradient_placement, gradient_settings_from_item, spread_adjusted_samples, transform_is_invertible, + GradientShape, ItemRef, RenderParams, composite_paint_colors, faded_paint_color, format_transform_matrix, gradient_placement, painted_gradient, spread_adjusted_samples, svg_gradient_definition, + transform_is_invertible, }; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; -use core_types::{ATTR_GRADIENT_FORM, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; use graphic_types::Graphic; -use graphic_types::vector_types::gradient::GradientForm; use graphic_types::vector_types::vector::style::{Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::Gradient; @@ -87,12 +87,11 @@ fn render_gradient_paint(item: Option>, svg_defs: &mut Str let mut stop = String::new(); let item = item?; - let stops = item.element()?; - let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let (stops, settings) = painted_gradient(item)?; + let shape = GradientShape::of(item); let local_gradient_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - let settings = gradient_settings_from_item(item); - let mut samples = spread_adjusted_samples(stops, settings, gradient_form); + let mut samples = spread_adjusted_samples(&stops, settings, shape.reaches_before_start()); let paint_opacity = item.paint_opacity(for_mask); if paint_opacity < 1. { @@ -134,7 +133,7 @@ fn render_gradient_paint(item: Option>, svg_defs: &mut Str let document_transform = item_transform * local_gradient_transform; - let placement = gradient_placement(document_transform, gradient_form); + let placement = gradient_placement(document_transform, shape); let gradient_transform = format_transform_matrix(element_transform_inverse * placement); let gradient_transform = if gradient_transform.is_empty() { String::new() @@ -149,23 +148,7 @@ fn render_gradient_paint(item: Option>, svg_defs: &mut Str }; let gradient_id = generate_uuid(); - - match gradient_form { - GradientForm::Linear => { - let _ = write!( - svg_defs, - r#"{}"#, - gradient_id, stop - ); - } - GradientForm::Radial => { - let _ = write!( - svg_defs, - r#"{}"#, - gradient_id, stop - ); - } - } + svg_defs.push_str(&svg_gradient_definition(shape, gradient_id, &format!("{gradient_spread}{gradient_transform}"), &stop)); Some(gradient_id) } @@ -285,10 +268,12 @@ impl RenderExt for Graphic { | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) + | Graphic::Generator(_) | Graphic::Text(_) | Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) + | Graphic::GeneratorList(_) | Graphic::GraphicList(_) | Graphic::GradientList(_) | Graphic::TextList(_) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 3a1f8ff2631..1e147692618 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,6 +1,7 @@ +use crate::dial_gradient::{LINEAR_DIALS, PencilParameters, RADIAL_DIALS, SvgApproximation}; use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; -use crate::vello_scene_builder::VelloSceneBuilder; +use crate::vello_scene_builder::{SceneBrush, VelloSceneBuilder}; use core_types::CacheHash; use core_types::FallibleVec2Operations; use core_types::blending::{BlendMode, apply_blend_mode}; @@ -17,29 +18,32 @@ use core_types::render_complexity::RenderComplexity; use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ - ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, - ATTR_TRANSFORM, + ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, + ATTR_EFFECTS, ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_END_CURVATURE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_START_CURVATURE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, + ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; -use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; -use graphic_types::raster_types::{CPU, GPU, Image, Raster}; -use graphic_types::vector_types::gradient::{Gradient, GradientForm}; +use graphic_types::generator::GeneratorPattern; +use graphic_types::raster_effects::adjusted_raster; +use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::value_effects::{adjusted_color, adjusted_gradient, adjustments}; +use graphic_types::vector_types::gradient::{Gradient, GradientForm, wrapped_dial}; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint, TargetPath}; use graphic_types::vector_types::vector::misc::dvec2_to_point; use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::vector_types::vector::{ GeometryCache, Path, PlacedContours, contour_bezpaths, contours_bounding_box, contours_stroke_inclusive_bounding_box, edit_item_geometry, edit_list_row_geometry, no_paths, }; -use graphic_types::{Appearance, Artboard, Cover, Coverage, FillAndStroke, Graphic, Vector}; +use graphic_types::{Adjustment, Appearance, Artboard, BevelStyle, Cover, Coverage, EffectOperation, Effects, FillAndStroke, Generator, Graphic, Vector, fitted_paint}; use kurbo::{Affine, BezPath, Cap, Join, PathEl, Shape}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::raw::FontRef as SkrifaFontRef; use skrifa::{GlyphId, MetadataProvider}; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::hash::Hash; @@ -76,6 +80,13 @@ impl<'a, T> ItemRef<'a, T> { } } + pub(crate) fn attribute_any(self, key: &str) -> Option<&'a dyn std::any::Any> { + match self { + ItemRef::ListItem(list, index) => list.attribute_any(key, index), + ItemRef::Item(item) => item.attributes().get_any(key), + } + } + pub(crate) fn attribute_cloned_or(self, key: &str, fallback: A) -> A { match self { ItemRef::ListItem(list, index) => list.attribute_cloned_or(key, index, fallback), @@ -98,6 +109,37 @@ impl<'a, T> ItemRef<'a, T> { (self.attribute_cloned_or::(ATTR_OPACITY, 1.) * opacity_fill) as f32 } + /// How the item's opacity attributes and effects apply as it renders. + fn compositing(self, render_params: &RenderParams) -> ItemCompositing<'a> { + let opacity = self.attribute_cloned_or::(ATTR_OPACITY, 1.) as f32; + let fill = if render_params.for_mask { + 1. + } else { + self.attribute_cloned_or::(ATTR_OPACITY_FILL, 1.) as f32 + }; + + // Outline mode shows only geometry + let effects = self + .attribute::(ATTR_EFFECTS) + .filter(|effects| !effects.is_empty() && render_params.render_mode != RenderMode::Outline); + + let pixel_scale = render_params.viewport_zoom.unwrap_or(1.) * render_params.scale; + let pixel_scale = if pixel_scale.is_normal() && pixel_scale > 0. { pixel_scale } else { 1. }; + + match effects { + Some(effects) => ItemCompositing { + opacity, + effects: Some((effects, fill)), + pixel_scale, + }, + None => ItemCompositing { + opacity: opacity * fill, + effects: None, + pixel_scale, + }, + } + } + pub(crate) fn clone_item_attributes(self) -> core_types::list::ItemAttributeValues { match self { ItemRef::ListItem(list, index) => list.clone_item_attributes(index), @@ -163,13 +205,690 @@ impl<'a> ItemRef<'a, Vector> { } } -/// The color one paint item contributes, faded by its opacity attributes. +/// How an item composites: the alpha of the layer it draws through, and the effects its content renders through inside that layer. +struct ItemCompositing<'a> { + opacity: f32, + /// With the fill opacity, which the effects group applies to the content alone so the shadows and glows cast from it stay at full strength. + effects: Option<(&'a Effects, f32)>, + /// How many output pixels span a document unit, which SVG lighting measures the bevels' slopes across. + pixel_scale: f64, +} + +impl ItemCompositing<'_> { + /// How far the effects reach past the content, in document pixels. + fn reach(&self) -> f64 { + self.effects.map_or(0., |(effects, _)| effects.reach()) + } + + /// The adjustments at the start of the effects, which can be carried out on the content's own colors ahead of the rest. + /// A fill fades the content before the effects adjust it, so with one, only adjustments that keep alpha commute with it. + fn leading_adjustments(&self) -> Vec { + let Some((effects, fill)) = self.effects else { return Vec::new() }; + + effects + .operations() + .map_while(|operation| match operation { + EffectOperation::Adjustment(adjustment) if fill == 1. || adjustment.preserves_alpha() => Some(adjustment), + _ => None, + }) + .collect() + } + + /// The leading adjustments for the content to carry out on its own colors, and the compositing left for it, holding the effects after them in `remaining`. + fn split_leading_adjustments<'b>(&self, remaining: &'b mut Effects) -> (Vec, ItemCompositing<'b>) + where + Self: 'b, + { + let leading_adjustments = self.leading_adjustments(); + let Some((effects, _)) = self.effects.filter(|_| !leading_adjustments.is_empty()) else { + return (leading_adjustments, ItemCompositing { ..*self }); + }; + + *remaining = effects.after(leading_adjustments.len()); + (leading_adjustments, self.with_remaining_effects(remaining)) + } + + /// This compositing with only the `remaining` effects, those after the leading adjustments the content has taken. + fn with_remaining_effects<'b>(&self, remaining: &'b Effects) -> ItemCompositing<'b> { + let fill = self.effects.map_or(1., |(_, fill)| fill); + + match remaining.is_empty() { + true => ItemCompositing { + opacity: self.opacity * fill, + effects: None, + pixel_scale: self.pixel_scale, + }, + false => ItemCompositing { + opacity: self.opacity, + effects: Some((remaining, fill)), + pixel_scale: self.pixel_scale, + }, + } + } + + /// Draws the content `draw` draws through the effects group, given the content's bounds in the scene's space. Rendering a split scene calls `draw` again for each part. + /// `transform` maps the space the content's item is placed in, where the paint overlays' paints are placed too, into the scene's space. + fn draw_with_effects( + &self, + scene: &mut VelloSceneBuilder, + transform: DAffine2, + render_params: &RenderParams, + bounds: impl FnOnce() -> RenderBoundingBox, + mut draw: impl FnMut(&mut VelloSceneBuilder), + ) { + let Some((effects, fill)) = self.effects else { return draw(scene) }; + + let bounds = match bounds() { + RenderBoundingBox::Rectangle(bounds) => Some(bounds), + RenderBoundingBox::Infinite => None, + RenderBoundingBox::None => return draw(scene), + }; + + // A paint fitted to the content's bounds is placed in the scene's space they're measured in + let paint_params = render_params.for_pattern(); + let paints: Vec<(Graphic, DAffine2)> = effects + .operations() + .filter_map(|operation| match operation { + EffectOperation::PaintOverlay { paint, .. } => Some(paint), + _ => None, + }) + .map(|paint| match bounds.and_then(|bounds| fitted_paint(&paint, bounds)) { + Some(fitted) => (fitted, DAffine2::IDENTITY), + None => (paint, transform), + }) + .collect(); + let mut draw_paint = |index: usize, scene: &mut VelloSceneBuilder| { + if let Some((paint, transform)) = paints.get(index) { + paint.render_to_vello(scene, *transform, &paint_params); + } + }; + + let bounds = bounds.map(|[min, max]| kurbo::Rect::new(min.x, min.y, max.x, max.y)); + scene.draw_effects(effects, fill, bounds, &mut draw_paint, &mut draw); + } + + /// The opacity for content drawn without the effects group, as SVG draws content its filter can't apply to. + fn opacity_without_effects(&self) -> f32 { + self.opacity * self.effects.map_or(1., |(_, fill)| fill) + } + + /// A vector's `fill` and `stroke` paints with the leading adjustments carried out on their colors, if they're solid colors this is exact for, + /// sparing the content an effects group or an SVG filter that might not express them. `strokes` is whether the stroke is drawn at all. + fn fold_into_solid_paints(&self, fill: Option<&Graphic>, stroke: Option<&Graphic>, strokes: bool, for_mask: bool) -> Option { + // Antialiased edges are translucent, so an adjustment changing alpha would act differently on them than on the paint + let adjustments: Vec = self.leading_adjustments().into_iter().take_while(Adjustment::preserves_alpha).collect(); + let (effects, _) = self.effects.filter(|_| !adjustments.is_empty())?; + + // The color a solid paint draws, which is `None` for no paint, or no color at all for a paint that isn't solid + let solid_color = |paint: Option<&Graphic>| match paint { + None | Some(Graphic::None(_) | Graphic::NoneList(_)) => Some(None), + Some(Graphic::Color(item)) => Some(faded_paint_color(ItemRef::Item(item), for_mask)), + Some(Graphic::ColorList(list)) => Some(composite_paint_colors(list, for_mask)), + Some(_) => None, + }; + let fill_color = solid_color(fill)?; + let stroke_color = if strokes { solid_color(stroke)? } else { None }; + + // Where translucent paints overlap, the adjustment would act on their blend rather than on each + if let (Some(fill_color), Some(stroke_color)) = (fill_color, stroke_color) + && (fill_color.a() < 1. || stroke_color.a() < 1.) + { + return None; + } + + let adjusted = |color: Option| Some(Graphic::Color(Item::new_from_element(adjusted_color(color?, &adjustments)))); + Some(FoldedPaints { + fill: adjusted(fill_color), + stroke: adjusted(stroke_color), + remaining_effects: effects.after(adjustments.len()), + }) + } + + /// Writes the SVG filter carrying out the effects, if there are any to apply, given the content's bounds in the current user space. + /// Returns the reference for the `filter` attribute of a group wrapping the content, which also takes the item's opacity and blend mode. + fn svg_filter(&self, render: &mut SvgRender, render_params: &RenderParams, bounds: impl FnOnce() -> RenderBoundingBox) -> Option { + let (effects, fill) = self.effects?; + + // Effects change nothing visible about content without edges, like a color or gradient layer filling everything + let RenderBoundingBox::Rectangle(bounds) = bounds() else { return None }; + + // The paint overlays' paints that aren't solid colors, drawn as markup in the user space, where the content's bounds fit those without a placement + let paint_params = render_params.for_pattern(); + let paints: Vec = effects + .operations() + .filter_map(|operation| match operation { + EffectOperation::PaintOverlay { paint, .. } => Some(paint), + _ => None, + }) + .map(|paint| { + let paint = fitted_paint(&paint, bounds).unwrap_or(paint); + let mut paint_render = SvgRender::new(); + paint_render.transform = render.transform; + paint.render_svg(&mut paint_render, &paint_params); + format!("{}{}", paint_render.svg_defs, paint_render.svg.to_svg_string()) + }) + .collect(); + + let id = write_svg_effects_filter(&mut render.svg_defs, effects, fill, bounds, render.transform, self.pixel_scale, &paints)?; + Some(format!("url(#{id})")) + } +} + +/// A vector's solid paints with adjustments carried out on their colors, and the effects left after those adjustments. +struct FoldedPaints { + fill: Option, + stroke: Option, + remaining_effects: Effects, +} + +/// How far the effects of the items nested anywhere inside `graphic` reach past its bounds, in document pixels. +fn nested_effects_reach(graphic: &Graphic) -> f64 { + fn item_reach(item: &Item) -> f64 { + item.attribute::(ATTR_EFFECTS).map_or(0., Effects::reach) + } + fn list_reach(list: &List, nested: impl Fn(&T) -> f64) -> f64 { + (0..list.len()) + .map(|index| list.attribute::(ATTR_EFFECTS, index).map_or(0., Effects::reach) + list.element(index).map_or(0., &nested)) + .fold(0., f64::max) + } + + match graphic { + Graphic::None(_) | Graphic::NoneList(_) | Graphic::StrokeList(_) => 0., + Graphic::Graphic(item) => item_reach(item) + nested_effects_reach(item.element()), + Graphic::Vector(item) => item_reach(item), + Graphic::RasterCPU(item) => item_reach(item), + Graphic::RasterGPU(item) => item_reach(item), + Graphic::Color(item) => item_reach(item), + Graphic::Gradient(item) => item_reach(item), + Graphic::Generator(item) => item_reach(item), + Graphic::Text(item) => item_reach(item), + Graphic::GraphicList(list) => list_reach(list, nested_effects_reach), + Graphic::VectorList(list) => list_reach(list, |_| 0.), + Graphic::RasterCPUList(list) => list_reach(list, |_| 0.), + Graphic::RasterGPUList(list) => list_reach(list, |_| 0.), + Graphic::ColorList(list) => list_reach(list, |_| 0.), + Graphic::GradientList(list) => list_reach(list, |_| 0.), + Graphic::GeneratorList(list) => list_reach(list, |_| 0.), + Graphic::TextList(list) => list_reach(list, |_| 0.), + } +} + +/// Wraps an item's SVG `content` in a group applying its effects `filter`, with the item's opacity and blend mode, if there is a filter. +fn render_svg_through_filter(render: &mut SvgRender, filter: Option, opacity: f32, blend_mode: BlendMode, content: impl FnOnce(&mut SvgRender)) { + let Some(filter) = filter else { return content(render) }; + + render.parent_tag( + "g", + |attributes| { + attributes.push("filter", filter); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }, + content, + ); +} + +/// Builds the primitives of an SVG filter, naming each result so later primitives can take it as an input. +#[derive(Default)] +struct SvgFilterPrimitives { + markup: String, + count: usize, +} + +impl SvgFilterPrimitives { + /// Appends a primitive tag with `attributes` (and `children`, if any), returning the name of its result. + fn push(&mut self, tag: &str, attributes: &str, children: &str) -> String { + self.count += 1; + let result = format!("effect-{}", self.count); + + match children.is_empty() { + true => write!(self.markup, r#"<{tag} {attributes} result="{result}" />"#), + false => write!(self.markup, r#"<{tag} {attributes} result="{result}">{children}"#), + } + .unwrap(); + + result + } + + /// The sum of two premultiplied images, for composites whose terms SVG's other operators can't express. + fn add(&mut self, first: &str, second: &str) -> String { + self.push("feComposite", &format!(r#"in="{first}" in2="{second}" operator="arithmetic" k2="1" k3="1""#), "") + } + + /// The coverage of the content's silhouette (or of the area outside it, if `inverse`), cast by `offset`, grown by `spread` and softened by `blur`, all in user units. + fn cast(&mut self, full_content: &str, offset: DVec2, blur: f64, spread: f64, inverse: bool) -> String { + let mut coverage = match offset == DVec2::ZERO { + true => full_content.to_string(), + false => self.push("feOffset", &format!(r#"in="{full_content}" dx="{}" dy="{}""#, offset.x, offset.y), ""), + }; + if inverse { + let opaque = self.push("feFlood", r#"flood-opacity="1""#, ""); + coverage = self.push("feComposite", &format!(r#"in="{opaque}" in2="{coverage}" operator="out""#), ""); + } + if spread >= 0.01 { + coverage = self.push("feMorphology", &format!(r#"in="{coverage}" operator="dilate" radius="{spread}""#), ""); + } + if blur >= 0.01 { + coverage = self.push("feGaussianBlur", &format!(r#"in="{coverage}" stdDeviation="{blur}""#), ""); + } + coverage + } + + /// The coverage of the band reaching `width` user units from the silhouette's edge, outward or (if `inward`) inward. + /// SVG measures no straight-line distances, so a blur thresholded where it falls to that of a straight edge's at the width rounds the corners instead. + fn edge_band(&mut self, full_content: &str, width: f64, inward: bool) -> String { + // At this distance past a straight edge, a Gaussian of half the width falls to Φ(-2), dropping by φ(2) / σ per unit + const THRESHOLD: f64 = 0.022_750_13; + const FALLOFF: f64 = 0.053_990_97; + + let silhouette = self.cast(full_content, DVec2::ZERO, width / 2., 0., inward); + let slope = width / 2. / FALLOFF; + let intercept = 0.5 - slope * THRESHOLD; + self.push( + "feComponentTransfer", + &format!(r#"in="{silhouette}""#), + &format!(r#""#), + ) + } + + /// A bevel's height in the alpha channel, shaped by the `style` over a slope `size` user units wide. + /// SVG measures no distances from edges, so the silhouette's edge is moved to the slope's middle and blurred across the slope's width instead. + fn bevel_height(&mut self, full_content: &str, size: f64, style: BevelStyle) -> String { + let ramp = |primitives: &mut Self, operator: Option<&str>, width: f64| { + let silhouette = match operator { + Some(operator) => primitives.push("feMorphology", &format!(r#"in="{full_content}" operator="{operator}" radius="{}""#, width / 2.), ""), + None => full_content.to_string(), + }; + primitives.push("feGaussianBlur", &format!(r#"in="{silhouette}" stdDeviation="{}""#, width / 4.), "") + }; + + match style { + BevelStyle::InnerBevel => ramp(self, Some("erode"), size), + BevelStyle::OuterBevel => ramp(self, Some("dilate"), size), + BevelStyle::Emboss => ramp(self, None, size), + // Ramps up into the content and away from it, each half the slope wide, meeting low at the edge + BevelStyle::PillowEmboss => { + let rising_inward = ramp(self, Some("erode"), size / 2.); + let falling_outward = ramp(self, Some("dilate"), size / 2.); + self.push( + "feComposite", + &format!(r#"in="{rising_inward}" in2="{falling_outward}" operator="arithmetic" k2="1" k3="-1" k4="1""#), + "", + ) + } + } + } + + /// How far the `light` brightens the bevel's surface past a flat surface's brightness, or if `shadow`, how far it falls short, as coverage in the alpha channel. + /// `slope` scales the height's gradient per output pixel into the surface's steepness, and is negative for a bevel pressed into the surface. + fn bevel_shading(&mut self, height: &str, slope: f64, light: glam::DVec3, shadow: bool) -> String { + let azimuth = light.y.atan2(light.x).to_degrees(); + let elevation = light.z.clamp(-1., 1.).asin().to_degrees(); + // SVG lighting's Sobel kernel measures twice the gradient + let surface_scale = slope / 2.; + let lit = self.push( + "feDiffuseLighting", + &format!(r#"in="{height}" surfaceScale="{surface_scale}" diffuseConstant="1" lighting-color="white""#), + &format!(r#""#), + ); + + // The lit brightness in red becomes coverage in alpha, past the flat brightness for a highlight or short of it for a shadow + let flat = light.z; + let (scale, offset) = match shadow { + true => (-1. / flat.max(1e-4), 1.), + false => (1. / (1. - flat).max(1e-4), -flat / (1. - flat).max(1e-4)), + }; + self.push( + "feColorMatrix", + &format!(r#"in="{lit}" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 {scale} 0 0 0 {offset}""#), + "", + ) + } + + /// A paint's markup, drawn in the user space, as an image filling the `region` of it from its minimum to its maximum corner. + /// Filters take outside graphics only as images, which an embedded SVG document keeps portable to viewers that can't reference elements. + fn paint_image(&mut self, markup: &str, [min, max]: [DVec2; 2]) -> String { + use base64::Engine; + + let size = max - min; + let document = format!( + r#"{markup}"#, + min.x, min.y, size.x, size.y, size.x, size.y + ); + let href = format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(document)); + + let placement = format!(r#"x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="none""#, min.x, min.y, size.x, size.y); + self.push("feImage", &format!(r#"href="{href}" {placement}"#), "") + } + + /// The image with the paint mixed over its colors by the blend mode, keeping its alpha. + fn overlay(&mut self, image: &str, paint: &str, blend_mode: BlendMode) -> String { + // SVG has no counterpart for some blend modes, which paint normally instead + let mode = blend_mode.to_svg_style_name().unwrap_or("normal"); + let blended = self.push("feBlend", &format!(r#"in="{paint}" in2="{image}" mode="{mode}""#), ""); + self.push("feComposite", &format!(r#"in="{blended}" in2="{image}" operator="atop""#), "") + } + + /// The coverage tinted `color`. + fn tint(&mut self, coverage: &str, color: Color) -> String { + let flood = self.push( + "feFlood", + &format!(r##"flood-color="#{}" flood-opacity="{}""##, SRGBA8::from(color.with_alpha(1.)).to_rgb_hex(), color.a()), + "", + ); + self.push("feComposite", &format!(r#"in="{flood}" in2="{coverage}" operator="in""#), "") + } + + /// Stacks the coverage, tinted `color`, over the shadows and glows already `beneath` the content. + fn shade_beneath(&mut self, beneath: Option, coverage: &str, color: Color) -> String { + let tinted = self.tint(coverage, color); + match beneath { + Some(beneath) => self.push("feComposite", &format!(r#"in="{tinted}" in2="{beneath}" operator="over""#), ""), + None => tinted, + } + } + + /// Places the coverage, tinted `color`, over both the full-strength content and the faded fill, within the content so it stays visible over a faded fill. + fn shade_within(&mut self, full_content: &mut String, filled: &mut Option, coverage: &str, color: Color) { + let tinted = self.tint(coverage, color); + let within = self.push("feComposite", &format!(r#"in="{tinted}" in2="{full_content}" operator="in""#), ""); + + let mut over = |image: &str| { + let uncovered = self.push("feComposite", &format!(r#"in="{image}" in2="{tinted}" operator="out""#), ""); + self.add(&within, &uncovered) + }; + *filled = filled.as_deref().map(&mut over); + *full_content = over(full_content); + } + + /// The image with the shadows and glows `beneath` it, knocked out by the full-strength content so a faded fill doesn't reveal them. + fn composite_beneath(&mut self, image: &str, beneath: &str, full_content: &str) -> String { + let knocked_out = self.push("feComposite", &format!(r#"in="{beneath}" in2="{full_content}" operator="out""#), ""); + self.add(image, &knocked_out) + } + + /// The image with `adjustment` applied where SVG's color primitives can express it, or left as it is where they can't. + fn adjust(&mut self, image: &str, adjustment: &graphic_types::Adjustment) -> String { + use graphic_types::Adjustment; + use graphic_types::adjustment::DesaturateMethod; + + // Enough entries that interpolating between them strays less than a level from the curve + const TABLE_SIZE: usize = 256; + + let table = |values: &[f32]| values.iter().map(|value| format!("{:.4}", value.clamp(0., 1.))).collect::>().join(" "); + let functions = |kind: &str, [red, green, blue]: [&[f32]; 3]| { + [("R", red), ("G", green), ("B", blue)] + .map(|(channel, values)| format!(r#""#, table(values))) + .concat() + }; + let in_gamma = |input: &str| format!(r#"in="{input}" color-interpolation-filters="sRGB""#); + // A matrix whose three color rows each hold `weights` (red, green, blue, constant), leaving alpha as it is + let matrix = |rows: [[f32; 4]; 3]| { + let row = |[red, green, blue, constant]: [f32; 4]| format!("{red} {green} {blue} 0 {constant}"); + format!(r#"type="matrix" values="{} {} {} 0 0 0 1 0""#, row(rows[0]), row(rows[1]), row(rows[2])) + }; + + if let Some([red, green, blue, mut alpha]) = adjustment.channel_curves(TABLE_SIZE) { + // Fully transparent pixels keep no color to adjust, so they stay empty as on the GPU + alpha[0] = 0.; + let curves = functions("table", [&red, &green, &blue]) + &format!(r#""#, table(&alpha)); + return self.push("feComponentTransfer", &in_gamma(image), &curves); + } + + match adjustment { + Adjustment::Threshold { min_luminance, max_luminance } => { + // The same fixed-point Rec. 601 luma the kernel compares, then white where it falls within the range + let weight = [4915., 9667., 1802., 0.].map(|weight| weight / 16384.); + let luma = self.push("feColorMatrix", &format!("{} {}", in_gamma(image), matrix([weight; 3])), ""); + let (min, max) = ((min_luminance / 100.) as f32, (max_luminance / 100.) as f32); + let steps: Vec = (0..TABLE_SIZE) + .map(|index| index as f32 / TABLE_SIZE as f32) + .map(|luma| if luma >= min && luma <= max { 1. } else { 0. }) + .collect(); + self.push("feComponentTransfer", &in_gamma(&luma), &functions("discrete", [&steps, &steps, &steps])) + } + Adjustment::GradientMap { gradient, reverse } => { + // The classic luma picks each color's position along the gradient, whose colors the tables hold + let luma = self.push("feColorMatrix", &format!("{} {}", in_gamma(image), matrix([[0.3, 0.59, 0.11, 0.]; 3])), ""); + let evaluator = gradient.element().evaluator(GradientSettings::from_item_attributes(gradient)); + let colors: Vec<[f32; 4]> = (0..TABLE_SIZE) + .map(|index| index as f64 / (TABLE_SIZE - 1) as f64) + .map(|position| evaluator.evaluate(if *reverse { 1. - position } else { position }).to_gamma_srgb_channels()) + .collect(); + let channel = |index: usize| colors.iter().map(|color| color[index]).collect::>(); + self.push("feComponentTransfer", &in_gamma(&luma), &functions("table", [&channel(0), &channel(1), &channel(2)])) + } + Adjustment::ChannelMixer(settings) => { + // Weights are 10-bit fixed point truncated toward zero, as the kernel takes them + let row = |row: graphic_types::adjustment::ChannelMixerRow| [row.red, row.green, row.blue, row.constant].map(|percent| (percent as f32 * 1024. / 100.).trunc() / 1024.); + let rows = match settings.monochrome { + true => [row(settings.monochrome_mix); 3], + false => [row(settings.red), row(settings.green), row(settings.blue)], + }; + self.push("feColorMatrix", &format!("{} {}", in_gamma(image), matrix(rows)), "") + } + Adjustment::Desaturate { method } => { + let (weights, space) = match method { + DesaturateMethod::LuminanceRec709 => ([0.2126, 0.7152, 0.0722, 0.], "linearRGB"), + DesaturateMethod::ChannelsAverage => ([1. / 3., 1. / 3., 1. / 3., 0.], "linearRGB"), + DesaturateMethod::LumaRec709 => ([0.2126, 0.7152, 0.0722, 0.], "sRGB"), + DesaturateMethod::LumaRec601 => ([0.299, 0.587, 0.114, 0.], "sRGB"), + // The rest take the extremes of the channels or a perceptual lightness, which a matrix can't express + _ => return image.to_string(), + }; + self.push("feColorMatrix", &format!(r#"in="{image}" color-interpolation-filters="{space}" {}"#, matrix([weights; 3])), "") + } + // TODO: Express the other cross-channel adjustments, which no SVG primitive covers exactly + _ => image.to_string(), + } + } +} + +/// Writes an SVG filter into `svg_defs` that carries out `effects` like the GPU passes do, returning its ID, or `None` if the user space is degenerate. +/// `bounds` is the content's extent in the filtered group's user space, which `user_to_document` maps into the document space the effects' lengths are measured in. +/// `pixel_scale` is how many output pixels span a document unit, and `paints` holds the markup of the paint overlays' paints that aren't solid colors, in order. +fn write_svg_effects_filter(svg_defs: &mut String, effects: &Effects, fill: f32, bounds: [DVec2; 2], user_to_document: DAffine2, pixel_scale: f64, paints: &[String]) -> Option { + use graphic_types::effects::{bevel_light, outline_extents}; + use graphic_types::{BevelDirection, EffectOperation, GlowSource}; + + let determinant = user_to_document.matrix2.determinant(); + if !determinant.is_normal() { + return None; + } + let document_to_user = user_to_document.matrix2.inverse(); + let length_scale = determinant.abs().sqrt().recip(); + + // The filter covers how far the effects reach past the content + let reach = effects.reach() * length_scale; + let region = [bounds[0] - reach, bounds[1] + reach]; + + let mut primitives = SvgFilterPrimitives::default(); + let mut paints = paints.iter(); + + // The content at full strength, which casts every silhouette, and the content faded by the fill, which shows in the result + let mut full = "SourceGraphic".to_string(); + let mut filled = (fill < 1.).then(|| primitives.push("feComponentTransfer", r#"in="SourceGraphic""#, &format!(r#""#))); + // The shadows and glows stacked beneath the content + let mut beneath: Option = None; + + for effect in effects.operations() { + match effect { + EffectOperation::Blur { radius, box_blur, gamma } => { + let radius = radius * length_scale; + if radius < 0.01 { + continue; + } + + // What lies beneath blurs together with the content + if let Some(beneath) = beneath.take() { + filled = filled.map(|filled| primitives.composite_beneath(&filled, &beneath, &full)); + full = primitives.composite_beneath(&full, &beneath, &full); + } + + // SVG has no box blur, so a box becomes the Gaussian of the same variance + let sigma = if box_blur { (radius * (radius + 1.) / 3.).sqrt() } else { radius }; + let space = if gamma { "sRGB" } else { "linearRGB" }; + let attributes = |input: &str| format!(r#"in="{input}" stdDeviation="{sigma}" color-interpolation-filters="{space}""#); + + filled = filled.map(|filled| primitives.push("feGaussianBlur", &attributes(&filled), "")); + full = primitives.push("feGaussianBlur", &attributes(&full), ""); + } + EffectOperation::DropShadow { color, offset, blur, spread } => { + let coverage = primitives.cast(&full, document_to_user * offset, blur * length_scale, spread * length_scale, false); + beneath = Some(primitives.shade_beneath(beneath, &coverage, color)); + } + EffectOperation::OuterGlow { color, blur, spread } => { + let coverage = primitives.cast(&full, DVec2::ZERO, blur * length_scale, spread * length_scale, false); + beneath = Some(primitives.shade_beneath(beneath, &coverage, color)); + } + EffectOperation::InnerShadow { color, offset, blur, spread } => { + let coverage = primitives.cast(&full, document_to_user * offset, blur * length_scale, spread * length_scale, true); + primitives.shade_within(&mut full, &mut filled, &coverage, color); + } + EffectOperation::InnerGlow { color, blur, choke, source } => { + let mut coverage = primitives.cast(&full, DVec2::ZERO, blur * length_scale, choke * length_scale, true); + if source == GlowSource::Center { + coverage = primitives.push("feComponentTransfer", &format!(r#"in="{coverage}""#), r#""#); + } + primitives.shade_within(&mut full, &mut filled, &coverage, color); + } + EffectOperation::Satin { color, offset, blur, invert } => { + let offset = document_to_user * offset; + let forward = primitives.cast(&full, offset, blur * length_scale, 0., false); + let backward = primitives.cast(&full, -offset, blur * length_scale, 0., false); + + // The difference either way, each clamped at zero, adds up to its absolute value + let difference = |primitives: &mut SvgFilterPrimitives, first: &str, second: &str| { + primitives.push("feComposite", &format!(r#"in="{first}" in2="{second}" operator="arithmetic" k2="1" k3="-1""#), "") + }; + let (ahead, behind) = (difference(&mut primitives, &forward, &backward), difference(&mut primitives, &backward, &forward)); + let mut coverage = primitives.add(&ahead, &behind); + if invert { + coverage = primitives.push("feComponentTransfer", &format!(r#"in="{coverage}""#), r#""#); + } + primitives.shade_within(&mut full, &mut filled, &coverage, color); + } + EffectOperation::Outline { color, weight, align } => { + let (outside, inside) = outline_extents(weight * length_scale, align); + if outside >= 0.01 { + let coverage = primitives.edge_band(&full, outside, false); + beneath = Some(primitives.shade_beneath(beneath, &coverage, color)); + } + if inside >= 0.01 { + let coverage = primitives.edge_band(&full, inside, true); + primitives.shade_within(&mut full, &mut filled, &coverage, color); + } + } + EffectOperation::BevelAndEmboss { + style, + depth, + direction, + size, + soften, + angle, + altitude, + highlight_color, + shadow_color, + } => { + let (size, soften) = (size * length_scale, soften * length_scale); + if size < 0.01 { + continue; + } + + let height = primitives.bevel_height(&full, size, style); + let slope = depth / 100. * size / length_scale * pixel_scale * if direction == BevelDirection::Down { -1. } else { 1. }; + let light = bevel_light(angle, altitude, document_to_user); + for (shadow, color) in [(false, highlight_color), (true, shadow_color)] { + let mut coverage = primitives.bevel_shading(&height, slope, light, shadow); + if soften >= 0.01 { + coverage = primitives.push("feGaussianBlur", &format!(r#"in="{coverage}" stdDeviation="{soften}""#), ""); + } + + beneath = Some(primitives.shade_beneath(beneath, &coverage, color)); + primitives.shade_within(&mut full, &mut filled, &coverage, color); + } + } + EffectOperation::PaintOverlay { blend_mode, opacity, .. } => { + let Some(markup) = paints.next() else { continue }; + + let mut paint = primitives.paint_image(markup, region); + if opacity < 100. { + let slope = opacity / 100.; + paint = primitives.push("feComponentTransfer", &format!(r#"in="{paint}""#), &format!(r#""#)); + } + + filled = filled.map(|filled| primitives.overlay(&filled, &paint, blend_mode)); + full = primitives.overlay(&full, &paint, blend_mode); + } + EffectOperation::Adjustment(ref adjustment) => { + filled = filled.map(|filled| primitives.adjust(&filled, adjustment)); + full = primitives.adjust(&full, adjustment); + } + } + } + + // The filter's result is its last primitive's, so finish with the image shown + let shown = filled.unwrap_or_else(|| full.clone()); + match beneath { + Some(beneath) => { + primitives.composite_beneath(&shown, &beneath, &full); + } + None => { + primitives.push("feOffset", &format!(r#"in="{shown}""#), ""); + } + } + + let [min, max] = region; + let id = format!("effects-{}", generate_uuid()); + write!( + svg_defs, + r#"{}"#, + min.x, + min.y, + max.x - min.x, + max.y - min.y, + primitives.markup + ) + .unwrap(); + + Some(id) +} + +/// Grows a layer's bounds in the scene's space by a length in document pixels. +fn inflate_bounds(bounds: [DVec2; 2], amount: f64) -> kurbo::Rect { + kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y).inflate(amount, amount) +} + +fn rect_bounds(rect: kurbo::Rect) -> RenderBoundingBox { + RenderBoundingBox::Rectangle([DVec2::new(rect.x0, rect.y0), DVec2::new(rect.x1, rect.y1)]) +} + +/// The color one paint item contributes, with the adjustments among its effects carried out and faded by its opacity attributes. pub(crate) fn faded_paint_color(item: ItemRef<'_, Color>, for_mask: bool) -> Option { - let color = item.element()?; + let mut color = *item.element()?; + if let Some(effects) = item.attribute::(ATTR_EFFECTS) { + color = adjusted_color(color, &adjustments(effects)); + } Some(color.with_alpha(color.a() * item.paint_opacity(for_mask))) } +/// The ramp one gradient paint item contributes, with the adjustments among its effects carried out along it, and the settings to read it with. +pub(crate) fn painted_gradient(item: ItemRef<'_, Gradient>) -> Option<(Cow<'_, Gradient>, GradientSettings)> { + let gradient = item.element()?; + let settings = gradient_settings_from_item(item); + + let adjusted = item.attribute::(ATTR_EFFECTS).and_then(|effects| adjusted_gradient(gradient, settings, &adjustments(effects))); + Some(match adjusted { + Some((gradient, settings)) => (Cow::Owned(gradient), settings), + None => (Cow::Borrowed(gradient), settings), + }) +} + /// Composites one paint color over the stack beneath it, mixing by the blend mode and then source-over in straight alpha. fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Color { let (over_alpha, under_alpha) = (over.a(), under.a()); @@ -249,8 +968,10 @@ impl MaskType { pub struct SvgRender { pub svg: Vec, pub svg_defs: String, + /// Maps the user space of the content being rendered into the document's space, where the lengths of effects are measured. pub transform: DAffine2, - pub image_data: HashMap>, u64>, + /// The images drawn to canvases rather than embedded, by the canvas ID their placeholders name. + pub image_data: HashMap, indent: usize, } @@ -344,7 +1065,7 @@ impl SvgRender { pub struct SvgRenderOutput { pub svg: String, pub svg_defs: String, - pub image_data: HashMap>, u64>, + pub image_data: HashMap, } impl From<&SvgRenderOutput> for SvgRender { @@ -560,22 +1281,51 @@ pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } -/// Maps a gradient's `transform` into the frame handed to the renderer: radial keeps the full matrix (so a +/// How a gradient's contours are laid out, decided by its form and curvature dials. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum GradientShape { + /// Both ends are lines. + Linear, + /// The start is a point at the center of the end circle. + Radial, + /// Any other pair of dialed end contours, with the pencil of circles they span filling in between. + Pencil(PencilParameters), +} + +impl GradientShape { + pub(crate) fn of(item: ItemRef<'_, Gradient>) -> Self { + let preset = item.attribute_cloned_or_default::(ATTR_GRADIENT_FORM).dials(); + let dial = |key: &str, preset: f64| wrapped_dial(item.attribute_cloned_or(key, preset), preset); + let dials = [dial(ATTR_GRADIENT_START_CURVATURE, preset[0]), dial(ATTR_GRADIENT_END_CURVATURE, preset[1])]; + + match dials { + LINEAR_DIALS => Self::Linear, + RADIAL_DIALS => Self::Radial, + [start, end] => Self::Pencil(PencilParameters::new(start, end)), + } + } + + /// Whether its position can fall before the start, which only a radial gradient's can't since its distance from the center never goes below zero. + pub(crate) fn reaches_before_start(&self) -> bool { + !matches!(self, Self::Radial) + } +} + +/// Maps a gradient's `transform` into the frame handed to the renderer: most shapes keep the full matrix (so a /// non-uniform transform makes an ellipse), while linear is reduced to the equivalent non-sheared gradient line (the /// axis projected onto the band normal) so the iso-color bands keep following a sheared transform, which Vello can /// represent since it stores only two endpoints. -pub(crate) fn gradient_placement(transform: DAffine2, gradient_form: GradientForm) -> DAffine2 { - match gradient_form { - GradientForm::Radial => transform, - GradientForm::Linear => { - let axis = transform.matrix2.x_axis; - let band_normal = transform.matrix2.y_axis.perp(); - let line = axis.try_project_onto(band_normal).unwrap_or(axis); - DAffine2 { - matrix2: DMat2::from_cols(line, line.perp()), - translation: transform.translation, - } - } +pub(crate) fn gradient_placement(transform: DAffine2, shape: GradientShape) -> DAffine2 { + if shape != GradientShape::Linear { + return transform; + } + + let axis = transform.matrix2.x_axis; + let band_normal = transform.matrix2.y_axis.perp(); + let line = axis.try_project_onto(band_normal).unwrap_or(axis); + DAffine2 { + matrix2: DMat2::from_cols(line, line.perp()), + translation: transform.translation, } } @@ -585,9 +1335,9 @@ type GradientSamples = Vec<(f64, Color, Option)>; /// The gradient's renderable samples, bracketed for the `Clear` spread (which neither SVG nor Vello supports natively) by transparent guard stops. /// /// The guards share the unit range's end offsets, ordered so the pad extension paints transparency outward while the later -/// of each pair of coincident stops cuts the paint off exactly at the range's boundaries. A radial gradient needs no guard -/// at its start, since its sampling distance never goes below the center. -pub(crate) fn spread_adjusted_samples(gradient: &Gradient, settings: GradientSettings, gradient_form: GradientForm) -> GradientSamples { +/// of each pair of coincident stops cuts the paint off exactly at the range's boundaries. The guard at the start is left out +/// for a gradient whose position can't reach before it. +pub(crate) fn spread_adjusted_samples(gradient: &Gradient, settings: GradientSettings, reaches_before_start: bool) -> GradientSamples { let samples = gradient.interpolated_samples(settings); if settings.spread != GradientSpread::Clear { return samples; @@ -602,7 +1352,7 @@ pub(crate) fn spread_adjusted_samples(gradient: &Gradient, settings: GradientSet let mut adjusted = Vec::with_capacity(samples.len() + 4); // Lead with the transparent guard, then anchor the visible range's start color - if gradient_form == GradientForm::Linear { + if reaches_before_start { adjusted.push((0., Color::TRANSPARENT, None)); } if needs_start_anchor { @@ -636,14 +1386,15 @@ fn peniko_color_stops(samples: &[(f64, Color, Option)]) -> peniko::ColorSto } /// The gradient in its own space before the brush transform places it: the line from the origin along +X to 1 for linear, or the unit circle about the origin for radial. -fn unit_gradient_kind(gradient_form: GradientForm) -> peniko::GradientKind { - match gradient_form { - GradientForm::Linear => peniko::LinearGradientPosition { +/// A pencil gradient's contours come from its own geometry, so it takes the linear kind, which goes unused. +fn unit_gradient_kind(shape: GradientShape) -> peniko::GradientKind { + match shape { + GradientShape::Linear | GradientShape::Pencil(_) => peniko::LinearGradientPosition { start: kurbo::Point::ORIGIN, end: kurbo::Point::new(1., 0.), } .into(), - GradientForm::Radial => peniko::RadialGradientPosition { + GradientShape::Radial => peniko::RadialGradientPosition { start_center: kurbo::Point::ORIGIN, start_radius: 0., end_center: kurbo::Point::ORIGIN, @@ -653,6 +1404,60 @@ fn unit_gradient_kind(gradient_form: GradientForm) -> peniko::GradientKind { } } +/// The SVG element defining a gradient of this shape in the unit gradient's own space, with `attributes` (like its spread +/// and transform) added. SVG has no dialed contours, so a pencil gradient is drawn as the closest gradient SVG has. +pub(crate) fn svg_gradient_definition(shape: GradientShape, id: u64, attributes: &str, stops: &str) -> String { + let approximation = match shape { + GradientShape::Linear => SvgApproximation::Linear, + GradientShape::Radial => SvgApproximation::Radial { + start_center: 0., + start_radius: 0., + end_center: 0., + end_radius: 1., + }, + GradientShape::Pencil(parameters) => parameters.svg_approximation(), + }; + + match approximation { + SvgApproximation::Linear => format!(r#"{stops}"#), + SvgApproximation::Radial { + start_center, + start_radius, + end_center, + end_radius, + } => { + // SVG's default focal circle is the point at the center, so it's written only where it differs + let focal = if start_center == end_center && start_radius == 0. { + String::new() + } else { + format!(r#" fx="{start_center}" fy="0" fr="{start_radius}""#) + }; + format!(r#"{stops}"#) + } + } +} + +/// The scene brush painting a gradient of this shape with these samples, in the unit gradient's own space. +fn gradient_scene_brush(shape: GradientShape, samples: &[(f64, Color, Option)], spread: GradientSpread) -> SceneBrush { + let ramp = peniko::Gradient { + kind: unit_gradient_kind(shape), + extend: peniko_extend(spread), + stops: peniko_color_stops(samples), + // Straight alpha, keeping parity with the SVG renderer's stop interpolation + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }; + + match shape { + GradientShape::Pencil(parameters) => vello_gpu::PencilGradient { + ramp, + geometry: parameters.to_vello(), + } + .into(), + GradientShape::Linear | GradientShape::Radial => peniko::Brush::Gradient(ramp).into(), + } +} + /// The peniko extend mode for a spread; `Clear` rides pad, with the transparent guard stops from `spread_adjusted_samples` doing the clearing. fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { match gradient_spread { @@ -664,14 +1469,13 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { /// The Vello brush for one gradient item, paired with its placement transform. /// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains. -fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> { - let stops = gradient_item.element()?; +fn create_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(SceneBrush, DAffine2)> { + let (stops, settings) = painted_gradient(gradient_item)?; - let gradient_form: GradientForm = gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let shape = GradientShape::of(gradient_item); let gradient_transform: DAffine2 = gradient_item.attribute_cloned_or_default(ATTR_TRANSFORM); - let settings = gradient_settings_from_item(gradient_item); - let mut samples = spread_adjusted_samples(stops, settings, gradient_form); + let mut samples = spread_adjusted_samples(&stops, settings, shape.reaches_before_start()); let paint_opacity = gradient_item.paint_opacity(for_mask); if paint_opacity < 1. { @@ -685,19 +1489,9 @@ fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied } } - let peniko_stops = peniko_color_stops(&samples); - // The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse - let gradient_to_device = gradient_placement(multiplied_transform * gradient_transform, gradient_form); - - let brush = peniko::Brush::Gradient(peniko::Gradient { - kind: unit_gradient_kind(gradient_form), - extend: peniko_extend(settings.spread), - stops: peniko_stops, - // Straight alpha, keeping parity with the SVG renderer's stop interpolation - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, - ..Default::default() - }); + let gradient_to_device = gradient_placement(multiplied_transform * gradient_transform, shape); + let brush = gradient_scene_brush(shape, &samples, settings.spread); Some((brush, gradient_to_device)) } @@ -727,6 +1521,8 @@ pub struct RenderMetadata { /// Per-layer `ATTR_APPEARANCE` item attribute, exposed so message handlers can read it. #[cfg_attr(feature = "serde", serde(skip))] pub appearance_attributes: HashMap>, + /// Per-layer distance its effects paint past its content, in document pixels, for layers with effects. + pub effects_reach: HashMap, pub backgrounds: Vec, } @@ -752,6 +1548,7 @@ impl RenderMetadata { clip_targets, vector_data, appearance_attributes, + effects_reach, backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); @@ -764,6 +1561,7 @@ impl RenderMetadata { clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); appearance_attributes.extend(other.appearance_attributes.iter().map(|(id, data)| (*id, data.clone()))); + effects_reach.extend(other.effects_reach.iter()); // TODO: Find a better non O(n^2) way to merge backgrounds for background in &other.backgrounds { @@ -819,14 +1617,13 @@ fn render_graphic_item_svg(item: ItemRef<'_, Graphic>, next_clips: bool, mask_st let Some(element) = item.element() else { return }; let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let blend_mode: 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.); // This item's declared appearance (if any) cascades to descendants lacking their own let child_render_params = item .attribute::(ATTR_APPEARANCE) .and_then(Appearance::declared) .map(|appearance| render_params.for_child_item(appearance)); let render_params = child_render_params.as_ref().unwrap_or(render_params); + let compositing = item.compositing(render_params); let matrix = format_transform_matrix(transform); let mut masked_by = None; @@ -857,24 +1654,47 @@ fn render_graphic_item_svg(item: ItemRef<'_, Graphic>, next_clips: bool, mask_st } let render_item = |render: &mut SvgRender| { - render.parent_tag( - "g", - |attributes| { - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix.clone()); - } + // The filter also covers how far the effects nested inside reach past the content, measured in this user space + let document_scale = render.transform.matrix2.determinant().abs().sqrt(); + let filter = compositing.svg_filter(render, render_params, || match element.bounding_box(transform, true) { + RenderBoundingBox::Rectangle([min, max]) => { + let nested_reach = nested_effects_reach(element) / document_scale; + RenderBoundingBox::Rectangle([min - nested_reach, max + nested_reach]) + } + bounds => bounds, + }); + let filtered = filter.is_some(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } + render_svg_through_filter(render, filter, compositing.opacity, blend_mode, |render| { + render.parent_tag( + "g", + |attributes| { + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix.clone()); + } - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - }, - |render| element.render_svg(render, render_params), - ); + // A filter's wrapping group takes the opacity and blend mode instead + if filtered { + return; + } + + let opacity = compositing.opacity_without_effects(); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }, + |render| { + let parent_transform = render.transform; + render.transform = parent_transform * transform; + element.render_svg(render, render_params); + render.transform = parent_transform; + }, + ); + }); }; // The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own @@ -892,61 +1712,70 @@ fn render_graphic_item_to_vello<'a>( next_clips: bool, mask_element_and_transform: &mut Option<(&'a Graphic, DAffine2)>, scene: &mut VelloSceneBuilder, - transform: DAffine2, + parent_transform: DAffine2, render_params: &RenderParams, ) { let Some(element) = item.element() else { return }; let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - let transform = transform * item_transform; + let transform = parent_transform * item_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.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); // This item's declared appearance (if any) cascades to descendants lacking their own let child_render_params = item .attribute::(ATTR_APPEARANCE) .and_then(Appearance::declared) .map(|appearance| render_params.for_child_item(appearance)); let render_params = child_render_params.as_ref().unwrap_or(render_params); - - let mut layer = false; + let compositing = item.compositing(render_params); let blend_mode = match render_params.render_mode { RenderMode::Outline => peniko::Mix::Normal, _ => blend_mode_attr.to_peniko(), }; - let mut bounds = RenderBoundingBox::None; + let needs_layer = compositing.opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()); + + // The bounds are only worth finding for the layers or effects group that need them + let bounds = if needs_layer || mask_element_and_transform.is_some() || compositing.effects.is_some() { + element.bounding_box(transform, true) + } else { + RenderBoundingBox::None + }; + let layer_rect = match bounds { + RenderBoundingBox::Rectangle(bounds) => Some(inflate_bounds(bounds, compositing.reach())), + _ => None, + }; - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) { - bounds = element.bounding_box(transform, true); + let layer = if needs_layer && let Some(rect) = layer_rect { + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), + compositing.opacity, + kurbo::Affine::IDENTITY, + &rect, + ); + true + } else { + false + }; - if let RenderBoundingBox::Rectangle(bounds) = bounds { - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), - opacity, - kurbo::Affine::IDENTITY, - &kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), - ); - layer = true; - } - } + // The effects group also covers how far the effects nested inside reach past the content + let effects_bounds = || match bounds { + RenderBoundingBox::Rectangle(bounds) => rect_bounds(inflate_bounds(bounds, nested_effects_reach(element))), + bounds => bounds, + }; + let draw_content = |scene: &mut VelloSceneBuilder| { + compositing.draw_with_effects(scene, parent_transform, render_params, effects_bounds, |scene| element.render_to_vello(scene, transform, render_params)); + }; if next_clips && mask_element_and_transform.is_none() { *mask_element_and_transform = Some((element, transform)); - element.render_to_vello(scene, transform, render_params); + draw_content(scene); } else if let Some((mask_element, transform_mask)) = *mask_element_and_transform { if !next_clips { *mask_element_and_transform = None; } - if !layer { - bounds = element.bounding_box(transform, true); - } - - if let RenderBoundingBox::Rectangle(bounds) = bounds { - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + if let Some(rect) = layer_rect { scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); mask_element.render_to_vello(scene, transform_mask, &render_params.for_clipper()); scene.push_layer( @@ -958,14 +1787,14 @@ fn render_graphic_item_to_vello<'a>( ); } - element.render_to_vello(scene, transform, render_params); + draw_content(scene); - if matches!(bounds, RenderBoundingBox::Rectangle(_)) { + if layer_rect.is_some() { scene.pop_layer(); scene.pop_layer(); } } else { - element.render_to_vello(scene, transform, render_params); + draw_content(scene); } if layer { @@ -983,6 +1812,13 @@ fn collect_graphic_item_metadata(item: ItemRef<'_, Graphic>, metadata: &mut Rend let mut footprint = footprint; footprint.transform *= item_transform; + if let Some(layer) = item.layer() { + let reach = item.attribute::(ATTR_EFFECTS).map_or(0., Effects::reach) + nested_effects_reach(element); + if reach > 0. { + metadata.effects_reach.insert(layer, reach); + } + } + // An anonymous wrapper item (no layer tag) still recurses to reach nested content with "editor:layer_path" attributes element.collect_metadata(metadata, footprint, item.layer(), child_appearance); } @@ -1077,6 +1913,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), 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::Generator(item) => render_generator_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), @@ -1084,6 +1921,7 @@ impl Render for Graphic { Graphic::RasterGPUList(_) => (), Graphic::ColorList(list) => list.render_svg(render, render_params), Graphic::GradientList(list) => list.render_svg(render, render_params), + Graphic::GeneratorList(list) => list.render_svg(render, render_params), Graphic::TextList(list) => list.render_svg(render, render_params), Graphic::StrokeList(_) => (), } @@ -1103,8 +1941,9 @@ impl Render for Graphic { } Graphic::RasterCPU(item) => render_raster_cpu_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::RasterGPU(item) => render_raster_gpu_item_to_vello(ItemRef::Item(item), scene, transform, render_params), - Graphic::Color(item) => render_color_item_to_vello(ItemRef::Item(item), scene, render_params), + Graphic::Color(item) => render_color_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::Gradient(item) => render_gradient_item_to_vello(ItemRef::Item(item), scene, transform, render_params), + Graphic::Generator(item) => render_generator_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::Text(item) => render_text_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::GraphicList(list) => list.render_to_vello(scene, transform, render_params), Graphic::VectorList(list) => list.render_to_vello(scene, transform, render_params), @@ -1112,6 +1951,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.render_to_vello(scene, transform, render_params), Graphic::ColorList(list) => list.render_to_vello(scene, transform, render_params), Graphic::GradientList(list) => list.render_to_vello(scene, transform, render_params), + Graphic::GeneratorList(list) => list.render_to_vello(scene, transform, render_params), Graphic::TextList(list) => list.render_to_vello(scene, transform, render_params), Graphic::StrokeList(_) => (), } @@ -1148,6 +1988,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Color(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Gradient(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::Generator(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::Text(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::RasterCPUList(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -1181,6 +2022,14 @@ impl Render for Graphic { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } + Graphic::GeneratorList(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } Graphic::TextList(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -1208,6 +2057,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => collect_raster_metadata(Some(ItemRef::Item(item)), metadata, footprint, element_id), Graphic::Color(_) => (), Graphic::Gradient(item) => collect_gradient_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, element_id), + Graphic::Generator(_) => (), Graphic::Text(item) => collect_text_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, footprint, element_id), Graphic::GraphicList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::VectorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), @@ -1215,6 +2065,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::ColorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::GradientList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), + Graphic::GeneratorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::TextList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::StrokeList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), } @@ -1229,6 +2080,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), click_targets), Graphic::Color(_) => (), Graphic::Gradient(item) => add_gradient_item_click_targets(ItemRef::Item(item), click_targets), + Graphic::Generator(_) => (), Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), click_targets), Graphic::GraphicList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), @@ -1236,6 +2088,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::ColorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::GradientList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), + Graphic::GeneratorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::TextList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::StrokeList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), } @@ -1250,6 +2103,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), outlines), Graphic::Color(_) => (), Graphic::Gradient(item) => add_gradient_item_outline_targets(ItemRef::Item(item), outlines), + Graphic::Generator(_) => (), Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), outlines), Graphic::GraphicList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), @@ -1257,6 +2111,7 @@ impl Render for Graphic { Graphic::RasterGPUList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::ColorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::GradientList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), + Graphic::GeneratorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::TextList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::StrokeList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), } @@ -1346,7 +2201,11 @@ impl Render for List { |render| { let mut render_params = render_params.clone(); render_params.artboard_background = Some(background); + + let parent_transform = render.transform; + render.transform = parent_transform * DAffine2::from_translation(location); content.render_svg(render, &render_params); + render.transform = parent_transform; }, ); } @@ -1469,12 +2328,12 @@ impl Render for List { } /// Emits one vector shape as SVG, with no wrapping group of its own. -fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) { +/// When `filtered`, the group applying the item's effects takes its opacity and blend mode instead. +fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, filtered: bool, folded: Option<&FoldedPaints>, render: &mut SvgRender, render_params: &RenderParams) { let paths = item.attribute::>(ATTR_PATHS).unwrap_or_else(|| no_paths()); let item_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.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let blend_mode_attr: BlendMode = if filtered { BlendMode::default() } else { item.attribute_cloned_or_default(ATTR_BLEND_MODE) }; + let opacity = if filtered { 1. } else { item.compositing(render_params).opacity_without_effects() }; // The item's own declared appearance wins over one cascading down from an ancestor let own_appearance = item.attribute::(ATTR_APPEARANCE).and_then(Appearance::declared); @@ -1485,6 +2344,10 @@ fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, render: & stroke_paint, stroke_below: wants_stroke_below, } = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + let (fill_paint, stroke_paint) = match folded { + Some(folded) => (folded.fill.as_ref(), folded.stroke.as_ref()), + None => (fill_paint, stroke_paint), + }; // Only consider strokes with nonzero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.); @@ -1648,7 +2511,6 @@ fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, render: & attributes.push_val(stroke_shape_attribute); attributes.push_val(stroke_attribute); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; if opacity < 1. { attributes.push("opacity", opacity.to_string()); } @@ -1681,7 +2543,10 @@ fn render_vector_item_svg(item: ItemRef<'_, Vector>, next_clips: bool, clip_mask let mut masked_by = None; if next_clips && clip_mask_state.is_none() { - let masker = Graphic::VectorList(List::new_from_item(Item::from_parts(vector.clone(), item.clone_item_attributes()))); + // The shadows and glows the masker casts don't widen the area its clipped content shows in + let mut masker = Item::from_parts(vector.clone(), item.clone_item_attributes()); + masker.remove_attribute::(ATTR_EFFECTS); + let masker = Graphic::VectorList(List::new_from_item(masker)); let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; let uuid = generate_uuid(); @@ -1699,22 +2564,55 @@ fn render_vector_item_svg(item: ItemRef<'_, Vector>, next_clips: bool, clip_mask masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); } + let compositing = item.compositing(render_params); + let appearance = || { + item.attribute::(ATTR_APPEARANCE) + .and_then(Appearance::declared) + .or(render_params.inherited_appearance.as_ref()) + }; + + // Solid paints take leading adjustments into their colors, which SVG filters can't always express + let folded = compositing.effects.and_then(|_| { + let fill_and_stroke = appearance()?.fill_and_stroke(); + let strokes = fill_and_stroke.stroke.as_ref().is_some_and(|stroke| stroke.weight() > 0.); + compositing.fold_into_solid_paints(fill_and_stroke.fill_paint, fill_and_stroke.stroke_paint, strokes, render_params.for_mask) + }); + let compositing = match &folded { + Some(folded) => compositing.with_remaining_effects(&folded.remaining_effects), + None => compositing, + }; + + let render_shape = |render: &mut SvgRender| { + let filter = compositing.svg_filter(render, render_params, || { + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let stroke = appearance().and_then(|appearance| appearance.fill_and_stroke().stroke); + let paths = item.attribute::>(ATTR_PATHS).unwrap_or_else(|| no_paths()); + + vector + .stroke_inclusive_bounding_box_with_transform(paths, item_transform, stroke.as_ref()) + .map_or(RenderBoundingBox::None, RenderBoundingBox::Rectangle) + }); + let filtered = filter.is_some(); + let blend_mode: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + + render_svg_through_filter(render, filter, compositing.opacity, blend_mode, |render| { + render_vector_shape_svg(item, vector, filtered, folded.as_ref(), render, render_params) + }); + }; + // Item geometry is baked into the path data instead of a group transform, so mask coordinates line up match masked_by { - Some((attribute, selector)) => render.parent_tag( - "g", - |attributes| attributes.push(attribute, selector), - |render| render_vector_shape_svg(item, vector, render, render_params), - ), - None => render_vector_shape_svg(item, vector, render, render_params), + Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_shape), + None => render_shape(render), } } -/// Whether `bounds` placed by `transform` overlaps the scene once widened by `inflation`, which is measured in the space `inflation_transform` maps from. -fn overlaps_scene(scene: &VelloSceneBuilder, bounds: [DVec2; 2], transform: DAffine2, inflation: f64, inflation_transform: DAffine2) -> bool { +/// Whether `bounds` placed by `transform` overlaps the scene once widened by `inflation`, measured in the space `inflation_transform` maps from, and by its effects' `reach`, measured in the scene's space. +fn overlaps_scene(scene: &VelloSceneBuilder, bounds: [DVec2; 2], transform: DAffine2, inflation: f64, inflation_transform: DAffine2, reach: f64) -> bool { let view = DAffine2::from_cols_array(&scene.view().as_coeffs()); let (largest_scale, _) = singular_values(view * inflation_transform); - let inflation = inflation * largest_scale; + let (largest_view_scale, _) = singular_values(view); + let inflation = inflation * largest_scale + reach * largest_view_scale; let [min, max] = (view * transform * Quad::from_box(bounds)).bounding_box(); min.x - inflation < scene.width() as f64 && max.x + inflation > 0. && min.y - inflation < scene.height() as f64 && max.y + inflation > 0. @@ -1736,8 +2634,7 @@ fn render_vector_item_to_vello( let element_paths = item.attribute::>(ATTR_PATHS).unwrap_or_else(|| no_paths()); let item_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.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let compositing = item.compositing(render_params); let multiplied_transform = parent_transform * item_transform; // The item's own declared appearance wins over one cascading down from an ancestor @@ -1751,6 +2648,14 @@ fn render_vector_item_to_vello( } = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.); + + // Solid paints take leading adjustments into their colors rather than through an effects group + let folded = compositing.fold_into_solid_paints(fill_paint, stroke_paint, has_real_stroke.is_some(), render_params.for_mask); + let (fill_paint, stroke_paint, compositing) = match &folded { + Some(folded) => (folded.fill.as_ref(), folded.stroke.as_ref(), compositing.with_remaining_effects(&folded.remaining_effects)), + None => (fill_paint, stroke_paint, compositing), + }; + // A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own let set_stroke_transform = has_real_stroke .map(|stroke| if own_appearance.is_some() { stroke.transform } else { item_transform }) @@ -1789,7 +2694,7 @@ fn render_vector_item_to_vello( // Vello only culls after expanding strokes and flattening paths, so an item outside the scene is skipped before any of that work let stroke_inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)).max(get_outline_styles(render_params).0.width); - if !overlaps_scene(scene, layer_bounds, multiplied_transform, stroke_inflation, element_transform) { + if !overlaps_scene(scene, layer_bounds, multiplied_transform, stroke_inflation, element_transform, compositing.reach()) { if next_clips && clip_masker.is_none() { *clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), item.clone_item_attributes()))); } else if clip_masker.is_some() && !next_clips { @@ -1801,11 +2706,11 @@ fn render_vector_item_to_vello( let Some(placed) = item.placed_contours(applied_stroke_transform) else { return }; let path = placed.path(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let opacity = compositing.opacity; let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); - // Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed - let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| { + // Shared by the layers and effects group below, so it is only worth deriving when one of them is pushed + let layer_geometry = (needs_blend_layer || clip_masker.is_some() || compositing.effects.is_some()).then(|| { // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. let (_, smallest_scale) = singular_values(applied_stroke_transform); @@ -1819,17 +2724,35 @@ fn render_vector_item_to_vello( ) }); + // The layers also cover what the effects paint past the content, which reaches a length in the scene's space + let effects_inflation = match singular_values(multiplied_transform) { + (_, smallest_scale) if smallest_scale > 0. => compositing.reach() / smallest_scale, + _ => 0., + }; + let layer_rect_with_effects = |layer_rect: kurbo::Rect| layer_rect.inflate(effects_inflation, effects_inflation); + if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry { layer = true; - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), + opacity, + layer_affine, + &layer_rect_with_effects(layer_rect), + ); } // Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result let mut clip_layers = false; if next_clips && clip_masker.is_none() { - *clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), item.clone_item_attributes()))); + // The shadows and glows the masker casts don't widen the area its clipped content shows in + let mut masker = Item::from_parts(element.clone(), item.clone_item_attributes()); + masker.remove_attribute::(ATTR_EFFECTS); + *clip_masker = Some(List::new_from_item(masker)); } else if let Some(masker) = clip_masker.as_ref() { if let Some((layer_affine, layer_rect)) = layer_geometry { + let layer_rect = layer_rect_with_effects(layer_rect); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect); masker.render_to_vello(scene, parent_transform, &render_params.for_clipper()); scene.push_layer( @@ -1859,7 +2782,7 @@ fn render_vector_item_to_vello( scene.fill_path(fill_rule, element_affine, &fill, None, path); }; let gradient_fill = |scene: &mut VelloSceneBuilder, gradient_item: ItemRef<'_, Gradient>| { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { + let Some((brush, gradient_to_device)) = create_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { return; }; @@ -1869,7 +2792,7 @@ fn render_vector_item_to_vello( Default::default() }; let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); - scene.fill_path(fill_rule, element_affine, &brush, Some(brush_transform), path); + scene.fill_path(fill_rule, element_affine, brush, Some(brush_transform), path); }; match paint { @@ -1885,10 +2808,12 @@ fn render_vector_item_to_vello( | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) + | Graphic::Generator(_) | Graphic::Text(_) | Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) + | Graphic::GeneratorList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) | Graphic::StrokeList(_) => { @@ -1948,7 +2873,7 @@ fn render_vector_item_to_vello( scene.fill_path(peniko::Fill::NonZero, element_affine, &brush, None, &outline); }; let gradient_stroke = |scene: &mut VelloSceneBuilder, gradient_item: ItemRef<'_, Gradient>| { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { + let Some((brush, gradient_to_device)) = create_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else { return; }; let inverse_element_transform = if transform_is_invertible(element_transform) { @@ -1959,7 +2884,7 @@ fn render_vector_item_to_vello( let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); let outline = placed.stroke_outline(&stroke, scene.stroke_tolerance(element_affine)); - scene.fill_path(peniko::Fill::NonZero, element_affine, &brush, Some(brush_transform), &outline); + scene.fill_path(peniko::Fill::NonZero, element_affine, brush, Some(brush_transform), &outline); }; match paint { @@ -1975,10 +2900,12 @@ fn render_vector_item_to_vello( | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) + | Graphic::Generator(_) | Graphic::Text(_) | Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) + | Graphic::GeneratorList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) | Graphic::StrokeList(_) => { @@ -2000,76 +2927,79 @@ fn render_vector_item_to_vello( scene.fill_path(peniko::Fill::NonZero, element_affine, outline_color_peniko, None, &outline); } _ => { - if use_layer { - let cloned_element = element.clone(); - - // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. - // The outer opacity/blend layer (above) handles the user-set opacity. - let mut mask_item = Item::new_from_element(cloned_element) - .with_attribute(ATTR_PATHS, element_paths.clone()) - .with_attribute(ATTR_GEOMETRY_CACHE, item.geometry_cache()) - .with_attribute(ATTR_TRANSFORM, item_transform); - let black_fill = Graphic::ColorList(List::new_from_element(Color::BLACK)); - mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill)); - let vector_list = List::new_from_item(mask_item); - - let bounds = contours_bounding_box(&contours, multiplied_transform).unwrap_or(layer_bounds); - // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed - let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); - let (largest_scale, _) = singular_values(applied_stroke_transform); - let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); - let bounds = quad.bounding_box(); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { - peniko::Compose::SrcOut - } else { - peniko::Compose::SrcIn - }; - - if wants_stroke_below { - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - - do_stroke(scene, 2.); + let effects_bounds = || layer_geometry.map_or(RenderBoundingBox::None, |(layer_affine, layer_rect)| rect_bounds(layer_affine.transform_rect_bbox(layer_rect))); + compositing.draw_with_effects(scene, parent_transform, render_params, effects_bounds, |scene| { + if use_layer { + let cloned_element = element.clone(); + + // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. + // The outer opacity/blend layer (above) handles the user-set opacity. + let mut mask_item = Item::new_from_element(cloned_element) + .with_attribute(ATTR_PATHS, element_paths.clone()) + .with_attribute(ATTR_GEOMETRY_CACHE, item.geometry_cache()) + .with_attribute(ATTR_TRANSFORM, item_transform); + let black_fill = Graphic::ColorList(List::new_from_element(Color::BLACK)); + mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill)); + let vector_list = List::new_from_item(mask_item); + + let bounds = contours_bounding_box(&contours, multiplied_transform).unwrap_or(layer_bounds); + // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed + let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); + let (largest_scale, _) = singular_values(applied_stroke_transform); + let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); + let bounds = quad.bounding_box(); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { + peniko::Compose::SrcOut + } else { + peniko::Compose::SrcIn + }; - scene.pop_layer(); - scene.pop_layer(); + if wants_stroke_below { + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_list.render_to_vello(scene, parent_transform, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - do_fill(scene); - } else { - // Fill first (unclipped), then stroke (clipped) above - do_fill(scene); + do_stroke(scene, 2.); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + scene.pop_layer(); + scene.pop_layer(); - do_stroke(scene, 2.); + do_fill(scene); + } else { + // Fill first (unclipped), then stroke (clipped) above + do_fill(scene); - scene.pop_layer(); - scene.pop_layer(); - } - } else { - // Non-aligned strokes or open paths: default order behavior - enum Op { - Fill, - Stroke, - } + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_list.render_to_vello(scene, parent_transform, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - let order = match wants_stroke_below { - true => [Op::Stroke, Op::Fill], - false => [Op::Fill, Op::Stroke], // Default - }; + do_stroke(scene, 2.); + + scene.pop_layer(); + scene.pop_layer(); + } + } else { + // Non-aligned strokes or open paths: default order behavior + enum Op { + Fill, + Stroke, + } + + let order = match wants_stroke_below { + true => [Op::Stroke, Op::Fill], + false => [Op::Fill, Op::Stroke], // Default + }; - for operation in &order { - match operation { - Op::Fill => do_fill(scene), - Op::Stroke => do_stroke(scene, 1.), + for operation in &order { + match operation { + Op::Fill => do_fill(scene), + Op::Stroke => do_stroke(scene, 1.), + } } } - } + }); } } @@ -2364,15 +3294,73 @@ fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRe 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.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let compositing = item.compositing(render_params); if image.data.is_empty() { return; } + // SVG filters can't carry out every adjustment, so those leading the effects are carried out in the image's pixels + let mut remaining_effects = Effects::default(); + let (leading_adjustments, compositing) = compositing.split_leading_adjustments(&mut remaining_effects); + let adjusted_image; + let image = match leading_adjustments.is_empty() { + true => image, + false => { + adjusted_image = adjusted_raster(image, &leading_adjustments); + &adjusted_image + } + }; + + let filter = compositing.svg_filter(render, render_params, || { + RenderBoundingBox::Rectangle((transform * Quad::from_box([DVec2::ZERO, DVec2::ONE])).bounding_box()) + }); + + // A filter's wrapping group takes the opacity and blend mode instead + let (opacity, blend_mode) = match filter { + Some(_) => (1., BlendMode::default()), + None => (compositing.opacity_without_effects(), blend_mode_attr), + }; + + render_svg_through_filter(render, filter, compositing.opacity, blend_mode_attr, |render| { + render_raster_cpu_image_svg(image, transform, opacity, blend_mode, render, render_params) + }); +} + +/// The most times an image drawn to a canvas is halved, which is when 4096 of its pixels fit on one screen pixel. +const MAX_CANVAS_HALVINGS: u32 = 12; + +/// The most pixels an image may have for its adjustments to be carried out on a copy of its texture, past which an effects group carries them out at the render's resolution. +const MAX_ADJUSTED_TEXTURE_PIXELS: u64 = 4096 * 4096; + +/// An image the frontend draws to a canvas rather than an SVG embedding it, at a resolution to suit how large it appears. +#[derive(Clone, Debug, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CanvasImage { + pub raster: Raster, + /// How many times the pixels are halved along each axis for display. + pub halvings: u32, +} + +/// Names the canvas for one resolution of an image's pixels, staying the same while the pixels do. +fn canvas_image_id(buffer_id: u64, halvings: u32) -> u64 { + (buffer_id << 4) | halvings as u64 +} + +/// Emits a CPU raster's image as SVG, either as a placeholder for a canvas or as an embedded PNG. +fn render_raster_cpu_image_svg(image: &Raster, transform: DAffine2, opacity: f32, blend_mode: BlendMode, render: &mut SvgRender, render_params: &RenderParams) { if render_params.to_canvas() { - let id = *render.image_data.entry(CacheHashWrapper(image.clone().into_data())).or_insert_with(generate_uuid); + // An image drawn smaller than its pixels is sent at a resolution halved enough times to still cover each screen pixel + let image_to_screen = render_params.footprint.transform * render.transform * transform * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); + let (largest_scale, _) = singular_values(image_to_screen); + let halvings = if largest_scale > 0. { + (1. / largest_scale).log2().floor().clamp(0., MAX_CANVAS_HALVINGS as f64) as u32 + } else { + 0 + }; + + let id = canvas_image_id(image.buffer_id(), halvings); + render.image_data.entry(id).or_insert_with(|| CanvasImage { raster: image.clone(), halvings }); render.parent_tag( "foreignObject", @@ -2388,13 +3376,12 @@ fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRe attributes.push("width", size.x.to_string()); attributes.push("height", size.y.to_string()); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; if opacity < 1. { attributes.push("opacity", opacity.to_string()); } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); } }, |render| { @@ -2407,33 +3394,21 @@ fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRe }, ); } else { - let base64_string = image.base64_string.clone().unwrap_or_else(|| { - use base64::Engine; - - let output = image.to_png(); - let preamble = "data:image/png;base64,"; - let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); - base64_string.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); - base64_string - }); - render.leaf_tag("image", |attributes| { attributes.push("width", "1"); attributes.push("height", "1"); attributes.push("preserveAspectRatio", "none"); - attributes.push("href", base64_string); + attributes.push("href", image.png_data_url().to_string()); let matrix = format_transform_matrix(transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); } - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; if opacity < 1. { attributes.push("opacity", opacity.to_string()); } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); } }); } @@ -2447,11 +3422,26 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V } 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.); + let compositing = item.compositing(render_params); let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + // Leading adjustments are carried out on the image's texture as it's drawn, rather than on a render of it in an effects group + let mut remaining_effects = Effects::default(); + let (leading_adjustments, split_compositing) = compositing.split_leading_adjustments(&mut remaining_effects); + let changes_alpha = leading_adjustments.iter().any(|adjustment| !adjustment.preserves_alpha()); + let fits_texture_copy = image.width as u64 * image.height as u64 <= MAX_ADJUSTED_TEXTURE_PIXELS; + let adjusted_image; + let (image, adjustments, compositing) = if leading_adjustments.is_empty() || !(changes_alpha || fits_texture_copy) { + (image, Vec::new(), compositing) + } else if changes_alpha { + // A premultiplied texture keeps no color where alpha is zero, so adjustments changing alpha are carried out on the CPU like in SVG + adjusted_image = adjusted_raster(image, &leading_adjustments); + (&adjusted_image, Vec::new(), split_compositing) + } else { + (image, leading_adjustments, split_compositing) + }; + + let opacity = compositing.opacity; let mut layer = false; let whole_bounds = || match item { @@ -2462,7 +3452,7 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V && let RenderBoundingBox::Rectangle(bounds) = whole_bounds() { let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + let rect = inflate_bounds(bounds, compositing.reach()); scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); layer = true; } @@ -2481,23 +3471,114 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V } let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); + let sampler = peniko::ImageSampler::new().with_extend(peniko::Extend::Pad); - let image_brush = peniko::ImageBrush::new(peniko::ImageData { - data: image.to_flat_u8().0.into(), - format: peniko::ImageFormat::Rgba8, - width: image.width, - height: image.height, - alpha_type: peniko::ImageAlphaType::Alpha, - }) - .with_extend(peniko::Extend::Pad); + let item_bounds = || RenderBoundingBox::Rectangle((transform * transform_attribute * Quad::from_box([DVec2::ZERO, DVec2::ONE])).bounding_box()); + compositing.draw_with_effects(scene, transform, render_params, item_bounds, |scene| { + scene.draw_raster(image.clone(), adjustments.clone(), sampler, kurbo::Affine::new(image_transform.to_cols_array())) + }); + + if layer { + scene.pop_layer(); + } +} + +/// The most pixels across or down an SVG's image of a generator may have. +const MAX_GENERATOR_IMAGE_PIXELS: f64 = 8192.; + +/// The most pixels across or down a thumbnail's image of a generator may have, whatever the size of the area it shows. +const MAX_GENERATOR_THUMBNAIL_PIXELS: f64 = 256.; + +/// Emits one item of generator content as SVG, as an image of the part in view at the view's resolution. +fn render_generator_item_svg(item: ItemRef<'_, Generator>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(&generator) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let Some((area, width, height)) = generator_area_in_view(transform, render, render_params) else { + return; + }; + + // An image of the area, carrying the generator's attributes, stands in for it + let pattern = GeneratorPattern::decode(generator, &|key| item.attribute_any(key)); + let mut image = Item::from_parts(Raster::new_cpu(pattern.render(area, width, height)), item.clone_item_attributes()); + image.set_attribute(ATTR_TRANSFORM, transform * DAffine2::from_translation(area[0]) * DAffine2::from_scale(area[1] - area[0])); + render_raster_cpu_item_svg(ItemRef::Item(&image), render, render_params); +} + +/// The part of a generator's own space in view, which `transform` places, with the pixels across and down it covers in the view. +fn generator_area_in_view(transform: DAffine2, render: &SvgRender, render_params: &RenderParams) -> Option<([DVec2; 2], u32, u32)> { + let footprint = render_params.footprint; + + // A thumbnail's footprint starts at the document area it shows, one pixel per unit, rather than mapping the document into a view + let (document_to_view, max_pixels) = match render_params.thumbnail { + true => (DAffine2::from_translation(-footprint.transform.translation), MAX_GENERATOR_THUMBNAIL_PIXELS), + false => (footprint.transform, MAX_GENERATOR_IMAGE_PIXELS), + }; + let local_to_view = document_to_view * render.transform * transform; + if local_to_view.matrix2.determinant() == 0. || !local_to_view.is_finite() || footprint.resolution.min_element() <= 1 { + return None; + } + + let view = Quad::from_box([DVec2::ZERO, footprint.resolution.as_dvec2()]); + let [min, max] = (local_to_view.inverse() * view).bounding_box(); + + let pixels_per_unit = DVec2::new(local_to_view.matrix2.x_axis.length(), local_to_view.matrix2.y_axis.length()); + let pixels = ((max - min) * pixels_per_unit).ceil().clamp(DVec2::ONE, DVec2::splat(max_pixels)); + Some(([min, max], pixels.x as u32, pixels.y as u32)) +} + +/// Draws one item of generator content into the Vello scene, which renders its pattern for each render at the resolution it needs. +fn render_generator_item_to_vello(item: ItemRef<'_, Generator>, scene: &mut VelloSceneBuilder, transform: DAffine2, render_params: &RenderParams) { + let Some(&generator) = item.element() else { return }; + + // Outline mode shows only geometry, and a generator filling the plane has none + if let RenderMode::Outline = render_params.render_mode { + return; + } + + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let compositing = item.compositing(render_params); + let local_to_scene = transform * item.attribute_cloned_or_default::(ATTR_TRANSFORM); + let bounds = generator.bounding_box(local_to_scene, false); + if bounds == RenderBoundingBox::None { + return; + } + + // Opacity and blend modes composite through a layer covering the whole render + let mut layer = false; + if compositing.opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + scene.push_viewport_layer(blending, compositing.opacity); + layer = true; + } - scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); + let pattern = GeneratorPattern::decode(generator, &|key| item.attribute_any(key)); + compositing.draw_with_effects( + scene, + transform, + render_params, + || bounds, + |scene| scene.draw_generator(&pattern, kurbo::Affine::new(local_to_scene.to_cols_array())), + ); if layer { scene.pop_layer(); } } +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..self.len() { + render_generator_item_svg(ItemRef::ListItem(self, index), render, render_params); + } + } + + fn render_to_vello(&self, scene: &mut VelloSceneBuilder, transform: DAffine2, render_params: &RenderParams) { + for index in 0..self.len() { + render_generator_item_to_vello(ItemRef::ListItem(self, index), scene, transform, render_params); + } + } +} + /// The metadata a raster contributes under an `element_id`: a unit-square click target, /// plus the first item's transform and any merged-layers snapshot when a first item exists. fn collect_raster_metadata(first_row: Option>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { @@ -2583,8 +3664,7 @@ impl Render for List> { 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 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.); + let compositing = item.compositing(render_params); let clip_attr: bool = item.attribute_cloned_or_default(ATTR_CLIPPING_MASK); let blend_mode = match render_params.render_mode { RenderMode::Outline => peniko::Mix::Normal, @@ -2597,13 +3677,13 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V ItemRef::ListItem(list, _) => list.bounding_box(transform, true), ItemRef::Item(item) => item.bounding_box(transform, true), }; - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let opacity = compositing.opacity; let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr; if (render_params.render_mode != RenderMode::Outline && any_nondefault) && let RenderBoundingBox::Rectangle(bounds) = whole_bounds() { let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + let rect = inflate_bounds(bounds, compositing.reach()); scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); layer = true; } @@ -2626,7 +3706,11 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut V 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())); + + let item_bounds = || RenderBoundingBox::Rectangle((transform * transform_attribute * Quad::from_box([DVec2::ZERO, DVec2::ONE])).bounding_box()); + compositing.draw_with_effects(scene, transform, render_params, item_bounds, |scene| { + scene.draw_texture(raster.texture.clone(), sampler, kurbo::Affine::new(image_transform.to_cols_array())) + }); if layer { scene.pop_layer() @@ -2651,9 +3735,9 @@ impl Render for List { } } - fn render_to_vello(&self, scene: &mut VelloSceneBuilder, _parent_transform: DAffine2, render_params: &RenderParams) { + fn render_to_vello(&self, scene: &mut VelloSceneBuilder, parent_transform: DAffine2, render_params: &RenderParams) { for index in 0..self.len() { - render_color_item_to_vello(ItemRef::ListItem(self, index), scene, render_params); + render_color_item_to_vello(ItemRef::ListItem(self, index), scene, parent_transform, render_params); } } } @@ -2661,6 +3745,8 @@ impl Render for List { /// Emits one item of color content as SVG, painting a stand-in for an infinite background. fn render_color_item_svg(item: ItemRef<'_, Color>, render: &mut SvgRender, render_params: &RenderParams) { let Some(color) = item.element() else { return }; + // SVG filters change nothing visible about an endless color, so its adjustments are carried out on the color itself + let color = item.attribute::(ATTR_EFFECTS).map_or(*color, |effects| adjusted_color(*color, &adjustments(effects))); let blend_mode: 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.); @@ -2671,7 +3757,7 @@ fn render_color_item_svg(item: ItemRef<'_, Color>, render: &mut SvgRender, rende const MAX: f64 = 1e7; attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); - attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex())); + attributes.push("fill", format!("#{}", SRGBA8::from(color).to_rgb_hex())); if color.a() < 1. { attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string()); } @@ -2688,15 +3774,20 @@ fn render_color_item_svg(item: ItemRef<'_, Color>, render: &mut SvgRender, rende } /// Draws one item of color content into the Vello scene, covering the whole render area. -fn render_color_item_to_vello(item: ItemRef<'_, Color>, scene: &mut VelloSceneBuilder, render_params: &RenderParams) { +fn render_color_item_to_vello(item: ItemRef<'_, Color>, scene: &mut VelloSceneBuilder, parent_transform: DAffine2, render_params: &RenderParams) { let Some(color) = item.element() 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.); + let compositing = item.compositing(render_params); + + // The adjustments leading the effects are carried out on the color itself, leaving the rest to the effects group + let mut remaining_effects = Effects::default(); + let (leading_adjustments, compositing) = compositing.split_leading_adjustments(&mut remaining_effects); + let color = adjusted_color(*color, &leading_adjustments); + let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let opacity = compositing.opacity; - let vello_color = SRGBA8::from(*color).to_peniko_color(); + let vello_color = SRGBA8::from(color).to_peniko_color(); let mut layer = false; if opacity < 1. || blend_mode_attr != BlendMode::default() { @@ -2704,7 +3795,7 @@ fn render_color_item_to_vello(item: ItemRef<'_, Color>, scene: &mut VelloSceneBu layer = true; } - scene.fill_viewport(vello_color, None); + compositing.draw_with_effects(scene, parent_transform, render_params, || RenderBoundingBox::Infinite, |scene| scene.fill_viewport(vello_color, None)); if layer { scene.pop_layer(); @@ -2792,13 +3883,13 @@ impl Render for List { /// Emits one item of gradient content as SVG, painting the thumbnail rect or an infinite-background stand-in. fn render_gradient_item_svg_with_thumbnail_rect(item: ItemRef<'_, Gradient>, thumbnail_rect: Option<(DVec2, DVec2)>, render: &mut SvgRender, render_params: &RenderParams) { - let Some(gradient) = item.element() else { return }; + // SVG filters change nothing visible about an endless gradient, so its adjustments are carried out along its ramp + let Some((gradient, settings)) = painted_gradient(item) else { return }; let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let blend_mode: 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.); - let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); - let settings = gradient_settings_from_item(item); + let shape = GradientShape::of(item); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { @@ -2814,7 +3905,7 @@ fn render_gradient_item_svg_with_thumbnail_rect(item: ItemRef<'_, Gradient>, thu attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } - let samples = spread_adjusted_samples(gradient, settings, gradient_form); + let samples = spread_adjusted_samples(&gradient, settings, shape.reaches_before_start()); let mut stop_string = String::new(); for (position, color, original_midpoint) in samples { @@ -2845,20 +3936,8 @@ fn render_gradient_item_svg_with_thumbnail_rect(item: ItemRef<'_, Gradient>, thu }; // The unit gradient line is the +X unit vector in local space, before the item's transform is applied - match gradient_form { - GradientForm::Linear => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - GradientForm::Radial => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - } + let definition_attributes = format!("{gradient_spread_attribute}{gradient_transform_attribute}"); + attributes.0.svg_defs.push_str(&svg_gradient_definition(shape, gradient_id, &definition_attributes, &stop_string)); attributes.push("fill", format!("url('#{gradient_id}')")); @@ -2881,33 +3960,27 @@ fn render_gradient_item_to_vello(item: ItemRef<'_, Gradient>, scene: &mut VelloS { let Some(gradient) = item.element() else { return }; - let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let shape = GradientShape::of(item); 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.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let compositing = item.compositing(render_params); let gradient_transform = parent_transform * transform; - let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - + // The adjustments leading the effects are carried out along the ramp, leaving the rest to the effects group + let mut remaining_effects = Effects::default(); + let (leading_adjustments, compositing) = compositing.split_leading_adjustments(&mut remaining_effects); let settings = gradient_settings_from_item(item); - let samples = spread_adjusted_samples(gradient, settings, gradient_form); - - let stops = peniko_color_stops(&samples); - - let extend = peniko_extend(settings.spread); + let (gradient, settings) = match adjusted_gradient(gradient, settings, &leading_adjustments) { + Some((adjusted, settings)) => (Cow::Owned(adjusted), settings), + None => (Cow::Borrowed(gradient), settings), + }; - let kind = unit_gradient_kind(gradient_form); + let blend_mode = blend_mode_attr.to_peniko(); + let opacity = compositing.opacity; - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind, - stops, - extend, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, - ..Default::default() - }); - let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array()); + let samples = spread_adjusted_samples(&gradient, settings, shape.reaches_before_start()); + let fill = gradient_scene_brush(shape, &samples, settings.spread); + let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, shape).to_cols_array()); let mut layer = false; if opacity < 1. || blend_mode_attr != BlendMode::default() { @@ -2915,7 +3988,13 @@ fn render_gradient_item_to_vello(item: ItemRef<'_, Gradient>, scene: &mut VelloS layer = true; } - scene.fill_viewport(&fill, Some(brush_transform)); + compositing.draw_with_effects( + scene, + parent_transform, + render_params, + || RenderBoundingBox::Infinite, + |scene| scene.fill_viewport(fill.clone(), Some(brush_transform)), + ); if layer { scene.pop_layer(); @@ -2932,7 +4011,7 @@ fn collect_gradient_items_metadata<'a>(items: impl Iterator(items: impl Iterator) -> GradientForm { + match GradientShape::of(item) { + GradientShape::Radial => GradientForm::Radial, + GradientShape::Linear | GradientShape::Pencil(_) => GradientForm::Linear, + } +} + /// Collects one gradient item's control geometry as a click target when its interior is draggable. fn add_gradient_item_click_targets(item: ItemRef<'_, Gradient>, click_targets: &mut Vec) { - let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let gradient_form = gradient_control_form(item); if !gradient_control_interior_is_clickable(gradient_form) { return; } @@ -2973,7 +4060,7 @@ fn add_gradient_item_click_targets(item: ItemRef<'_, Gradient>, click_targets: & /// Collects one gradient item's control geometry as an outline target. fn add_gradient_item_outline_targets(item: ItemRef<'_, Gradient>, outlines: &mut Vec) { - let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let gradient_form = gradient_control_form(item); let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let mut target = ClickTarget::new_with_path(gradient_control_outline(gradient_form), 0.); @@ -3175,8 +4262,7 @@ fn render_text_item_svg(item: ItemRef<'_, String>, render: &mut SvgRender, rende } let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let compositing = item.compositing(render_params); let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); let font: Resource = { let f: Resource = item.attribute_cloned_or_default(ATTR_FONT); @@ -3189,7 +4275,6 @@ fn render_text_item_svg(item: ItemRef<'_, String>, render: &mut SvgRender, rende let max_height = item.attribute::(ATTR_MAX_HEIGHT).copied().filter(|height| *height > 0.); let letter_tilt: f64 = item.attribute_cloned_or(ATTR_LETTER_TILT, 0.); let align: text_nodes::TextAlign = item.attribute_cloned_or_default(ATTR_TEXT_ALIGN); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; let typesetting = text_nodes::TypesettingConfig { font_size, @@ -3202,11 +4287,17 @@ fn render_text_item_svg(item: ItemRef<'_, String>, render: &mut SvgRender, rende }; let mut glyph_paths: Vec = Vec::new(); + let mut layout_bounds = RenderBoundingBox::None; text_nodes::TextContext::with_thread_local(|ctx| { let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; let tilt_tan = letter_tilt.to_radians().tan(); + // Padded by the font size, since glyphs can overhang their layout box + let alignment_width = max_width.unwrap_or(layout.full_width() as f64); + let padded = Quad::from_box([DVec2::splat(-font_size), DVec2::new(alignment_width, layout.height() as f64) + font_size]); + layout_bounds = RenderBoundingBox::Rectangle((transform * padded).bounding_box()); + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { glyph_paths.push(bez_path.to_svg()); @@ -3218,37 +4309,47 @@ fn render_text_item_svg(item: ItemRef<'_, String>, render: &mut SvgRender, rende return; } - // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. - render.parent_tag( - "g", - |attributes| { - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push("transform", matrix); - } - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }, - |render| { - for path_d in glyph_paths { - render.leaf_tag("path", |attributes| { - attributes.push("d", path_d); - if let RenderMode::Outline = render_params.render_mode { - attributes.push("fill", "none"); - attributes.push("stroke", "black"); - attributes.push("stroke-width", "1"); - } else { - attributes.push("fill", "black"); - attributes.push("fill-rule", "nonzero"); - } - }); - } - }, - ); + let filter = compositing.svg_filter(render, render_params, || layout_bounds); + let filtered = filter.is_some(); + + // Wrap all glyph elements in a with the item's transform/opacity/blend-mode, which a filter's wrapping group takes instead + render_svg_through_filter(render, filter, compositing.opacity, blend_mode_attr, |render| { + render.parent_tag( + "g", + |attributes| { + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + if filtered { + return; + } + + let opacity = compositing.opacity_without_effects(); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }, + |render| { + for path_d in glyph_paths { + render.leaf_tag("path", |attributes| { + attributes.push("d", path_d); + if let RenderMode::Outline = render_params.render_mode { + attributes.push("fill", "none"); + attributes.push("stroke", "black"); + attributes.push("stroke-width", "1"); + } else { + attributes.push("fill", "black"); + attributes.push("fill-rule", "nonzero"); + } + }); + } + }, + ); + }); } /// Draws one item of text content into the Vello scene, laying out its glyphs under the item's styling. @@ -3271,9 +4372,8 @@ fn render_text_item_to_vello(item: ItemRef<'_, String>, scene: &mut VelloSceneBu let letter_tilt: f64 = item.attribute_cloned_or(ATTR_LETTER_TILT, 0.); let align: text_nodes::TextAlign = item.attribute_cloned_or_default(ATTR_TEXT_ALIGN); 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.); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let compositing = item.compositing(render_params); + let opacity = compositing.opacity; let typesetting = text_nodes::TypesettingConfig { font_size, @@ -3290,28 +4390,40 @@ fn render_text_item_to_vello(item: ItemRef<'_, String>, scene: &mut VelloSceneBu text_nodes::TextContext::with_thread_local(|ctx| { let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; - let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); - if needs_layer { + let transformed_bounds = || { let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); - let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); let padding = font_size; let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding); - let transformed_bounds = affine.transform_rect_bbox(bounds); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); + affine.transform_rect_bbox(bounds) + }; + + let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + if needs_layer { + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + let reach = compositing.reach(); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds().inflate(reach, reach)); } let tilt_tan = letter_tilt.to_radians().tan(); - text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { - if let RenderMode::Outline = render_params.render_mode { - let (outline_stroke, outline_color) = get_outline_styles(render_params); - scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); - } else { - scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); - } - }); - }); + compositing.draw_with_effects( + scene, + transform, + render_params, + || rect_bounds(transformed_bounds()), + |scene| { + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { + draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { + if let RenderMode::Outline = render_params.render_mode { + let (outline_stroke, outline_color) = get_outline_styles(render_params); + scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); + } else { + scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); + } + }); + }); + }, + ); if needs_layer { scene.pop_layer(); @@ -3494,18 +4606,18 @@ mod tests { }; // Other spreads pass the samples through unchanged - let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Repeat), GradientForm::Linear); + let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Repeat), true); assert_eq!(samples, gradient.interpolated_samples(settings(GradientSpread::Pad))); // The guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops - let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Clear), GradientForm::Linear); + let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Clear), true); assert_eq!( samples, vec![(0., Color::TRANSPARENT, None), (0., Color::BLACK, None), (1., Color::WHITE, None), (1., Color::TRANSPARENT, None)] ); // A radial has no guard below the center - let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Clear), GradientForm::Radial); + let samples = spread_adjusted_samples(&gradient, settings(GradientSpread::Clear), GradientShape::Radial.reaches_before_start()); assert_eq!(samples, vec![(0., Color::BLACK, None), (1., Color::WHITE, None), (1., Color::TRANSPARENT, None)]); } @@ -3518,9 +4630,360 @@ mod tests { space: GradientSpace::RgbGamma, ..Default::default() }, - GradientForm::Linear, + true, ); let colors: Vec = samples.iter().map(|&(_, color, _)| color).collect(); assert_eq!(colors, vec![Color::TRANSPARENT, Color::BLACK, Color::BLACK, Color::TRANSPARENT]); } + + #[test] + fn gradient_shape_follows_the_form_until_dialed() { + let shape = |form: Option, dials: Option<(f64, f64)>| { + let mut item = Item::new_from_element(Gradient::from(Vec::new())); + if let Some(form) = form { + item.set_attribute(ATTR_GRADIENT_FORM, form); + } + if let Some((start, end)) = dials { + item.set_attribute(ATTR_GRADIENT_START_CURVATURE, start); + item.set_attribute(ATTR_GRADIENT_END_CURVATURE, end); + } + GradientShape::of(ItemRef::Item(&item)) + }; + + assert_eq!(shape(None, None), GradientShape::Linear); + assert_eq!(shape(Some(GradientForm::Radial), None), GradientShape::Radial); + + // The dials override the form, and either one's presets take the native path + assert_eq!(shape(Some(GradientForm::Radial), Some((0., 0.))), GradientShape::Linear); + assert_eq!(shape(None, Some((1., 0.5))), GradientShape::Radial); + assert!(matches!(shape(None, Some((0.5, 0.25))), GradientShape::Pencil(_))); + + // A dial wraps around, with -1 and 3 both being the end's own point like 1 + assert_eq!(shape(None, Some((-1., 0.5))), GradientShape::Radial); + assert_eq!(shape(None, Some((3., 0.5))), GradientShape::Radial); + } + + #[test] + fn svg_effects_filters_measure_lengths_in_the_document() { + use graphic_types::EffectOperation; + + let shadow = EffectOperation::DropShadow { + color: Color::BLACK, + offset: DVec2::new(4., 0.), + blur: 2., + spread: 0., + }; + let mut defs = String::new(); + + // A user space drawn at twice the document's scale halves every length measured in it, so the shadow reaches 5 units past the content + write_svg_effects_filter( + &mut defs, + &Effects::from_operations([shadow]), + 1., + [DVec2::ZERO, DVec2::splat(10.)], + DAffine2::from_scale(DVec2::splat(2.)), + 1., + &[], + ) + .unwrap(); + + assert!(defs.contains(r#"dx="2" dy="0""#), "{defs}"); + assert!(defs.contains(r#"stdDeviation="1""#), "{defs}"); + assert!(defs.contains(r#"x="-5" y="-5" width="20" height="20""#), "{defs}"); + } + + #[test] + fn svg_effects_filters_end_with_the_image_shown() { + use graphic_types::EffectOperation; + + let blur = EffectOperation::Blur { + radius: 3., + box_blur: false, + gamma: false, + }; + let mut defs = String::new(); + write_svg_effects_filter(&mut defs, &Effects::from_operations([blur]), 0.5, [DVec2::ZERO, DVec2::ONE], DAffine2::IDENTITY, 1., &[]).unwrap(); + + // The fill fades a copy of the content, which is blurred in linear light and passed through as the filter's last result + assert!(defs.contains(r#""#), "{defs}"); + assert!(defs.contains(r#"in="effect-1" stdDeviation="3" color-interpolation-filters="linearRGB""#), "{defs}"); + assert!(defs.ends_with(r#""#), "{defs}"); + } + + #[test] + fn svg_effects_filters_carry_out_inner_glows_satins_outlines_bevels_and_paint_overlays() { + use graphic_types::vector_types::vector::style::StrokeAlign; + use graphic_types::{BevelDirection, EffectOperation, GlowSource}; + + let filter = |operation| { + let mut defs = String::new(); + write_svg_effects_filter(&mut defs, &Effects::from_operations([operation]), 1., [DVec2::ZERO, DVec2::splat(10.)], DAffine2::IDENTITY, 1., &[]).unwrap(); + defs + }; + let invert = r#""#; + + // A glow from the center is the inverse of one from the edges, cast from the area outside the content + let inner_glow = filter(EffectOperation::InnerGlow { + color: Color::WHITE, + blur: 2., + choke: 1., + source: GlowSource::Center, + }); + assert!( + inner_glow.contains(r#"operator="out""#) && inner_glow.contains(r#"operator="dilate" radius="1""#) && inner_glow.contains(invert), + "{inner_glow}" + ); + + // Satin takes the difference both ways between two opposite casts + let satin = filter(EffectOperation::Satin { + color: Color::BLACK, + offset: DVec2::new(3., 0.), + blur: 1., + invert: false, + }); + assert!(satin.contains(r#"dx="3" dy="0""#) && satin.contains(r#"dx="-3" dy="-0""#), "{satin}"); + assert_eq!(satin.matches(r#"k2="1" k3="-1""#).count(), 2, "{satin}"); + + // A centered outline draws half its weight on each side, outside beneath the content and inside within it + let outline = filter(EffectOperation::Outline { + color: Color::BLACK, + weight: 4., + align: StrokeAlign::Center, + }); + assert_eq!(outline.matches(r#"stdDeviation="1""#).count(), 2, "{outline}"); + assert_eq!(outline.matches(r#"k2="1" k3="1""#).count(), 2, "placed within the content, then above what lies beneath it: {outline}"); + + // An inner bevel's height ramps up inside the edge, lit once each for highlight and shadow, steepened by the pixel scale + let bevel = EffectOperation::BevelAndEmboss { + style: BevelStyle::InnerBevel, + depth: 100., + direction: BevelDirection::Up, + size: 4., + soften: 0., + angle: 120., + altitude: 30., + highlight_color: Color::WHITE, + shadow_color: Color::BLACK, + }; + let mut bevel_defs = String::new(); + write_svg_effects_filter(&mut bevel_defs, &Effects::from_operations([bevel]), 1., [DVec2::ZERO, DVec2::splat(10.)], DAffine2::IDENTITY, 2., &[]).unwrap(); + assert!(bevel_defs.contains(r#"operator="erode" radius="2""#) && bevel_defs.contains(r#"stdDeviation="1""#), "{bevel_defs}"); + assert_eq!(bevel_defs.matches(r#"surfaceScale="4""#).count(), 2, "{bevel_defs}"); + + // A paint overlay embeds its paint's markup as an image of the filter region, then blends it over the content within the content's alpha + let overlay = EffectOperation::PaintOverlay { + paint: Graphic::from(Gradient::default()), + blend_mode: BlendMode::Multiply, + opacity: 50., + }; + let mut overlay_defs = String::new(); + let paints = [r#""#.to_string()]; + write_svg_effects_filter( + &mut overlay_defs, + &Effects::from_operations([overlay]), + 1., + [DVec2::ZERO, DVec2::splat(10.)], + DAffine2::IDENTITY, + 1., + &paints, + ) + .unwrap(); + assert!( + overlay_defs.contains(r#""#), "{overlay_defs}"); + assert!(overlay_defs.contains(r#"mode="multiply""#) && overlay_defs.contains(r#"operator="atop""#), "{overlay_defs}"); + } + + #[test] + fn svg_effects_filters_express_adjustments_where_svg_can() { + use graphic_types::adjustment::DesaturateMethod; + use graphic_types::{Adjustment, EffectOperation}; + + let filter = |adjustment| { + let mut defs = String::new(); + write_svg_effects_filter( + &mut defs, + &Effects::from_operations([EffectOperation::Adjustment(adjustment)]), + 1., + [DVec2::ZERO, DVec2::ONE], + DAffine2::IDENTITY, + 1., + &[], + ) + .unwrap(); + defs + }; + + // A per-channel adjustment becomes curves on the gamma-encoded channels, and Invert's run from white to black + let invert = filter(Adjustment::Invert); + assert!(invert.contains(r#"

(self); } @@ -121,19 +170,16 @@ impl WgpuExecutor { } pub fn with_context(context: WgpuContext) -> Option { - let vello_renderer = VelloRenderer::new(&context.device); + let vello_renderer = VelloRenderer::new(&context.device, &context.adapter); let texture_cache = TextureCache::new(TEXTURE_CACHE_SIZE); - let shader_runtime = ShaderRuntime::default(); - Some(Self { inner: Arc::new(WgpuExecutorInner { context, texture_cache: texture_cache.into(), vello_renderer, document_canvas: Mutex::default(), - shader_runtime, }), }) } diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs b/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs deleted file mode 100644 index 540ddf8c8af..00000000000 --- a/node-graph/libraries/wgpu-executor/src/shader_runtime/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::shader_runtime::per_pixel_adjust_runtime::PerPixelAdjustShaderRuntime; - -pub mod per_pixel_adjust_runtime; - -pub const FULLSCREEN_VERTEX_SHADER_NAME: &str = "fullscreen_vertex_fullscreen_vertex"; - -#[derive(Default)] -pub struct ShaderRuntime { - per_pixel_adjust: PerPixelAdjustShaderRuntime, -} diff --git a/node-graph/libraries/wgpu-executor/src/shader_runtime/per_pixel_adjust_runtime.rs b/node-graph/libraries/wgpu-executor/src/shader_runtime/per_pixel_adjust_runtime.rs deleted file mode 100644 index ffb4406efa1..00000000000 --- a/node-graph/libraries/wgpu-executor/src/shader_runtime/per_pixel_adjust_runtime.rs +++ /dev/null @@ -1,234 +0,0 @@ -use crate::shader_runtime::FULLSCREEN_VERTEX_SHADER_NAME; -use crate::{Buffer, WgpuContext, WgpuExecutor}; -use core_types::list::{Item, List}; -use core_types::shaders::buffer_struct::BufferStruct; -use glam::UVec2; -use raster_types::{GPU, Raster}; -use std::borrow::Cow; -use std::collections::HashMap; -use std::sync::Mutex; -use wgpu::util::BufferInitDescriptor; -use wgpu::{ - BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, BufferBinding, BufferBindingType, BufferUsages, ColorTargetState, Face, - FragmentState, FrontFace, LoadOp, Operations, PipelineLayoutDescriptor, PolygonMode, PrimitiveState, PrimitiveTopology, RenderPassColorAttachment, RenderPassDescriptor, RenderPipelineDescriptor, - ShaderModuleDescriptor, ShaderSource, ShaderStages, StoreOp, TextureFormat, TextureSampleType, TextureViewDescriptor, TextureViewDimension, VertexState, -}; - -pub struct PerPixelAdjustShaderRuntime { - // TODO: PerPixelAdjustGraphicsPipeline already contains the key as `name` - pipeline_cache: Mutex>, -} - -impl Default for PerPixelAdjustShaderRuntime { - fn default() -> Self { - Self::new() - } -} - -impl PerPixelAdjustShaderRuntime { - pub fn new() -> Self { - Self { - pipeline_cache: Mutex::new(HashMap::new()), - } - } -} - -impl WgpuExecutor { - pub fn run_per_pixel_adjust(&self, shaders: &Shaders<'_>, textures: List>, args: Option<&T>) -> List> { - let mut cache = self.inner.shader_runtime.per_pixel_adjust.pipeline_cache.lock().unwrap(); - let pipeline = cache - .entry(shaders.fragment_shader_name.to_owned()) - .or_insert_with(|| PerPixelAdjustGraphicsPipeline::new(self.context(), shaders)); - - let arg_buffer = args.map(|args| { - self.create_buffer_init(&BufferInitDescriptor { - label: Some(&format!("{} arg buffer", pipeline.name.as_str())), - usage: BufferUsages::STORAGE, - contents: bytemuck::bytes_of(&T::write(*args)), - }) - }); - pipeline.dispatch(self, textures, arg_buffer) - } -} - -pub struct Shaders<'a> { - pub wgsl_shader: &'a str, - pub fragment_shader_name: &'a str, - pub has_uniform: bool, -} - -pub struct PerPixelAdjustGraphicsPipeline { - name: String, - has_uniform: bool, - pipeline: wgpu::RenderPipeline, -} - -impl PerPixelAdjustGraphicsPipeline { - pub fn new(context: &WgpuContext, info: &Shaders) -> Self { - let device = &context.device; - let name = info.fragment_shader_name.to_owned(); - - let fragment_name = &name; - let fragment_name = &fragment_name[(fragment_name.find("::").unwrap() + 2)..]; - let fragment_name = fragment_name.replace("::", "_"); - let shader_module = device.create_shader_module(ShaderModuleDescriptor { - label: Some(&format!("PerPixelAdjust {name} wgsl shader")), - source: ShaderSource::Wgsl(Cow::Borrowed(info.wgsl_shader)), - }); - - let entries: &[_] = if info.has_uniform { - &[ - BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - sample_type: TextureSampleType::Float { filterable: false }, - view_dimension: TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - ] - } else { - &[BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - sample_type: TextureSampleType::Float { filterable: false }, - view_dimension: TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }] - }; - let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { - label: Some(&format!("PerPixelAdjust {name} PipelineLayout")), - bind_group_layouts: &[Some(&device.create_bind_group_layout(&BindGroupLayoutDescriptor { - label: Some(&format!("PerPixelAdjust {name} BindGroupLayout 0")), - entries, - }))], - ..Default::default() - }); - - let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { - label: Some(&format!("PerPixelAdjust {name} Pipeline")), - layout: Some(&pipeline_layout), - vertex: VertexState { - module: &shader_module, - entry_point: Some(FULLSCREEN_VERTEX_SHADER_NAME), - compilation_options: Default::default(), - buffers: &[], - }, - primitive: PrimitiveState { - topology: PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: FrontFace::Ccw, - cull_mode: Some(Face::Back), - unclipped_depth: false, - polygon_mode: PolygonMode::Fill, - conservative: false, - }, - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - fragment: Some(FragmentState { - module: &shader_module, - entry_point: Some(&fragment_name), - compilation_options: Default::default(), - targets: &[Some(ColorTargetState { - format: TextureFormat::Rgba8UnormSrgb, - blend: None, - write_mask: Default::default(), - })], - }), - cache: None, - }); - Self { - pipeline, - name, - has_uniform: info.has_uniform, - } - } - - pub fn dispatch(&self, executor: &WgpuExecutor, textures: List>, arg_buffer: Option) -> List> { - assert_eq!(self.has_uniform, arg_buffer.is_some()); - let device = &executor.context().device; - let name = self.name.as_str(); - - let mut cmd = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some(&format!("{name} cmd encoder")), - }); - let out = (0..textures.len()) - .map(|index| { - let element = textures.element(index).unwrap(); - let tex_in = &element.texture; - let view_in = tex_in.create_view(&TextureViewDescriptor::default()); - let format = tex_in.format(); - - let entries: &[_] = if let Some(arg_buffer) = arg_buffer.as_ref() { - &[ - BindGroupEntry { - binding: 0, - resource: BindingResource::Buffer(BufferBinding { - buffer: arg_buffer, - offset: 0, - size: None, - }), - }, - BindGroupEntry { - binding: 1, - resource: BindingResource::TextureView(&view_in), - }, - ] - } else { - &[BindGroupEntry { - binding: 0, - resource: BindingResource::TextureView(&view_in), - }] - }; - let bind_group = device.create_bind_group(&BindGroupDescriptor { - label: Some(&format!("{name} bind group")), - // `get_bind_group_layout` allocates unnecessary memory, we could create it manually to not do that - layout: &self.pipeline.get_bind_group_layout(0), - entries, - }); - - let tex_out = executor.request_texture_with_format(UVec2::new(tex_in.width(), tex_in.height()), format); - - let view_out = tex_out.create_view(&TextureViewDescriptor::default()); - let mut rp = cmd.begin_render_pass(&RenderPassDescriptor { - label: Some(&format!("{name} render pipeline")), - color_attachments: &[Some(RenderPassColorAttachment { - view: &view_out, - resolve_target: None, - ops: Operations { - // should be dont_care but wgpu doesn't expose that - load: LoadOp::Clear(wgpu::Color::BLACK), - store: StoreOp::Store, - }, - depth_slice: None, - })], - ..Default::default() - }); - rp.set_pipeline(&self.pipeline); - rp.set_bind_group(0, Some(&bind_group), &[]); - rp.draw(0..3, 0..1); - - let attributes = textures.clone_item_attributes(index); - Item::from_parts(Raster::new_gpu(tex_out), attributes) - }) - .collect::>(); - executor.context().queue.submit([cmd.finish()]); - out - } -} diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index 933a7d7109c..ce93577e358 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -147,7 +147,6 @@ impl RasterGpuToRasterCpuConverter { data: cpu_data, width: self.width, height: self.height, - base64_string: None, }; Ok(Raster::new_cpu(cpu_image)) diff --git a/node-graph/libraries/wgpu-executor/src/vello_effects.rs b/node-graph/libraries/wgpu-executor/src/vello_effects.rs new file mode 100644 index 00000000000..1520f3713a8 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/vello_effects.rs @@ -0,0 +1,1005 @@ +//! The GPU passes that carry out an item's effects on its content, between rendering the content offscreen and drawing the result back. + +use bytemuck::{Pod, Zeroable}; +use core_types::Color; +use core_types::graphene_hash::CacheHash; +use glam::{DMat2, DVec2, UVec2}; +use graphic_types::adjustment_tables::{AdjustmentPass, CUBE_SIZE, CURVE_TABLE_SIZE, DRAFT_CUBE_SIZE}; +use graphic_types::effects::{bevel_light, bevel_smoothing, outline_extents}; +use graphic_types::raster_types::effect_kernels::{BlurKernel, MAX_KERNEL_REACH, NEGLIGIBLE_LENGTH}; +use graphic_types::{Adjustment, BevelDirection, EffectOperation, Effects, GlowSource}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; +use wgpu::util::DeviceExt; + +/// The format of the images the passes produce, with the precision to keep soft gradients smooth. +pub(crate) const WORKING_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +/// The format of the silhouettes shadows and glows are cast from, which hold only coverage. +const COVERAGE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; + +/// The most taps one kernel pass weighs, matching the length of the shader's array, which holds a kernel of [`MAX_KERNEL_REACH`] texels to each side in pairs. +const MAX_TAPS: usize = 1 + 2 * MAX_KERNEL_REACH.div_ceil(2); + +/// How many renders a pooled texture survives unused before it is freed. +const UNUSED_TEXTURE_RENDER_LIMIT: u64 = 3; + +/// How many runs of adjustments keep their uploaded tables past the render that last used them, each holding up to a few megabytes. +const MAX_CACHED_ADJUSTMENT_RUNS: usize = 8; + +/// The shader's parameters, laid out to match its `Parameters` struct. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Parameters { + target_to_uv: [f32; 2], + step: [f32; 2], + offset: [f32; 2], + amount: f32, + tap_count: u32, + color: [f32; 4], + values: [f32; 4], + taps: [[f32; 4]; MAX_TAPS], +} + +impl Default for Parameters { + fn default() -> Self { + Self::zeroed() + } +} + +/// The kernel's taps for a texture halved `levels` times, each the distance and weight of a symmetric pair of taps that sample between two texels. +fn kernel_taps(kernel: BlurKernel, levels: u32) -> Vec<[f32; 4]> { + let weights = kernel.weights(levels); + + // Each tap between a pair of texels samples them in the ratio of their weights, through bilinear filtering + let mut taps = vec![[0., weights[0] as f32, 0., 0.]]; + for (pair_index, pair) in weights[1..].chunks(2).enumerate() { + let near_distance = (1 + 2 * pair_index) as f64; + let (distance, weight) = match *pair { + [near, far] => ((near_distance * near + (near_distance + 1.) * far) / (near + far), near + far), + [near] => (near_distance, near), + _ => continue, + }; + taps.push([distance as f32, weight as f32, 0., 0.]); + taps.push([-distance as f32, weight as f32, 0., 0.]); + } + + taps +} + +/// The pipelines of the effect passes, each built the first time it is used. +pub(crate) struct EffectPasses { + shader: wgpu::ShaderModule, + bind_group_layout: wgpu::BindGroupLayout, + pipeline_layout: wgpu::PipelineLayout, + sampler: wgpu::Sampler, + pipelines: Mutex>, + /// Whether the GPU can render into the passes' formats, which WebGL2 can't without `EXT_color_buffer_float`. + pub(crate) renders_working_formats: bool, +} + +impl EffectPasses { + pub(crate) fn new(device: &wgpu::Device, adapter: &wgpu::Adapter) -> Self { + let renders_working_formats = [WORKING_FORMAT, COVERAGE_FORMAT] + .into_iter() + .all(|format| adapter.get_texture_format_features(format).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)); + if !renders_working_formats { + log::warn!("This GPU can't render into the formats layer effects need, so content draws without its effects"); + } + + let shader = device.create_shader_module(wgpu::include_wgsl!("vello_effects.wgsl")); + + let texture_entry = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("vello_effects_bind_group_layout"), + entries: &[ + texture_entry(0), + texture_entry(1), + texture_entry(2), + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("vello_effects_pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + // Sampling past the edges repeats them, which is transparent since the content's offscreen area reaches past it by the effects' reach + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("vello_effects_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + Self { + shader, + bind_group_layout, + pipeline_layout, + sampler, + pipelines: Mutex::new(HashMap::new()), + renders_working_formats, + } + } + + fn pipeline(&self, device: &wgpu::Device, entry_point: &'static str, format: wgpu::TextureFormat) -> wgpu::RenderPipeline { + let mut pipelines = self.pipelines.lock().unwrap_or_else(PoisonError::into_inner); + + pipelines + .entry((entry_point, format)) + .or_insert_with(|| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry_point), + layout: Some(&self.pipeline_layout), + vertex: wgpu::VertexState { + module: &self.shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &self.shader, + entry_point: Some(entry_point), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }) + .clone() + } + + /// Carries out `effects` on `content`, a texture of premultiplied pixels, returning the result in a pooled texture of [`WORKING_FORMAT`] the same size. + /// `lengths_to_pixels` maps the effects' lengths onto the texture's pixels, and `fill` fades the content but not the shadows and glows cast from it. + /// `paints` holds the paint overlays' paints that aren't solid colors, in order, each rendered over the same pixels as the content. + #[expect(clippy::too_many_arguments)] + pub(crate) fn apply( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + textures: &mut EffectTextures, + content: &wgpu::Texture, + effects: &Effects, + fill: f32, + lengths_to_pixels: DMat2, + paints: &[wgpu::Texture], + ) -> wgpu::Texture { + let mut recorder = PassRecorder { + passes: self, + device, + encoder: device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vello_effects") }), + textures, + size: UVec2::new(content.width(), content.height()), + }; + let length_scale = lengths_to_pixels.determinant().abs().sqrt(); + + // The content at full strength, which casts every silhouette, and the content faded by the fill, which shows in the result + let mut full = recorder.scale(content, 1.); + let mut filled = (fill < 1.).then(|| recorder.scale(content, fill)); + // The shadows and glows stacked beneath the content + let mut beneath: Option = None; + + let operations: Vec = effects.operations().collect(); + let mut paints = paints.iter(); + for (index, operation) in operations.iter().enumerate() { + match *operation { + EffectOperation::Blur { radius, box_blur, gamma } => { + let radius = radius * length_scale; + if radius < NEGLIGIBLE_LENGTH { + continue; + } + + // What lies beneath blurs together with the content + if let Some(beneath) = beneath.take() { + if let Some(filled) = &mut filled { + let composited = recorder.composite_beneath(filled, &beneath, &full); + recorder.replace(filled, composited); + } + let composited = recorder.composite_beneath(&full, &beneath, &full); + recorder.replace(&mut full, composited); + recorder.textures.release(beneath); + } + + let kernel = if box_blur { BlurKernel::Box { radius } } else { BlurKernel::Gaussian { sigma: radius } }; + full = recorder.blur(full, kernel, !gamma); + filled = filled.map(|filled| recorder.blur(filled, kernel, !gamma)); + } + EffectOperation::DropShadow { color, offset, blur, spread } => { + let coverage = recorder.cast(&full, lengths_to_pixels * offset, blur * length_scale, spread * length_scale, false); + beneath = Some(recorder.accumulate(beneath, &coverage, color)); + recorder.textures.release(coverage); + } + EffectOperation::OuterGlow { color, blur, spread } => { + let coverage = recorder.cast(&full, DVec2::ZERO, blur * length_scale, spread * length_scale, false); + beneath = Some(recorder.accumulate(beneath, &coverage, color)); + recorder.textures.release(coverage); + } + EffectOperation::InnerShadow { color, offset, blur, spread } => { + let coverage = recorder.cast(&full, lengths_to_pixels * offset, blur * length_scale, spread * length_scale, true); + recorder.shade_within(&mut full, &mut filled, coverage, color); + } + EffectOperation::InnerGlow { color, blur, choke, source } => { + let mut coverage = recorder.cast(&full, DVec2::ZERO, blur * length_scale, choke * length_scale, true); + if source == GlowSource::Center { + let inverted = recorder.run("invert_coverage", COVERAGE_FORMAT, recorder.size, &coverage, None, None, Parameters::default()); + recorder.replace(&mut coverage, inverted); + } + recorder.shade_within(&mut full, &mut filled, coverage, color); + } + EffectOperation::Satin { color, offset, blur, invert } => { + let offset = lengths_to_pixels * offset; + let forward = recorder.cast(&full, offset, blur * length_scale, 0., false); + let backward = recorder.cast(&full, -offset, blur * length_scale, 0., false); + let parameters = Parameters { + amount: if invert { 1. } else { 0. }, + ..Default::default() + }; + let coverage = recorder.run("satin", COVERAGE_FORMAT, recorder.size, &forward, Some(&backward), None, parameters); + recorder.textures.release(forward); + recorder.textures.release(backward); + + recorder.shade_within(&mut full, &mut filled, coverage, color); + } + EffectOperation::Outline { color, weight, align } => { + let (outside, inside) = outline_extents(weight * length_scale, align); + if outside >= NEGLIGIBLE_LENGTH { + let coverage = recorder.edge_band(&full, outside, false); + beneath = Some(recorder.accumulate(beneath, &coverage, color)); + recorder.textures.release(coverage); + } + if inside >= NEGLIGIBLE_LENGTH { + let coverage = recorder.edge_band(&full, inside, true); + recorder.shade_within(&mut full, &mut filled, coverage, color); + } + } + EffectOperation::BevelAndEmboss { + style, + depth, + direction, + size, + soften, + angle, + altitude, + highlight_color, + shadow_color, + } => { + let (size, soften) = (size * length_scale, soften * length_scale); + if size < NEGLIGIBLE_LENGTH { + continue; + } + + // The surface's height, rounded by a blur, lit from the light's direction into its highlights and shadows + let to_inside = recorder.nearest_across_edge(&full, size, false); + let to_outside = recorder.nearest_across_edge(&full, size, true); + let parameters = Parameters { + values: [size as f32, style as u32 as f32, 0., 0.], + ..Default::default() + }; + let mut height = recorder.run("bevel_height", COVERAGE_FORMAT, recorder.size, &to_inside, Some(&to_outside), None, parameters); + recorder.textures.release(to_inside); + recorder.textures.release(to_outside); + if bevel_smoothing(size) >= NEGLIGIBLE_LENGTH { + height = recorder.blur(height, BlurKernel::Gaussian { sigma: bevel_smoothing(size) }, false); + } + let slope = depth / 100. * size * if direction == BevelDirection::Down { -1. } else { 1. }; + let light = bevel_light(angle, altitude, lengths_to_pixels).as_vec3(); + + // Their parts outside the content lie beneath it, and those inside are placed over it + for (shadow, color) in [(false, highlight_color), (true, shadow_color)] { + let parameters = Parameters { + amount: if shadow { 1. } else { 0. }, + values: [slope as f32, light.x, light.y, light.z], + ..Default::default() + }; + let mut coverage = recorder.run("bevel_shading", COVERAGE_FORMAT, recorder.size, &height, None, None, parameters); + if soften >= NEGLIGIBLE_LENGTH { + coverage = recorder.blur(coverage, BlurKernel::Gaussian { sigma: soften }, false); + } + + beneath = Some(recorder.accumulate(beneath, &coverage, color)); + recorder.shade_within(&mut full, &mut filled, coverage, color); + } + recorder.textures.release(height); + } + EffectOperation::PaintOverlay { blend_mode, opacity, .. } => { + let Some(paint) = paints.next() else { continue }; + let parameters = Parameters { + amount: (opacity / 100.) as f32, + values: [blend_mode as u32 as f32, 0., 0., 0.], + ..Default::default() + }; + + let overlaid = recorder.run("paint_overlay", WORKING_FORMAT, recorder.size, &full, Some(paint), None, parameters); + recorder.replace(&mut full, overlaid); + if let Some(filled) = &mut filled { + let overlaid = recorder.run("paint_overlay", WORKING_FORMAT, recorder.size, filled, Some(paint), None, parameters); + recorder.replace(filled, overlaid); + } + } + EffectOperation::Adjustment(_) => { + // A run of adjustments is carried out from its first row, so tables can merge it into fewer passes + if index > 0 && matches!(operations[index - 1], EffectOperation::Adjustment(_)) { + continue; + } + let run: Vec<&Adjustment> = operations[index..] + .iter() + .map_while(|operation| match operation { + EffectOperation::Adjustment(adjustment) => Some(adjustment), + _ => None, + }) + .collect(); + + for pass in recorder.textures.adjustment_passes(device, queue, &run).iter() { + let adjusted = recorder.adjust(&full, pass); + recorder.replace(&mut full, adjusted); + if let Some(filled) = &mut filled { + let adjusted = recorder.adjust(filled, pass); + recorder.replace(filled, adjusted); + } + } + } + } + } + + let result = match (filled, beneath) { + (filled, Some(beneath)) => { + let result = recorder.composite_beneath(filled.as_ref().unwrap_or(&full), &beneath, &full); + recorder.textures.release(beneath); + recorder.textures.release(full); + if let Some(filled) = filled { + recorder.textures.release(filled); + } + result + } + (Some(filled), None) => { + recorder.textures.release(full); + filled + } + (None, None) => full, + }; + + queue.submit([recorder.encoder.finish()]); + result + } + + /// Carries out adjustment `passes` on the top level of `image`, a texture of premultiplied pixels, into the top level of `target`, + /// then averages each of `target`'s smaller levels from the one above it like the levels of an uploaded image. + pub(crate) fn adjust_image(&self, device: &wgpu::Device, queue: &wgpu::Queue, textures: &mut EffectTextures, image: &wgpu::Texture, passes: &[AdjustmentPassTextures], target: &wgpu::Texture) { + let size = UVec2::new(image.width(), image.height()); + let mut recorder = PassRecorder { + passes: self, + device, + encoder: device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("vello_effects_adjust_image"), + }), + textures, + size, + }; + let level = |texture: &wgpu::Texture, level: u32| { + texture.create_view(&wgpu::TextureViewDescriptor { + base_mip_level: level, + mip_level_count: Some(1), + ..Default::default() + }) + }; + + // The passes take turns writing the target's top level and one scratch texture like it, ending on the target, so they need no larger texture + let view = |texture: &wgpu::Texture| texture.create_view(&wgpu::TextureViewDescriptor::default()); + let scratch = (passes.len() > 1).then(|| recorder.textures.acquire(device, size, target.format())); + let (target_top, scratch_view) = (level(target, 0), scratch.as_ref().map(view)); + let mut source = view(image); + for (index, pass) in passes.iter().enumerate() { + let destination = match (&scratch_view, (passes.len() - 1 - index) % 2) { + (Some(scratch_view), 1) => scratch_view, + _ => &target_top, + }; + + let (entry_point, secondary, content, parameters) = pass.shader_inputs(); + let (secondary, content) = (secondary.map(view), content.map(view)); + recorder.run_into(entry_point, target.format(), destination, size, &source, secondary.as_ref(), content.as_ref(), parameters); + source = destination.clone(); + } + if let Some(scratch) = scratch { + recorder.textures.release(scratch); + } + + self.record_mipmaps(device, &mut recorder.encoder, target); + queue.submit([recorder.encoder.finish()]); + } + + /// Records the averaging of each of `texture`'s smaller mip levels from the one above it, like the levels of an uploaded image. + pub(crate) fn record_mipmaps(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture) { + let level = |level: u32| { + texture.create_view(&wgpu::TextureViewDescriptor { + base_mip_level: level, + mip_level_count: Some(1), + ..Default::default() + }) + }; + + for index in 1..texture.mip_level_count() { + let level_size = UVec2::new(texture.width() >> index, texture.height() >> index).max(UVec2::ONE); + self.record( + device, + encoder, + "halve", + texture.format(), + &level(index), + level_size, + &level(index - 1), + None, + None, + Parameters::default(), + ); + } + } + + /// Records the shader's `entry_point` into `target`, a view of `format` and `size`, reading the `source` view and optionally the `secondary` and `content` views. + #[expect(clippy::too_many_arguments)] + fn record( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + entry_point: &'static str, + format: wgpu::TextureFormat, + target: &wgpu::TextureView, + size: UVec2, + source: &wgpu::TextureView, + secondary: Option<&wgpu::TextureView>, + content: Option<&wgpu::TextureView>, + parameters: Parameters, + ) { + let parameters = Parameters { + target_to_uv: [1. / size.x as f32, 1. / size.y as f32], + ..parameters + }; + + let (secondary, content) = (secondary.unwrap_or(source), content.unwrap_or(source)); + let parameters_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("vello_effects_parameters"), + contents: bytemuck::bytes_of(¶meters), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("vello_effects_bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(secondary), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(content), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: parameters_buffer.as_entire_binding(), + }, + ], + }); + + let pipeline = self.pipeline(device, entry_point, format); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(entry_point), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } +} + +/// A color as the premultiplied, gamma-encoded channels the passes work with. +fn premultiplied_gamma_channels(color: Color) -> [f32; 4] { + let [red, green, blue, alpha] = color.to_gamma_srgb_channels(); + [red * alpha, green * alpha, blue * alpha, alpha] +} + +/// Records the passes of one effects group, whose images all share one size. +struct PassRecorder<'a> { + passes: &'a EffectPasses, + device: &'a wgpu::Device, + encoder: wgpu::CommandEncoder, + textures: &'a mut EffectTextures, + size: UVec2, +} + +impl PassRecorder<'_> { + /// Runs the shader's `entry_point` into a new pooled texture of `format` and `size`, reading `source` and optionally the `secondary` and `content` inputs. + #[expect(clippy::too_many_arguments)] + fn run( + &mut self, + entry_point: &'static str, + format: wgpu::TextureFormat, + size: UVec2, + source: &wgpu::Texture, + secondary: Option<&wgpu::Texture>, + content: Option<&wgpu::Texture>, + parameters: Parameters, + ) -> wgpu::Texture { + let target = self.textures.acquire(self.device, size, format); + + let view = |texture: &wgpu::Texture| texture.create_view(&wgpu::TextureViewDescriptor::default()); + let (source, secondary, content) = (view(source), secondary.map(view), content.map(view)); + self.run_into(entry_point, format, &view(&target), size, &source, secondary.as_ref(), content.as_ref(), parameters); + + target + } + + /// Runs the shader's `entry_point` into `target`, a view of `format` and `size`, reading the `source` view and optionally the `secondary` and `content` views. + #[expect(clippy::too_many_arguments)] + fn run_into( + &mut self, + entry_point: &'static str, + format: wgpu::TextureFormat, + target: &wgpu::TextureView, + size: UVec2, + source: &wgpu::TextureView, + secondary: Option<&wgpu::TextureView>, + content: Option<&wgpu::TextureView>, + parameters: Parameters, + ) { + self.passes + .record(self.device, &mut self.encoder, entry_point, format, target, size, source, secondary, content, parameters); + } + + /// Puts `next` in place of the texture in `slot`, returning the old one to the pool. + fn replace(&mut self, slot: &mut wgpu::Texture, next: wgpu::Texture) { + self.textures.release(std::mem::replace(slot, next)); + } + + /// A copy of `source` in the working format, multiplied by `amount`. + fn scale(&mut self, source: &wgpu::Texture, amount: f32) -> wgpu::Texture { + self.run("scale", WORKING_FORMAT, self.size, source, None, None, Parameters { amount, ..Default::default() }) + } + + /// The coverage of the content's silhouette (or of the area outside it, if `inverse`), cast by `offset`, grown by `spread` and softened by `blur`, all in pixels. + fn cast(&mut self, content: &wgpu::Texture, offset: DVec2, blur: f64, spread: f64, inverse: bool) -> wgpu::Texture { + let offset = offset / self.size.as_dvec2(); + let parameters = Parameters { + offset: [offset.x as f32, offset.y as f32], + ..Default::default() + }; + let entry_point = if inverse { "cast_inverse_silhouette" } else { "cast_silhouette" }; + let mut coverage = self.run(entry_point, COVERAGE_FORMAT, self.size, content, None, None, parameters); + + if spread >= NEGLIGIBLE_LENGTH { + coverage = self.dilate(coverage, spread); + } + if blur >= NEGLIGIBLE_LENGTH { + coverage = self.blur(coverage, BlurKernel::Gaussian { sigma: blur }, false); + } + + coverage + } + + /// Stacks a silhouette's coverage, tinted `color`, over what lies `beneath` the content so far. + fn accumulate(&mut self, beneath: Option, coverage: &wgpu::Texture, color: Color) -> wgpu::Texture { + let parameters = Parameters { + color: premultiplied_gamma_channels(color), + ..Default::default() + }; + + let Some(beneath) = beneath else { + return self.run("tint", WORKING_FORMAT, self.size, coverage, None, None, parameters); + }; + let accumulated = self.run("accumulate", WORKING_FORMAT, self.size, &beneath, Some(coverage), None, parameters); + self.textures.release(beneath); + + accumulated + } + + /// The image with a silhouette's coverage, tinted `color`, placed over it within the full-strength content. + fn composite_within(&mut self, image: &wgpu::Texture, coverage: &wgpu::Texture, full_content: &wgpu::Texture, color: Color) -> wgpu::Texture { + let parameters = Parameters { + color: premultiplied_gamma_channels(color), + ..Default::default() + }; + self.run("composite_within", WORKING_FORMAT, self.size, image, Some(coverage), Some(full_content), parameters) + } + + /// Places a silhouette's coverage, tinted `color`, over both the full-strength content and the faded fill, within the content, then frees the coverage. + fn shade_within(&mut self, full: &mut wgpu::Texture, filled: &mut Option, coverage: wgpu::Texture, color: Color) { + if let Some(filled) = filled { + let composited = self.composite_within(filled, &coverage, full, color); + self.replace(filled, composited); + } + let composited = self.composite_within(full, &coverage, full, color); + self.replace(full, composited); + self.textures.release(coverage); + } + + /// The coverage of the band reaching `width` pixels from the content's silhouette edge, outward or (if `inward`) inward, measured in straight lines so it rounds corners. + fn edge_band(&mut self, content: &wgpu::Texture, width: f64, inward: bool) -> wgpu::Texture { + let nearest = self.nearest_across_edge(content, width, inward); + + let parameters = Parameters { + values: [width as f32, 0., 0., 0.], + ..Default::default() + }; + let band = self.run("edge_band", COVERAGE_FORMAT, self.size, &nearest, None, None, parameters); + self.textures.release(nearest); + band + } + + /// Each texel's offset to the nearest texel across the content's silhouette edge: outside it if `inward`, or inside it otherwise, in the layout `jump_flood_step` keeps. + /// Jump flooding finds them in a pass for each halving of a step that starts past `reach`, so farther texels may find none. + fn nearest_across_edge(&mut self, content: &wgpu::Texture, reach: f64, inward: bool) -> wgpu::Texture { + let parameters = Parameters { + amount: if inward { 1. } else { 0. }, + ..Default::default() + }; + let mut nearest = self.run("jump_flood_seed", WORKING_FORMAT, self.size, content, None, None, parameters); + + let mut step = (reach.max(0.).ceil() as u32 + 1).next_power_of_two(); + while step >= 1 { + let parameters = Parameters { + values: [step as f32, 0., 0., 0.], + ..Default::default() + }; + let flooded = self.run("jump_flood_step", WORKING_FORMAT, self.size, &nearest, None, None, parameters); + self.replace(&mut nearest, flooded); + step /= 2; + } + + nearest + } + + /// The image with the shadows and glows `beneath` placed under it, knocked out by the full-strength content. + fn composite_beneath(&mut self, image: &wgpu::Texture, beneath: &wgpu::Texture, full_content: &wgpu::Texture) -> wgpu::Texture { + self.run("composite_beneath", WORKING_FORMAT, self.size, image, Some(beneath), Some(full_content), Parameters::default()) + } + + /// The image with one pass of adjustments carried out on it. + fn adjust(&mut self, image: &wgpu::Texture, pass: &AdjustmentPassTextures) -> wgpu::Texture { + let (entry_point, secondary, content, parameters) = pass.shader_inputs(); + self.run(entry_point, WORKING_FORMAT, self.size, image, secondary, content, parameters) + } + + /// Grows the shapes in `texture` by `distance` pixels along each axis, in a square like SVG's `feMorphology`. + fn dilate(&mut self, texture: wgpu::Texture, distance: f64) -> wgpu::Texture { + // Steps that double, then the remainder, so each pass grows the shapes by up to as much as all the passes before it + let mut steps = Vec::new(); + let (mut covered, mut step) = (0., 1.); + while covered + step <= distance { + steps.push(step); + covered += step; + step *= 2.; + } + if distance - covered >= NEGLIGIBLE_LENGTH { + steps.push(distance - covered); + } + + let format = texture.format(); + let mut texture = texture; + for step in steps { + for axis in [DVec2::X, DVec2::Y] { + let step = axis * step / self.size.as_dvec2(); + let parameters = Parameters { + step: [step.x as f32, step.y as f32], + ..Default::default() + }; + let dilated = self.run("dilate", format, self.size, &texture, None, None, parameters); + self.replace(&mut texture, dilated); + } + } + + texture + } + + /// Blurs `texture` by `kernel` along both axes, in linear light if `linear`. + /// Kernels too wide for one pass run on a texture halved enough times for them to fit, then doubled back to full size. O(width × height) per pass. + fn blur(&mut self, texture: wgpu::Texture, kernel: BlurKernel, linear: bool) -> wgpu::Texture { + let format = texture.format(); + let mut texture = texture; + + if linear { + let decoded = self.run("decode_to_linear", format, self.size, &texture, None, None, Parameters::default()); + self.replace(&mut texture, decoded); + } + + let levels = kernel.levels(); + let size = self.size; + let level_size = |level: u32| UVec2::new(size.x.div_ceil(1 << level), size.y.div_ceil(1 << level)); + + for level in 1..=levels { + let halved = self.run("resample", format, level_size(level), &texture, None, None, Parameters::default()); + self.replace(&mut texture, halved); + } + + let taps = kernel_taps(kernel, levels); + let mut tap_array = [[0.; 4]; MAX_TAPS]; + tap_array[..taps.len()].copy_from_slice(&taps); + for axis in [DVec2::X, DVec2::Y] { + let step = axis / level_size(levels).as_dvec2(); + let parameters = Parameters { + step: [step.x as f32, step.y as f32], + tap_count: taps.len() as u32, + taps: tap_array, + ..Default::default() + }; + let convolved = self.run("convolve", format, level_size(levels), &texture, None, None, parameters); + self.replace(&mut texture, convolved); + } + + for level in (0..levels).rev() { + let doubled = self.run("resample", format, level_size(level), &texture, None, None, Parameters::default()); + self.replace(&mut texture, doubled); + } + + if linear { + let encoded = self.run("encode_to_gamma", format, self.size, &texture, None, None, Parameters::default()); + self.replace(&mut texture, encoded); + } + + texture + } +} + +/// Textures for the effect passes and the content they work on, kept between renders for reuse at the same sizes. +#[derive(Default)] +pub(crate) struct EffectTextures { + /// Each unused texture with the render it was last used in. + free: Vec<(wgpu::Texture, u64)>, + /// The passes of recent runs of adjustments. + adjustment_runs: Vec, + render_count: u64, +} + +/// The uploaded passes of one run of adjustments. +struct CachedAdjustmentRun { + hash: u64, + passes: Arc>, + /// Whether its cubes are drafts, baked again at full size once a later render reuses the run unchanged. + draft: bool, + last_used: u64, +} + +/// Identifies a run of adjustments by its settings. +pub(crate) fn adjustment_run_hash(run: &[&Adjustment]) -> u64 { + let mut hasher = std::hash::DefaultHasher::new(); + run.iter().for_each(|adjustment| adjustment.cache_hash(&mut hasher)); + std::hash::Hasher::finish(&hasher) +} + +/// One pass of adjustments, with the tables it looks colors up in uploaded as textures. +pub(crate) enum AdjustmentPassTextures { + Curves(wgpu::Texture), + Cube { size: usize, cube: wgpu::Texture, alpha: wgpu::Texture }, + Luma { weights: [f32; 3], colors: wgpu::Texture }, + Vibrance { vibrance: f32, saturation_scale: f32 }, +} + +impl AdjustmentPassTextures { + /// The shader entry point carrying out the pass, the tables it reads as its secondary and content inputs, and its parameters. + fn shader_inputs(&self) -> (&'static str, Option<&wgpu::Texture>, Option<&wgpu::Texture>, Parameters) { + let (entry_point, secondary, content, values) = match self { + Self::Curves(curves) => ("adjust_curves", Some(curves), None, [0.; 4]), + Self::Cube { size, cube, alpha } => ("adjust_cube", Some(cube), Some(alpha), [*size as f32, 0., 0., 0.]), + Self::Luma { weights, colors } => ("adjust_by_luma", Some(colors), None, [weights[0], weights[1], weights[2], 0.]), + Self::Vibrance { vibrance, saturation_scale } => ("adjust_vibrance", None, None, [*vibrance, *saturation_scale, 0., 0.]), + }; + (entry_point, secondary, content, Parameters { values, ..Default::default() }) + } + + fn upload(device: &wgpu::Device, queue: &wgpu::Queue, pass: AdjustmentPass) -> Self { + let table = |width: usize, height: usize, entries: &mut dyn Iterator| { + let data: Vec = entries.flat_map(|entry| entry.map(half::f16::from_f32)).collect(); + let descriptor = wgpu::TextureDescriptor { + label: Some("vello_effects_adjustment_table"), + size: wgpu::Extent3d { + width: width as u32, + height: height as u32, + 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 | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }; + device.create_texture_with_data(queue, &descriptor, wgpu::util::TextureDataOrder::LayerMajor, bytemuck::cast_slice(&data)) + }; + + match pass { + AdjustmentPass::Curves(curves) => Self::Curves(table(CURVE_TABLE_SIZE, 1, &mut curves.into_iter())), + // The cube's slices of constant blue, each a square of red across and green down, tile a texture near square in shape + AdjustmentPass::Cube { size, colors, alpha } => { + let columns = size.isqrt() + usize::from(size.isqrt().pow(2) < size); + let rows = size.div_ceil(columns); + let (width, height) = (columns * size, rows * size); + + let mut tiled = vec![[0.; 4]; width * height]; + for (index, [red, green, blue]) in colors.into_iter().enumerate() { + let (r, g, b) = (index % size, index / size % size, index / (size * size)); + let (x, y) = (b % columns * size + r, b / columns * size + g); + tiled[y * width + x] = [red, green, blue, 1.]; + } + + Self::Cube { + size, + cube: table(width, height, &mut tiled.into_iter()), + alpha: table(CURVE_TABLE_SIZE, 1, &mut alpha.into_iter().map(|alpha| [alpha; 4])), + } + } + AdjustmentPass::Luma { weights, colors } => Self::Luma { + weights, + colors: table(CURVE_TABLE_SIZE, 1, &mut colors.into_iter().map(|[red, green, blue]| [red, green, blue, 1.])), + }, + AdjustmentPass::Vibrance { vibrance, saturation_scale } => Self::Vibrance { vibrance, saturation_scale }, + } + } +} + +impl EffectTextures { + /// The passes that carry out a run of adjustments, baking and uploading their tables only when the run hasn't been seen recently. + /// A new run is likely changing every frame, as when a slider drags, so its cubes start as quick drafts that a later render refines. + pub(crate) fn adjustment_passes(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, run: &[&Adjustment]) -> Arc> { + let hash = adjustment_run_hash(run); + + let bake = |cube_size: usize| { + let run: Vec = run.iter().map(|&adjustment| adjustment.clone()).collect(); + let passes = AdjustmentPass::plan(&run, cube_size); + let has_cube = AdjustmentPass::any_cube(&passes); + let textures = passes.into_iter().map(|pass| AdjustmentPassTextures::upload(device, queue, pass)).collect::>(); + (Arc::new(textures), has_cube) + }; + + if let Some(cached) = self.adjustment_runs.iter_mut().find(|cached| cached.hash == hash) { + let reused_in_later_render = cached.last_used < self.render_count; + cached.last_used = self.render_count; + + if cached.draft && reused_in_later_render { + (cached.passes, _) = bake(CUBE_SIZE); + cached.draft = false; + } + return cached.passes.clone(); + } + + let (passes, draft) = bake(DRAFT_CUBE_SIZE); + self.adjustment_runs.push(CachedAdjustmentRun { + hash, + passes: passes.clone(), + draft, + last_used: self.render_count, + }); + + passes + } + + pub(crate) fn acquire(&mut self, device: &wgpu::Device, size: UVec2, format: wgpu::TextureFormat) -> wgpu::Texture { + let size = size.max(UVec2::ONE); + let matches = |texture: &wgpu::Texture| texture.width() == size.x && texture.height() == size.y && texture.format() == format; + if let Some(index) = self.free.iter().position(|(texture, _)| matches(texture)) { + return self.free.swap_remove(index).0; + } + + device.create_texture(&wgpu::TextureDescriptor { + label: Some("vello_effects_texture"), + size: wgpu::Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + } + + /// Returns a texture to the pool once the passes recorded so far are done with it, since later passes are recorded and submitted after them. + pub(crate) fn release(&mut self, texture: wgpu::Texture) { + self.free.push((texture, self.render_count)); + } + + /// Frees the textures no recent render has used. + pub(crate) fn finish_render(&mut self) { + let render_count = self.render_count; + self.free.retain(|(_, last_used)| render_count - last_used < UNUSED_TEXTURE_RENDER_LIMIT); + + // Dragging a slider makes a new run every frame, so the runs this render didn't use make way rather than piling up, least recent first + self.adjustment_runs.sort_by_key(|cached| std::cmp::Reverse(cached.last_used)); + let used_this_render = self.adjustment_runs.iter().take_while(|cached| cached.last_used == render_count).count(); + self.adjustment_runs.truncate(used_this_render.max(MAX_CACHED_ADJUSTMENT_RUNS)); + + self.render_count += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sum of the weights of all taps, which must be one so blurring keeps the total brightness. + fn total_weight(taps: &[[f32; 4]]) -> f32 { + taps.iter().map(|tap| tap[1]).sum() + } + + #[test] + fn kernels_keep_brightness_and_fit_one_pass() { + let kernels = [0.3, 1., 2.5, 7.9, 8., 12., 100., 4000.] + .into_iter() + .flat_map(|length| [BlurKernel::Gaussian { sigma: length }, BlurKernel::Box { radius: length }]); + + for kernel in kernels { + let taps = kernel_taps(kernel, kernel.levels()); + + assert!(taps.len() <= MAX_TAPS, "{kernel:?} needs {} taps", taps.len()); + assert!((total_weight(&taps) - 1.).abs() < 1e-5, "{kernel:?} weighs {}", total_weight(&taps)); + } + } + + #[test] + fn merged_taps_sample_between_the_texels_they_weigh() { + // A box of half width two weighs five texels evenly, so its outer pair merges into taps one and a half texels out + let taps = kernel_taps(BlurKernel::Box { radius: 2. }, 0); + + assert_eq!(taps.len(), 3); + assert!((taps[0][1] - 0.2).abs() < 1e-6); + assert!((taps[1][0] - 1.5).abs() < 1e-6 && (taps[1][1] - 0.4).abs() < 1e-6); + assert!((taps[2][0] + 1.5).abs() < 1e-6); + } +} diff --git a/node-graph/libraries/wgpu-executor/src/vello_effects.wgsl b/node-graph/libraries/wgpu-executor/src/vello_effects.wgsl new file mode 100644 index 00000000000..919fe5074fa --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/vello_effects.wgsl @@ -0,0 +1,666 @@ +// The passes that carry out an item's effects on the pixels of its content, which are premultiplied with gamma-encoded color like the pixels Vello GPU blends + +struct Parameters { + // Converts a target pixel's position into the normalized coordinates the inputs are sampled at + target_to_uv: vec2, + // One step along the axis a kernel runs, in normalized coordinates + step: vec2, + // How far a silhouette is cast, in normalized coordinates + offset: vec2, + // The factor the scale pass multiplies by + amount: f32, + tap_count: u32, + // The premultiplied color a silhouette is tinted + color: vec4, + // The constants of an adjustment pass that computes rather than looks up its colors + values: vec4, + // Each kernel tap's distance in steps (x) and weight (y) + taps: array, 25>, +} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var secondary: texture_2d; +@group(0) @binding(2) var content: texture_2d; +@group(0) @binding(3) var linear_sampler: sampler; +@group(0) @binding(4) var parameters: Parameters; + +// One triangle that covers the whole target +@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.); +} + +fn sample_source(uv: vec2) -> vec4 { + return textureSampleLevel(source, linear_sampler, uv, 0.); +} + +// A separable kernel along one axis, whose taps sit between texels to weight two of them at once +@fragment +fn convolve(@builtin(position) position: vec4) -> @location(0) vec4 { + let uv = position.xy * parameters.target_to_uv; + + var sum = vec4(0.); + for (var i = 0u; i < parameters.tap_count; i++) { + let tap = parameters.taps[i]; + sum += sample_source(uv + parameters.step * tap.x) * tap.y; + } + return sum; +} + +// Halving a texture samples the corner shared by each block of four texels, averaging them, and doubling it interpolates between texels +@fragment +fn resample(@builtin(position) position: vec4) -> @location(0) vec4 { + return sample_source(position.xy * parameters.target_to_uv); +} + +// Grows the shapes by one step along an axis, so steps that double each time grow them by any distance in a few passes +@fragment +fn dilate(@builtin(position) position: vec4) -> @location(0) vec4 { + let uv = position.xy * parameters.target_to_uv; + return max(sample_source(uv), max(sample_source(uv - parameters.step), sample_source(uv + parameters.step))); +} + +// The coverage of the content's silhouette, shifted by the offset +@fragment +fn cast_silhouette(@builtin(position) position: vec4) -> @location(0) vec4 { + return vec4(sample_source(position.xy * parameters.target_to_uv - parameters.offset).a); +} + +// The coverage of the area outside the content's silhouette, shifted by the offset +@fragment +fn cast_inverse_silhouette(@builtin(position) position: vec4) -> @location(0) vec4 { + return vec4(1. - sample_source(position.xy * parameters.target_to_uv - parameters.offset).a); +} + +// The coverage of where a silhouette doesn't reach, which turns an inner glow from the edges into one from the center +@fragment +fn invert_coverage(@builtin(position) position: vec4) -> @location(0) vec4 { + return vec4(1. - textureLoad(source, vec2(position.xy), 0).r); +} + +// Satin's shading from two casts of the silhouette: how much one covers without the other, or how much they agree with `amount` 1 +@fragment +fn satin(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let difference = abs(textureLoad(source, pixel, 0).r - textureLoad(secondary, pixel, 0).r); + return vec4(select(difference, 1. - difference, parameters.amount > 0.5)); +} + +// Jump flooding keeps each texel's offset to the nearest seed found so far (xy) and whether one is found (z). +// The seeds are the texels inside the content's silhouette, or outside it with `amount` 1, and each is its own nearest. +@fragment +fn jump_flood_seed(@builtin(position) position: vec4) -> @location(0) vec4 { + let inside = textureLoad(source, vec2(position.xy), 0).a >= 0.5; + let seed = inside != (parameters.amount > 0.5); + return vec4(0., 0., select(0., 1., seed), 0.); +} + +// Takes the nearest of the seeds that the texel and its eight neighbors `values.x` texels away have found so far +@fragment +fn jump_flood_step(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let size = vec2(textureDimensions(source)); + let step = i32(parameters.values.x); + + var best = textureLoad(source, pixel, 0); + for (var neighbor_y = -1; neighbor_y <= 1; neighbor_y++) { + for (var neighbor_x = -1; neighbor_x <= 1; neighbor_x++) { + let direction = vec2(neighbor_x, neighbor_y); + let neighbor = pixel + direction * step; + if any(neighbor < vec2(0)) || any(neighbor >= size) { + continue; + } + let found = textureLoad(source, neighbor, 0); + if found.z < 0.5 { + continue; + } + + let candidate = found.xy + vec2(direction * step); + if best.z < 0.5 || dot(candidate, candidate) < dot(best.xy, best.xy) { + best = vec4(candidate, 1., 0.); + } + } + } + return best; +} + +// Covers the texels within `values.x` texels of the edge, which lies about half a texel short of the nearest seed, fading out over one texel +@fragment +fn edge_band(@builtin(position) position: vec4) -> @location(0) vec4 { + let nearest = textureLoad(source, vec2(position.xy), 0); + if nearest.z < 0.5 { + return vec4(0.); + } + return vec4(clamp(parameters.values.x + 1. - length(nearest.xy), 0., 1.)); +} + +// A bevel's height from 0 to 1, shaped by the style in `values.y` over a slope `values.x` texels wide, from the nearest texels inside (source) and outside (secondary) +@fragment +fn bevel_height(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let to_inside = textureLoad(source, pixel, 0); + let to_outside = textureLoad(secondary, pixel, 0); + let size = parameters.values.x; + + // Texels inside the silhouette are those at no distance from it, and the edge lies about half a texel short of the nearest texel across it + let far = 4. * size + 4.; + var signed_distance: f32; + if to_inside.z > 0.5 && dot(to_inside.xy, to_inside.xy) == 0. { + signed_distance = select(far, length(to_outside.xy) - 0.5, to_outside.z > 0.5); + } else { + signed_distance = select(-far, 0.5 - length(to_inside.xy), to_inside.z > 0.5); + } + + var progress: f32; + switch u32(parameters.values.y) { + case 0u: { + progress = signed_distance / size; + } + case 1u: { + progress = (signed_distance + size) / size; + } + case 2u: { + progress = (signed_distance + size / 2.) / size; + } + default: { + progress = abs(signed_distance) / (size / 2.); + } + } + let height = clamp(progress, 0., 1.); + return vec4(height * height * (3. - 2. * height)); +} + +fn height_at(pixel: vec2) -> f32 { + return textureLoad(source, clamp(pixel, vec2(0), vec2(textureDimensions(source)) - 1), 0).r; +} + +// How far the light toward `values.yzw` brightens the bevel's surface past a flat surface's brightness, or with `amount` 1, how far it falls short. +// `values.x` scales the height's gradient into the surface's steepness, and is negative for a bevel pressed into the surface. +@fragment +fn bevel_shading(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let gradient = vec2( + height_at(pixel + vec2(1, 0)) - height_at(pixel - vec2(1, 0)), + height_at(pixel + vec2(0, 1)) - height_at(pixel - vec2(0, 1)), + ) / 2.; + let light = parameters.values.yzw; + let shade = dot(normalize(vec3(-parameters.values.x * gradient, 1.)), light); + + if parameters.amount > 0.5 { + return vec4(clamp((light.z - shade) / max(light.z, 1e-4), 0., 1.)); + } + return vec4(clamp((shade - light.z) / max(1. - light.z, 1e-4), 0., 1.)); +} + +// A silhouette's coverage in its color +@fragment +fn tint(@builtin(position) position: vec4) -> @location(0) vec4 { + return parameters.color * textureLoad(source, vec2(position.xy), 0).r; +} + +// Stacks a tinted silhouette over the shadows and glows beneath the content +@fragment +fn accumulate(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let beneath = textureLoad(source, pixel, 0); + let tinted = parameters.color * textureLoad(secondary, pixel, 0).r; + + return tinted + beneath * (1. - tinted.a); +} + +// Places a tinted silhouette over the image, within the content at full strength so it stays visible over a faded fill +@fragment +fn composite_within(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let image = textureLoad(source, pixel, 0); + let tinted = parameters.color * textureLoad(secondary, pixel, 0).r; + let full_content = textureLoad(content, pixel, 0); + + return tinted * full_content.a + image * (1. - tinted.a); +} + +// Places the shadows and glows beneath the image, knocked out by the content at full strength so a faded fill doesn't reveal them +@fragment +fn composite_beneath(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let image = textureLoad(source, pixel, 0); + let beneath = textureLoad(secondary, pixel, 0); + let full_content = textureLoad(content, pixel, 0); + + return image + beneath * (1. - full_content.a); +} + +// The image (source) with the paint (secondary input) mixed over its colors by the blend mode in `values.x`, as much as the paint's alpha times `amount`, keeping the image's alpha +@fragment +fn paint_overlay(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let image = textureLoad(source, pixel, 0); + let paint = textureLoad(secondary, pixel, 0); + if image.a <= 0. { + return vec4(0.); + } + if paint.a <= 0. { + return image; + } + + // The blend modes work on straight linear colors, as the CPU's do + let backdrop = srgb_to_linear(image.rgb / image.a); + let blended = blend(backdrop, srgb_to_linear(paint.rgb / paint.a), u32(parameters.values.x)); + let mixed = mix(backdrop, blended, parameters.amount * paint.a); + + return vec4(linear_to_srgb(mixed) * image.a, image.a); +} + +// The paint mixed with the backdrop by a blend mode, numbered in the order `BlendMode` declares them, matching `apply_blend_mode` +fn blend(backdrop: vec3, paint: vec3, mode: u32) -> vec3 { + let average = vec3(1. / 3.); + switch mode { + // Darker Color and Lighter Color keep whichever whole color has the lower or higher mean + case 5u: { + return select(paint, backdrop, dot(backdrop, average) <= dot(paint, average)); + } + case 10u: { + return select(paint, backdrop, dot(backdrop, average) >= dot(paint, average)); + } + // The component modes are defined on gamma-encoded channels + case 22u, 23u, 24u, 25u: { + return srgb_to_linear(blend_components(clamp(linear_to_srgb(backdrop), vec3(0.), vec3(1.)), clamp(linear_to_srgb(paint), vec3(0.), vec3(1.)), mode)); + } + default: { + return vec3(blend_channel(backdrop.r, paint.r, mode), blend_channel(backdrop.g, paint.g, mode), blend_channel(backdrop.b, paint.b, mode)); + } + } +} + +// One channel mixed by a separable blend mode, clamped to 0 to 1 +fn blend_channel(backdrop: f32, paint: f32, mode: u32) -> f32 { + var result: f32; + switch mode { + // Darken, Multiply, Color Burn, Linear Burn + case 1u: { + result = min(paint, backdrop); + } + case 2u: { + result = paint * backdrop; + } + case 3u: { + result = color_burn(backdrop, paint); + } + case 4u: { + result = backdrop + paint - 1.; + } + // Lighten, Screen, Color Dodge, Linear Dodge + case 6u: { + result = max(paint, backdrop); + } + case 7u: { + result = screen(backdrop, paint); + } + case 8u: { + result = color_dodge(backdrop, paint); + } + case 9u: { + result = backdrop + paint; + } + // Overlay, Soft Light, Hard Light, Vivid Light, Linear Light, Pin Light, Hard Mix + case 11u: { + result = hard_light(paint, backdrop); + } + case 12u: { + result = soft_light(backdrop, paint); + } + case 13u: { + result = hard_light(backdrop, paint); + } + case 14u: { + result = select(color_dodge(2. * paint - 1., backdrop), color_burn(2. * paint, backdrop), paint <= 0.5); + } + case 15u: { + result = linear_light(backdrop, paint); + } + case 16u: { + result = select(max(2. * paint - 1., backdrop), min(2. * paint, backdrop), paint <= 0.5); + } + case 17u: { + result = select(1., 0., linear_light(backdrop, paint) < 0.5); + } + // Difference, Exclusion, Subtract, Divide + case 18u: { + result = abs(backdrop - paint); + } + case 19u: { + result = backdrop + paint - 2. * backdrop * paint; + } + case 20u: { + result = backdrop - paint; + } + case 21u: { + result = select(backdrop / paint, 1., backdrop == 0.); + } + // Normal, and the alpha-only utility modes, which mix no color + default: { + result = paint; + } + } + return clamp(result, 0., 1.); +} + +fn screen(backdrop: f32, paint: f32) -> f32 { + return 1. - (1. - paint) * (1. - backdrop); +} + +fn color_burn(backdrop: f32, paint: f32) -> f32 { + if backdrop == 1. { + return 1.; + } + if paint == 0. { + return 0.; + } + return 1. - min((1. - backdrop) / paint, 1.); +} + +fn color_dodge(backdrop: f32, paint: f32) -> f32 { + if paint == 1. { + return 1.; + } + return min(backdrop / (1. - paint), 1.); +} + +fn hard_light(backdrop: f32, paint: f32) -> f32 { + return select(screen(2. * paint - 1., backdrop), 2. * paint * backdrop, paint <= 0.5); +} + +fn soft_light(backdrop: f32, paint: f32) -> f32 { + if paint <= 0.5 { + return backdrop - (1. - 2. * paint) * backdrop * (1. - backdrop); + } + let curve = select(sqrt(backdrop), ((16. * backdrop - 12.) * backdrop + 4.) * backdrop, backdrop <= 0.25); + return backdrop + (2. * paint - 1.) * (curve - backdrop); +} + +fn linear_light(backdrop: f32, paint: f32) -> f32 { + return select(2. * paint - 1. + backdrop, 2. * paint + backdrop - 1., paint <= 0.5); +} + +// Hue, Saturation, Color, and Luminosity on gamma-encoded channels, as the compositing specification constructs them +fn blend_components(backdrop: vec3, paint: vec3, mode: u32) -> vec3 { + switch mode { + case 22u: { + let hued = set_saturation(paint, channel_range(backdrop)); + return set_luminosity(hued, luma(hued), luma(backdrop)); + } + case 23u: { + let saturated = set_saturation(backdrop, channel_range(paint)); + return set_luminosity(saturated, luma(saturated), luma(backdrop)); + } + case 24u: { + return set_luminosity(paint, luma(paint), luma(backdrop)); + } + default: { + return set_luminosity(backdrop, luma(backdrop), luma(paint)); + } + } +} + +fn luma(color: vec3) -> f32 { + return dot(color, vec3(0.3, 0.59, 0.11)); +} + +fn channel_range(color: vec3) -> f32 { + return max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b); +} + +fn set_saturation(color: vec3, saturation: f32) -> vec3 { + let range = channel_range(color); + if range <= 0. { + return vec3(0.); + } + return (color - min(min(color.r, color.g), color.b)) * (saturation / range); +} + +// Shifts the channels from `from_luma` to `luminosity`, then pulls them toward it just enough to bring every channel back into 0 to 1 +fn set_luminosity(color: vec3, from_luma: f32, luminosity: f32) -> vec3 { + var channels = color + (luminosity - from_luma); + + let low = min(min(channels.r, channels.g), channels.b); + if low < 0. { + channels = luminosity + (channels - luminosity) * (luminosity / (luminosity - low)); + } + let high = max(max(channels.r, channels.g), channels.b); + if high > 1. { + channels = luminosity + (channels - luminosity) * ((1. - luminosity) / (high - luminosity)); + } + + return clamp(channels, vec3(0.), vec3(1.)); +} + +@fragment +fn scale(@builtin(position) position: vec4) -> @location(0) vec4 { + return textureLoad(source, vec2(position.xy), 0) * parameters.amount; +} + +// A mipmap level from the one above it, each texel averaging a block of two by two, which grows to three along an odd side's last texel +@fragment +fn halve(@builtin(position) position: vec4) -> @location(0) vec4 { + let size = vec2(textureDimensions(source)); + let half_size = max(size / 2, vec2(1)); + let index = vec2(position.xy); + let start = index * 2; + // Selecting per axis, since WebGL2 can't select between integer vectors + let last = index + 1 == half_size; + let end = clamp(vec2(select(start.x + 2, size.x, last.x), select(start.y + 2, size.y, last.y)), start + 1, size); + + var sum = vec4(0.); + for (var y = start.y; y < end.y; y++) { + for (var x = start.x; x < end.x; x++) { + sum += textureLoad(source, vec2(x, y), 0); + } + } + let count = end - start; + return sum / f32(count.x * count.y); +} + +@fragment +fn decode_to_linear(@builtin(position) position: vec4) -> @location(0) vec4 { + let color = textureLoad(source, vec2(position.xy), 0); + if color.a <= 0. { + return vec4(0.); + } + + return vec4(srgb_to_linear(max(color.rgb / color.a, vec3(0.))) * color.a, color.a); +} + +@fragment +fn encode_to_gamma(@builtin(position) position: vec4) -> @location(0) vec4 { + let color = textureLoad(source, vec2(position.xy), 0); + if color.a <= 0. { + return vec4(0.); + } + + return vec4(linear_to_srgb(max(color.rgb / color.a, vec3(0.))) * color.a, color.a); +} + +fn srgb_to_linear(srgb: vec3) -> vec3 { + let low = srgb / 12.92; + let high = pow((srgb + 0.055) / 1.055, vec3(2.4)); + return select(high, low, srgb <= vec3(0.04045)); +} + +fn linear_to_srgb(linear: vec3) -> vec3 { + let low = linear * 12.92; + let high = 1.055 * pow(linear, vec3(1. / 2.4)) - 0.055; + return select(high, low, linear <= vec3(0.0031308)); +} + +// =========== +// Adjustments +// =========== + +// The adjustment passes work on a pixel's straight color, leaving fully transparent pixels empty since they keep no color to adjust + +// A one-row table's value at a position from 0 to 1, interpolated between its two nearest entries +fn curve_lookup(table: texture_2d, value: f32) -> vec4 { + let last = f32(textureDimensions(table).x - 1u); + let position = clamp(value, 0., 1.) * last; + let index = min(floor(position), last - 1.); + + let before = textureLoad(table, vec2(i32(index), 0), 0); + let after = textureLoad(table, vec2(i32(index) + 1, 0), 0); + return mix(before, after, position - index); +} + +// Each channel through its own curve +@fragment +fn adjust_curves(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = textureLoad(source, vec2(position.xy), 0); + if pixel.a <= 0. { + return vec4(0.); + } + let color = vec4(pixel.rgb / pixel.a, pixel.a); + + let red = curve_lookup(secondary, color.r).r; + let green = curve_lookup(secondary, color.g).g; + let blue = curve_lookup(secondary, color.b).b; + let alpha = curve_lookup(secondary, color.a).a; + return vec4(vec3(red, green, blue) * alpha, alpha); +} + +// The cube entry at a grid point, from the texture's tiles of slices of constant blue, each with red across and green down +fn cube_entry(size: u32, point: vec3) -> vec3 { + let columns = textureDimensions(secondary).x / size; + let tile = vec2(point.b % columns, point.b / columns) * size; + return textureLoad(secondary, vec2(tile + point.rg), 0).rgb; +} + +// The cube's color at a point, interpolated in the tetrahedron of its cell that holds it, which fits the cube far closer than trilinear interpolation. +// The cube has `values.x` samples along each side. +fn cube_lookup(color: vec3) -> vec3 { + let size = u32(parameters.values.x); + let scaled = clamp(color, vec3(0.), vec3(1.)) * f32(size - 1u); + let base = min(vec3(floor(scaled)), vec3(size - 2u)); + let f = scaled - vec3(base); + + let black = cube_entry(size, base); + let white = cube_entry(size, base + vec3(1u, 1u, 1u)); + let r = cube_entry(size, base + vec3(1u, 0u, 0u)); + let g = cube_entry(size, base + vec3(0u, 1u, 0u)); + let b = cube_entry(size, base + vec3(0u, 0u, 1u)); + let rg = cube_entry(size, base + vec3(1u, 1u, 0u)); + let rb = cube_entry(size, base + vec3(1u, 0u, 1u)); + let gb = cube_entry(size, base + vec3(0u, 1u, 1u)); + + // Each tetrahedron runs from the cell's black corner to its white one, stepping along the axes in order of how far the point lies along them + if f.r >= f.g && f.g >= f.b { + return (1. - f.r) * black + (f.r - f.g) * r + (f.g - f.b) * rg + f.b * white; + } else if f.r >= f.b && f.b >= f.g { + return (1. - f.r) * black + (f.r - f.b) * r + (f.b - f.g) * rb + f.g * white; + } else if f.b >= f.r && f.r >= f.g { + return (1. - f.b) * black + (f.b - f.r) * b + (f.r - f.g) * rb + f.g * white; + } else if f.g >= f.r && f.r >= f.b { + return (1. - f.g) * black + (f.g - f.r) * g + (f.r - f.b) * rg + f.b * white; + } else if f.g >= f.b && f.b >= f.r { + return (1. - f.g) * black + (f.g - f.b) * g + (f.b - f.r) * gb + f.r * white; + } + return (1. - f.b) * black + (f.b - f.g) * b + (f.g - f.r) * gb + f.r * white; +} + +// The color through the cube and alpha through its own curve +@fragment +fn adjust_cube(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = textureLoad(source, vec2(position.xy), 0); + if pixel.a <= 0. { + return vec4(0.); + } + + let alpha = curve_lookup(content, pixel.a).a; + return vec4(cube_lookup(pixel.rgb / pixel.a) * alpha, alpha); +} + +// The color the pixel's luma picks from a table, with the pixel's alpha +@fragment +fn adjust_by_luma(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = textureLoad(source, vec2(position.xy), 0); + if pixel.a <= 0. { + return vec4(0.); + } + + let luma = dot(pixel.rgb / pixel.a, parameters.values.rgb); + return vec4(curve_lookup(secondary, luma).rgb * pixel.a, pixel.a); +} + +fn wrap_positive(value: f32, modulus: f32) -> f32 { + return value - floor(value / modulus) * modulus; +} + +// Hexagon hue in degrees, 0 for gray +fn hexagon_hue_degrees(color: vec3) -> f32 { + let maximum = max(color.r, max(color.g, color.b)); + let chroma = maximum - min(color.r, min(color.g, color.b)); + if chroma <= 0. { + return 0.; + } + + if maximum == color.r { + return wrap_positive((color.g - color.b) / chroma, 6.) * 60.; + } else if maximum == color.g { + return ((color.b - color.r) / chroma + 2.) * 60.; + } + return ((color.r - color.g) / chroma + 4.) * 60.; +} + +// How fully a hue falls under the skin-tone protection: all of it from red to 30 degrees, fading out by 45, and back in from 300 +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.; +} + +// Vibrance on linear channels as the chroma factor about the maximum channel (x) and the brightness multiplier (y), mirroring the CPU kernel +fn vibrance_factors(color: vec3, amount: f32) -> vec2 { + let maximum = max(color.r, max(color.g, color.b)); + let minimum = min(color.r, min(color.g, color.b)); + if maximum <= 0. { + return vec2(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 desaturation = -amount; + let chroma_factor = (1. - desaturation / 4.) * (1. - desaturation * (1. - rolloff * saturation * (1. + saturation) / 2.)); + return vec2(chroma_factor, 1. - desaturation * brightness); + } + + let protection = skin_tone_window(hexagon_hue_degrees(color)) * (1. - saturation * saturation); + let protected_amount = amount * (1. - protection * (1. - amount)); + let boost = (5. / 6.) * (1. - 0.4857 * protection) * protected_amount * ratio * (1. - minimum) * rolloff; + return vec2(1. / (1. - boost), 1. + protected_amount * brightness / 4.); +} + +// Vibrance (amount in values.x) then saturation (scale in values.y), both in linear light +@fragment +fn adjust_vibrance(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = textureLoad(source, vec2(position.xy), 0); + if pixel.a <= 0. { + return vec4(0.); + } + let linear = srgb_to_linear(max(pixel.rgb / pixel.a, vec3(0.))); + + let maximum = max(linear.r, max(linear.g, linear.b)); + let factors = vibrance_factors(linear, parameters.values.x); + let vibrant = clamp(factors.y * (maximum + factors.x * (linear - maximum)), vec3(0.), vec3(1.)); + + // Saturation scales each channel's distance from a gray weighted by ProPhoto's luminance coefficients + let gray = dot(vibrant, vec3(0.288040, 0.711874, 0.000086)); + let saturated = clamp(gray + (vibrant - gray) * parameters.values.y, vec3(0.), vec3(1.)); + return vec4(linear_to_srgb(saturated) * pixel.a, pixel.a); +} diff --git a/node-graph/libraries/wgpu-executor/src/vello_generators.rs b/node-graph/libraries/wgpu-executor/src/vello_generators.rs new file mode 100644 index 00000000000..8b031c8099c --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/vello_generators.rs @@ -0,0 +1,282 @@ +//! The GPU pass that renders tiles of generators' patterns, matching the CPU's renders of them. + +use crate::vello_render::TEXTURE_FORMAT; +use bytemuck::{Pod, Zeroable}; +use glam::DVec2; +use graphic_types::generator::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, GeneratorPattern, NoiseSettings, NoiseType}; +use std::sync::OnceLock; +use wgpu::util::DeviceExt; + +/// How many of a 32-bit float's steps one pixel must span for the Mandelbrot set to render on the GPU, past which its deep zooms render on the CPU in 64 bits. +const MIN_MANDELBROT_PIXEL_STEPS: f64 = 64.; + +/// The shader's patterns, matching its `PATTERN_` constants. +const PATTERN_NOISE: u32 = 0; +const PATTERN_WHITE_NOISE: u32 = 1; +const PATTERN_MANDELBROT: u32 = 2; + +/// FastNoiseLite's `fnl_state`, laid out to match the shader's. +#[repr(C)] +#[derive(Clone, Copy, Default, Pod, Zeroable)] +struct NoiseState { + seed: i32, + frequency: f32, + noise_type: i32, + rotation_type_3d: i32, + fractal_type: i32, + octaves: i32, + lacunarity: f32, + gain: f32, + weighted_strength: f32, + ping_pong_strength: f32, + cellular_distance_func: i32, + cellular_return_type: i32, + cellular_jitter_mod: f32, + domain_warp_type: i32, + domain_warp_amp: f32, +} + +/// The shader's parameters, laid out to match its `Parameters` struct. +#[repr(C)] +#[derive(Clone, Copy, Default, Pod, Zeroable)] +struct Parameters { + corner: [f32; 2], + texel: [f32; 2], + pattern: u32, + domain_warp: u32, + _padding: [u32; 2], + noise: NoiseState, + _end_padding: u32, +} + +impl NoiseState { + /// The state FastNoiseLite's CPU sampler is given for the same settings, with whether it warps its domain. + fn new(settings: &NoiseSettings) -> (Self, bool) { + let domain_warp_type = match settings.domain_warp_type { + DomainWarpType::None => None, + DomainWarpType::OpenSimplex2 => Some(0), + DomainWarpType::OpenSimplex2Reduced => Some(1), + DomainWarpType::BasicGrid => Some(2), + }; + let state = Self { + seed: settings.seed as i32, + frequency: 1. / (settings.scale as f32).max(f32::EPSILON), + noise_type: match settings.noise_type { + NoiseType::OpenSimplex2 | NoiseType::WhiteNoise => 0, + NoiseType::OpenSimplex2S => 1, + NoiseType::Cellular => 2, + NoiseType::Perlin => 3, + NoiseType::ValueCubic => 4, + NoiseType::Value => 5, + }, + rotation_type_3d: 0, + fractal_type: match settings.fractal_type { + FractalType::None => 0, + FractalType::FBm => 1, + FractalType::Ridged => 2, + FractalType::PingPong => 3, + FractalType::DomainWarpProgressive => 4, + FractalType::DomainWarpIndependent => 5, + }, + octaves: settings.fractal_octaves as i32, + lacunarity: settings.fractal_lacunarity as f32, + gain: settings.fractal_gain as f32, + weighted_strength: settings.fractal_weighted_strength as f32, + ping_pong_strength: settings.fractal_ping_pong_strength as f32, + cellular_distance_func: match settings.cellular_distance_function { + CellularDistanceFunction::Euclidean => 0, + CellularDistanceFunction::EuclideanSq => 1, + CellularDistanceFunction::Manhattan => 2, + CellularDistanceFunction::Hybrid => 3, + }, + cellular_return_type: match settings.cellular_return_type { + CellularReturnType::CellValue => 0, + CellularReturnType::Nearest => 1, + CellularReturnType::NextNearest => 2, + CellularReturnType::Average => 3, + CellularReturnType::Difference => 4, + CellularReturnType::Product => 5, + CellularReturnType::Division => 6, + }, + cellular_jitter_mod: settings.cellular_jitter as f32, + domain_warp_type: domain_warp_type.unwrap_or(0), + domain_warp_amp: settings.domain_warp_amplitude as f32, + }; + + (state, domain_warp_type.is_some() && settings.domain_warp_amplitude > 0.) + } +} + +impl Parameters { + /// The parameters of a tile of `pattern` whose top left corner lies at `corner` of the pattern's space, with pixels `texel` units across. + /// Returns `None` if the tile's pixels are too fine for 32-bit floats to place. + fn new(pattern: &GeneratorPattern, corner: DVec2, texel: f64, pixels: u32) -> Option { + let (pattern, corner, texel, noise, domain_warp) = match pattern { + GeneratorPattern::Noise(settings) => { + let (noise, domain_warp) = NoiseState::new(settings); + let pattern = if settings.noise_type == NoiseType::WhiteNoise { PATTERN_WHITE_NOISE } else { PATTERN_NOISE }; + (pattern, corner, texel, noise, domain_warp) + } + GeneratorPattern::Mandelbrot => { + // Sampled in the complex plane rather than its unit square, so the shader's coordinates there round only once + let (corner, texel) = (corner * 3. - DVec2::new(2., 1.5), texel * 3.); + let farthest = corner.abs().max((corner + texel * pixels as f64).abs()).max_element(); + if texel < farthest * f32::EPSILON as f64 * MIN_MANDELBROT_PIXEL_STEPS { + return None; + } + (PATTERN_MANDELBROT, corner, texel, NoiseState::default(), false) + } + }; + + Some(Self { + corner: corner.as_vec2().to_array(), + texel: [texel as f32; 2], + pattern, + domain_warp: domain_warp as u32, + noise, + ..Default::default() + }) + } +} + +/// The pipeline of the pass, built the first time a generator is drawn so documents without one don't compile its shader. +#[derive(Default)] +pub(crate) struct GeneratorPasses { + pipeline: OnceLock<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>, +} + +impl GeneratorPasses { + fn pipeline(&self, device: &wgpu::Device) -> &(wgpu::RenderPipeline, wgpu::BindGroupLayout) { + self.pipeline.get_or_init(|| { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("vello_generators"), + source: wgpu::ShaderSource::Wgsl(concat!(include_str!("fast_noise_lite.wgsl"), include_str!("vello_generators.wgsl")).into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("vello_generators_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("vello_generators_pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("vello_generators"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("generate"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: TEXTURE_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + (pipeline, bind_group_layout) + }) + } + + /// Records the rendering of the top mip level of a square tile of `pattern`, `pixels` across, whose top left corner lies at `corner` of the pattern's space with pixels `texel` units across. + /// Returns `None` if the GPU can't render the tile as precisely as the CPU, leaving it to the CPU. + pub(crate) fn render_tile(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, pattern: &GeneratorPattern, corner: DVec2, texel: f64, pixels: u32) -> Option { + let parameters = Parameters::new(pattern, corner, texel, pixels)?; + let (pipeline, bind_group_layout) = self.pipeline(device); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vello_generator_tile"), + size: wgpu::Extent3d { + width: pixels, + height: pixels, + depth_or_array_layers: 1, + }, + mip_level_count: pixels.ilog2() + 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TEXTURE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("vello_generator_parameters"), + contents: bytemuck::bytes_of(¶meters), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("vello_generator_bind_group"), + layout: bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor { + mip_level_count: Some(1), + ..Default::default() + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("vello_generator_tile"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + drop(pass); + + Some(texture) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mandelbrot_tiles_too_fine_for_32_bit_floats_are_left_to_the_cpu() { + let corner = DVec2::new(0.25, 0.5); + assert!(Parameters::new(&GeneratorPattern::Mandelbrot, corner, 2_f64.powi(-8), 260).is_some()); + assert!(Parameters::new(&GeneratorPattern::Mandelbrot, corner, 2_f64.powi(-30), 260).is_none()); + } +} diff --git a/node-graph/libraries/wgpu-executor/src/vello_generators.wgsl b/node-graph/libraries/wgpu-executor/src/vello_generators.wgsl new file mode 100644 index 00000000000..b0413fc7052 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/vello_generators.wgsl @@ -0,0 +1,98 @@ +// Renders a tile of a generator's pattern, sampled at the centers of the tile's pixels, as opaque gamma-encoded color like the CPU's tiles + +const PATTERN_NOISE: u32 = 0u; +const PATTERN_WHITE_NOISE: u32 = 1u; +const PATTERN_MANDELBROT: u32 = 2u; + +// The most iterations a point is tested for escaping the Mandelbrot set, beyond which it counts as inside +const MANDELBROT_ITERATIONS: u32 = 255u; + +struct Parameters { + // Where the tile's top left corner lies in the space the pattern is sampled in, and the size of one of its pixels there + corner: vec2, + texel: vec2, + pattern: u32, + // Whether the noise warps its domain before it's sampled + domain_warp: u32, + @align(16) noise: fnl_state, +} + +@group(0) @binding(0) var parameters: Parameters; + +// One triangle that covers the whole target +@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 generate(@builtin(position) position: vec4) -> @location(0) vec4 { + let point = parameters.corner + position.xy * parameters.texel; + + var luminance: f32; + switch parameters.pattern { + case PATTERN_NOISE: { + luminance = noise(point); + } + case PATTERN_WHITE_NOISE: { + luminance = white_noise(point); + } + case PATTERN_MANDELBROT, default: { + luminance = mandelbrot(point); + } + } + + return vec4(vec3(linear_to_srgb(luminance)), 1.); +} + +fn noise(point: vec2) -> f32 { + var x = point.x; + var y = point.y; + if parameters.domain_warp != 0u { + fnlDomainWarp2D(parameters.noise, &x, &y); + } + return (fnlGetNoise2D(parameters.noise, x, y) + 1.) * 0.5; +} + +// A random value for each square of one unit, matching the CPU's +fn white_noise(point: vec2) -> f32 { + let cell = vec2(floor(point)); + let hash = pcg3d(vec3(bitcast>(cell), bitcast(parameters.noise.seed))); + + // The top 24 bits, which an f32 holds exactly + return f32(hash.x >> 8u) / 16777216.; +} + +// PCG3D from Jarzynski and Olano's "Hash Functions for GPU Rendering", which spreads every input bit across the result +fn pcg3d(input: vec3) -> vec3 { + var v = input * 1664525u + 1013904223u; + v.x += v.y * v.z; + v.y += v.z * v.x; + v.z += v.x * v.y; + v ^= v >> vec3(16u); + v.x += v.y * v.z; + v.y += v.z * v.x; + v.z += v.x * v.y; + return v; +} + +// The fraction of the iteration limit that `c` takes to escape the Mandelbrot set, or 1 if it doesn't +fn mandelbrot(c: vec2) -> f32 { + var z = vec2(0.); + for (var iteration = 0u; iteration < MANDELBROT_ITERATIONS; iteration++) { + z = vec2(z.x * z.x - z.y * z.y, 2. * z.x * z.y) + c; + if dot(z, z) > 4. { + return f32(iteration) / f32(MANDELBROT_ITERATIONS); + } + } + return 1.; +} + +// Clamped to the range the CPU encodes, which takes NaN as 0 +fn linear_to_srgb(unclamped: f32) -> f32 { + let linear = select(0., min(unclamped, 1.), unclamped >= 0.); + let low = linear * 12.92; + let high = 1.055 * pow(linear, 1. / 2.4) - 0.055; + return select(high, low, linear <= 0.0031308); +} diff --git a/node-graph/libraries/wgpu-executor/src/vello_render.rs b/node-graph/libraries/wgpu-executor/src/vello_render.rs index 188abaf1276..98731a5bd87 100644 --- a/node-graph/libraries/wgpu-executor/src/vello_render.rs +++ b/node-graph/libraries/wgpu-executor/src/vello_render.rs @@ -1,104 +1,298 @@ -use glam::UVec2; -use rendering::vello_scene_builder::{SceneImage, VelloSceneBuilder, image_downscale_factor, image_texture_size}; +use crate::vello_effects::{AdjustmentPassTextures, EffectPasses, EffectTextures, adjustment_run_hash}; +use crate::vello_generators::GeneratorPasses; +use glam::{DMat2, UVec2}; +use graphic_types::raster_types::{CPU, Raster}; +use graphic_types::{Adjustment, EffectOperation, Effects}; +use rendering::vello_scene_builder::{EffectsRenderer, GeneratorTile, SceneImage, VelloSceneBuilder, image_downscale_factor, image_texture_size}; use std::collections::HashMap; -use std::sync::{Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; use vello_gpu::kurbo::Affine; use vello_gpu::peniko; -use vello_gpu::{ClearSettings, RenderSize, RenderTargetConfig, Renderer, Resources, TargetInit, TextureBindings, TextureId}; +use vello_gpu::{ + ClearSettings, IntermediateTextureError, LayersConfig, MemorySettings, RenderError, RenderSettings, RenderSize, RenderTargetConfig, Renderer, Resources, SizeU16, TargetInit, TextureBindings, + TextureId, +}; +use wgpu_sync::Queue; /// The format of the textures Vello GPU renders into, of the image textures it samples, and of the textures handed back. -const TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +pub(crate) const TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// How many renders a GPU texture's premultiplied copy survives without being drawn before it is freed. const UNUSED_IMAGE_RENDER_LIMIT: u64 = 60; +/// The most tiles of generators kept between renders, past which the least recently drawn are freed. +const MAX_CACHED_GENERATOR_TILES: usize = 256; + +/// How many times a render with too much content for Vello GPU is halved, reaching areas 1/4096 the size of the original. +const MAX_SPLIT_DEPTH: u32 = 12; + +/// The widest or tallest area a single render can draw an isolated layer across, since that is the limit of the intermediate textures Vello GPU draws layers into. +/// These grow to fit the largest layer and stay that size, so a larger limit keeps more memory after one large render. Renders larger than this are split. +pub const MAX_LAYER_RENDER_SIZE: u32 = 4096; + /// Vello GPU's renderer and GPU resources, shared by every [`VelloCanvas`] so their memory and pipelines exist once. pub(crate) struct VelloRenderer { /// Locked only while a scene is rendered and submitted, so one canvas drawing its scene on the CPU doesn't hold up another's render. gpu: Mutex<(Renderer, Resources)>, alpha_conversion: AlphaConversion, + effect_passes: EffectPasses, + generator_passes: GeneratorPasses, } /// A stream of frames rendered through the shared Vello renderer, such as the document or the overlays, keeping what its frames reuse. #[derive(Default)] pub struct VelloCanvas { - /// The Vello GPU scene of the last render, handed out again to keep its allocations. - spare_scene: Option, + /// Scenes to draw into, one for each depth of effects groups nested in a render, reused between renders to keep their allocations. + spare_scenes: Vec, /// The premultiplied texture Vello GPU renders into before conversion to straight alpha, kept while the render size stays the same. premultiplied_target: Option, images: ImageTextures, + effect_textures: EffectTextures, } -impl VelloCanvas { - /// An empty scene of `size` to draw into, with its content placed by `view`. - pub(crate) fn scene(&mut self, device: &wgpu::Device, size: UVec2, view: Affine) -> anyhow::Result { - let (Ok(width), Ok(height)) = (u16::try_from(size.x), u16::try_from(size.y)) else { - anyhow::bail!("Render size {size} exceeds Vello GPU's limit"); - }; +/// Whether a render leaves its target with the straight alpha the rest of Graphite expects, or the premultiplied alpha the effect passes take. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TargetAlpha { + Straight, + Premultiplied, +} - let mut scene = self.spare_scene.take().unwrap_or_else(|| vello_gpu::Scene::new(width, height)); - scene.reset_and_resize(width, height); - Ok(VelloSceneBuilder::from_vello(scene, view, device.limits().max_texture_dimension_2d)) - } +/// A render in progress: the shared renderer and GPU, and the canvas whose resources it draws with. +struct RenderContext<'a> { + renderer: &'a VelloRenderer, + canvas: &'a mut VelloCanvas, + device: &'a wgpu::Device, + queue: &'a Queue, } impl VelloRenderer { - pub(crate) fn new(device: &wgpu::Device) -> Self { + pub(crate) fn new(device: &wgpu::Device, adapter: &wgpu::Adapter) -> Self { let render_target_config = RenderTargetConfig { format: TEXTURE_FORMAT, width: 1, height: 1, }; + let settings = RenderSettings::default().with_memory_settings(MemorySettings { + layers_config: LayersConfig { + max_texture_size: SizeU16::new(MAX_LAYER_RENDER_SIZE as u16), + ..Default::default() + }, + ..Default::default() + }); + Self { - gpu: Mutex::new(Renderer::new(device, &render_target_config)), + gpu: Mutex::new(Renderer::new_with(device, &render_target_config, settings)), alpha_conversion: AlphaConversion::new(device), + effect_passes: EffectPasses::new(device, adapter), + generator_passes: GeneratorPasses::default(), } } - /// Renders `scene` on `canvas` into `target` (an `Rgba8Unorm` texture of the scene's size with straight alpha). - pub(crate) fn render(&self, canvas: &mut VelloCanvas, device: &wgpu::Device, queue: &wgpu::Queue, scene: VelloSceneBuilder, target: &wgpu::Texture) { - let (width, height) = (scene.width(), scene.height()); - let max_texture_size = scene.max_texture_size(); - let (scene, images) = scene.finish(); + /// Renders the scene `draw` draws on `canvas`, with its content placed by `view`, into `target` (an `Rgba8Unorm` texture of `size` with straight alpha). + /// A render with more content than Vello GPU can hold calls `draw` again for each part it splits into. + #[expect(clippy::too_many_arguments)] + pub(crate) fn render( + &self, + canvas: &mut VelloCanvas, + device: &wgpu::Device, + queue: &Queue, + draw: &mut dyn FnMut(&mut VelloSceneBuilder), + view: Affine, + size: UVec2, + target: &wgpu::Texture, + ) -> anyhow::Result<()> { + if u16::try_from(size.x).is_err() || u16::try_from(size.y).is_err() { + anyhow::bail!("Render size {size} exceeds Vello GPU's limit"); + } canvas.images.render_count += 1; + let mut context = RenderContext { + renderer: self, + canvas, + device, + queue, + }; + context.render_area(draw, view, size, target, TargetAlpha::Straight); + canvas.images.free_unused(); + canvas.effect_textures.finish_render(); + + Ok(()) + } + + /// Renders a `scene` drawn beforehand on `canvas` into `target` (an `Rgba8Unorm` texture of the scene's size with straight alpha). + /// Unlike [`Self::render`], it can't split the scene, so one with more content than Vello GPU can hold renders transparent. + pub(crate) fn render_scene(&self, canvas: &mut VelloCanvas, device: &wgpu::Device, queue: &Queue, scene: VelloSceneBuilder, target: &wgpu::Texture) { + canvas.images.render_count += 1; + let max_texture_size = scene.max_texture_size(); + let mut context = RenderContext { + renderer: self, + canvas, + device, + queue, + }; + if let Err(error) = context.submit(scene.finish(), max_texture_size, target, TargetAlpha::Straight) { + log::error!("Vello GPU failed to render: {error}"); + clear(device, queue, target); + } + canvas.images.free_unused(); + canvas.effect_textures.finish_render(); + } +} + +impl RenderContext<'_> { + /// Renders the scene `draw` draws into `target`, a texture of `size`, splitting its area into halves while it holds more content than one render can. + fn render_area(&mut self, draw: &mut dyn FnMut(&mut VelloSceneBuilder), view: Affine, size: UVec2, target: &wgpu::Texture, target_alpha: TargetAlpha) { + if let Err(error) = self.render_once(draw, view, size, target, target_alpha) { + self.render_halves(draw, view, size, target, target_alpha, error, 1); + } + } + + /// Renders each half of the area of `target`, a texture of `size`, after rendering it whole failed with `error`, splitting again each half that still holds too much. + #[expect(clippy::too_many_arguments)] + fn render_halves(&mut self, draw: &mut dyn FnMut(&mut VelloSceneBuilder), view: Affine, size: UVec2, target: &wgpu::Texture, target_alpha: TargetAlpha, error: RenderError, split_depth: u32) { + let (device, queue) = (self.device, self.queue); + + // Each half covers less of the scene, so it has less content and smaller layers to hold + let too_much_to_hold = matches!( + error, + RenderError::ResourceTextureOverflow { .. } | RenderError::IntermediateTexture(IntermediateTextureError::TooLarge { .. }) + ); + if !too_much_to_hold || split_depth > MAX_SPLIT_DEPTH || size.max_element() <= 1 { + // A failed render leaves its area transparent rather than taking down the editor + log::error!("Vello GPU failed to render: {error}"); + clear(device, queue, target); + return; + } + + let halves = if size.x >= size.y { + let left = size.x / 2; + [(UVec2::ZERO, UVec2::new(left, size.y)), (UVec2::new(left, 0), UVec2::new(size.x - left, size.y))] + } else { + let top = size.y / 2; + [(UVec2::ZERO, UVec2::new(size.x, top)), (UVec2::new(0, top), UVec2::new(size.x, size.y - top))] + }; + let halves = halves.map(|(offset, half_size)| { + let half_target = create_texture( + device, + "vello_gpu_split_target", + half_size, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + ); + let half_view = Affine::translate((-(offset.x as f64), -(offset.y as f64))) * view; + let rendered = self.render_once(draw, half_view, half_size, &half_target, target_alpha); + (offset, half_size, half_view, half_target, rendered) + }); + + // Content that each half still holds all of, like paints stacked across the whole area, can't be split apart + let stalled = |rendered: &Result<(), RenderError>| match (rendered, &error) { + ( + Err(RenderError::ResourceTextureOverflow { resource, required_height, .. }), + RenderError::ResourceTextureOverflow { + resource: whole_resource, + required_height: whole_required_height, + .. + }, + ) => resource == whole_resource && required_height >= whole_required_height, + _ => false, + }; + if halves.iter().all(|(.., rendered)| stalled(rendered)) { + log::error!("Vello GPU failed to render: {error}"); + clear(device, queue, target); + return; + } + + for (offset, half_size, half_view, half_target, rendered) in halves { + if let Err(half_error) = rendered { + self.render_halves(draw, half_view, half_size, &half_target, target_alpha, half_error, split_depth + 1); + } + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vello_gpu_split_copy") }); + encoder.copy_texture_to_texture( + half_target.as_image_copy(), + wgpu::TexelCopyTextureInfo { + origin: wgpu::Origin3d { x: offset.x, y: offset.y, z: 0 }, + ..target.as_image_copy() + }, + half_target.size(), + ); + queue.submit([encoder.finish()]); + } + } + + /// Renders the scene `draw` draws into `target`, a texture of `size`, in a single Vello GPU render. + fn render_once(&mut self, draw: &mut dyn FnMut(&mut VelloSceneBuilder), view: Affine, size: UVec2, target: &wgpu::Texture, target_alpha: TargetAlpha) -> Result<(), RenderError> { + let (width, height) = (size.x as u16, size.y as u16); + let max_texture_size = self.device.limits().max_texture_dimension_2d; + + // Draw the scene for this render's size and placement, which renders its effects groups as they're drawn + let mut scene = self.canvas.spare_scenes.pop().unwrap_or_else(|| vello_gpu::Scene::new(width, height)); + scene.reset_and_resize(width, height); + let mut builder = VelloSceneBuilder::from_vello(scene, view, max_texture_size).with_effects_renderer(self); + draw(&mut builder); + let drawn = builder.finish(); + + self.submit(drawn, max_texture_size, target, target_alpha) + } + + /// Renders a drawn scene, with the images it draws, into `target`, a texture of the scene's size, in a single Vello GPU render. + fn submit(&mut self, (scene, images): (vello_gpu::Scene, Vec), max_texture_size: u32, target: &wgpu::Texture, target_alpha: TargetAlpha) -> Result<(), RenderError> { + let device = self.device; + let (width, height) = (scene.width(), scene.height()); + let size = UVec2::new(width as u32, height as u32); + + // Locked from here to the submission, so drawing a scene doesn't hold up other uses of the queue + let queue = self.queue.lock(); + + // Each render submits its own encoder, since Vello GPU stages its uploads for the next submission let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vello_gpu_render") }); // Supply the textures of the images the scene draws, under the IDs it drew them with let mut texture_bindings = TextureBindings::new(); for (id, image) in images.iter().enumerate() { - if let Some(view) = canvas.images.prepare(device, queue, &mut encoder, &self.alpha_conversion, image, max_texture_size) { + let view = self.canvas.images.prepare(device, &queue, &mut encoder, self.renderer, image, max_texture_size); + + // Adjustments draw a copy of the texture they're carried out on + let view = match image { + SceneImage::Raster { raster, adjustments } if !adjustments.is_empty() => view.and_then(|_| { + let effects = (&self.renderer.effect_passes, &mut self.canvas.effect_textures); + self.canvas.images.prepare_adjusted(device, &queue, effects, raster, adjustments) + }), + _ => view, + }; + + if let Some(view) = view { texture_bindings.insert(TextureId(id as u64), view); } } - // Render premultiplied, then convert to the straight alpha the rest of Graphite expects - let size = UVec2::new(width as u32, height as u32); - let premultiplied_target = match &canvas.premultiplied_target { - Some(texture) if texture.width() == size.x && texture.height() == size.y => texture.clone(), - _ => { + // Vello GPU renders premultiplied, which is converted to straight alpha unless the target wants it premultiplied + let target_view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let premultiplied_view = match (target_alpha, &self.canvas.premultiplied_target) { + (TargetAlpha::Premultiplied, _) => target_view.clone(), + (TargetAlpha::Straight, Some(texture)) if texture.width() == size.x && texture.height() == size.y => texture.create_view(&wgpu::TextureViewDescriptor::default()), + (TargetAlpha::Straight, _) => { let texture = create_texture( device, "vello_gpu_premultiplied_target", size, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, ); - canvas.premultiplied_target = Some(texture.clone()); - texture + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.canvas.premultiplied_target = Some(texture); + view } }; - let premultiplied_view = premultiplied_target.create_view(&wgpu::TextureViewDescriptor::default()); let target_init = TargetInit::Clear(ClearSettings::Viewport { color: peniko::Color::TRANSPARENT }); // Vello GPU stages this render's uploads into resources every render shares, so another can't stage its own until this one is submitted - let mut gpu = self.gpu.lock().unwrap_or_else(PoisonError::into_inner); + let mut gpu = self.renderer.gpu.lock().unwrap_or_else(PoisonError::into_inner); let (renderer, resources) = &mut *gpu; let rendered = renderer.render( &scene, resources, device, - queue, + &queue, &mut encoder, &RenderSize { width, height }, &premultiplied_view, @@ -107,34 +301,94 @@ impl VelloRenderer { target_init, ); - // A failed render leaves its area transparent rather than taking down the editor - let target_view = target.create_view(&wgpu::TextureViewDescriptor::default()); - match rendered { - Ok(()) => self.alpha_conversion.unpremultiply(device, &mut encoder, &premultiplied_view, &target_view), - Err(error) => { - log::error!("Vello GPU failed to render: {error}"); - clear_texture(&mut encoder, &target_view); - } + if rendered.is_ok() && target_alpha == TargetAlpha::Straight { + self.renderer.alpha_conversion.unpremultiply(device, &mut encoder, &premultiplied_view, &target_view); } + // Submitted even after a failure, since it also prepares the textures of the scene's images queue.submit([encoder.finish()]); - drop(gpu); - canvas.images.free_unused(); - canvas.spare_scene = Some(scene); + drop((gpu, queue)); + + // Once the render that draws them is submitted, its scene and effects results are free to reuse + self.canvas.spare_scenes.push(scene); + for image in images { + if let SceneImage::Rendered(texture) = image { + self.canvas.effect_textures.release(texture); + } + } + + rendered + } +} + +impl EffectsRenderer for RenderContext<'_> { + fn renders_effects(&self) -> bool { + self.renderer.effect_passes.renders_working_formats + } + + fn render_effects( + &mut self, + size: UVec2, + view: Affine, + effects: &Effects, + fill: f32, + lengths_to_pixels: DMat2, + draw_paint: &mut dyn FnMut(usize, &mut VelloSceneBuilder), + draw: &mut dyn FnMut(&mut VelloSceneBuilder), + ) -> wgpu::Texture { + let content_texture = self.canvas.effect_textures.acquire(self.device, size, TEXTURE_FORMAT); + self.render_area(draw, view, size, &content_texture, TargetAlpha::Premultiplied); + + // The paint overlays' paints, each rendered over the same pixels as the content + let paint_count = effects.operations().filter(|operation| matches!(operation, EffectOperation::PaintOverlay { .. })).count(); + let paint_textures: Vec = (0..paint_count) + .map(|index| { + let paint_texture = self.canvas.effect_textures.acquire(self.device, size, TEXTURE_FORMAT); + self.render_area(&mut |scene| draw_paint(index, scene), view, size, &paint_texture, TargetAlpha::Premultiplied); + paint_texture + }) + .collect(); + + let effect_textures = &mut self.canvas.effect_textures; + let result = (self.renderer.effect_passes).apply(self.device, &self.queue.lock(), effect_textures, &content_texture, effects, fill, lengths_to_pixels, &paint_textures); + effect_textures.release(content_texture); + paint_textures.into_iter().for_each(|paint_texture| effect_textures.release(paint_texture)); + + result } } -/// Identifies the pixels behind an image texture: a blob of CPU pixels, or a GPU texture. +/// Leaves `target` transparent. +fn clear(device: &wgpu::Device, queue: &Queue, target: &wgpu::Texture) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vello_gpu_clear") }); + clear_texture(&mut encoder, &target.create_view(&wgpu::TextureViewDescriptor::default())); + queue.submit([encoder.finish()]); +} + +/// Identifies the pixels behind an image texture: a blob of CPU pixels, a GPU texture, or a tile of a generator's pattern. #[derive(Clone, PartialEq, Eq, Hash)] enum ImageKey { Blob(u64), + Raster(u64), Texture(wgpu::Texture), + GeneratorTile(GeneratorTile), } /// A premultiplied copy of an image's pixels that Vello GPU samples as an external texture. struct ImageTexture { texture: wgpu::Texture, last_used: u64, + /// Copies with runs of adjustments carried out on them, one for each run the image is drawn with. + adjusted: Vec, +} + +/// A copy of an image's texture with a run of adjustments carried out on it, made over in place for another run or refined tables. +struct AdjustedTexture { + texture: wgpu::Texture, + run_hash: u64, + /// The tables it was made with, which a run's refined tables replace. + passes: Arc>, + last_used: u64, } /// The textures of the images recent renders drew, each freed once it goes unused for long enough. @@ -145,25 +399,55 @@ struct ImageTextures { } impl ImageTextures { - /// A view of the premultiplied texture for `image`, uploading CPU pixels the first time they're drawn. + /// A view of the premultiplied texture for `image`, uploading CPU pixels or rendering a generator's tile the first time they're drawn. /// GPU textures are converted again at every render since the texture cache hands their textures out again with new contents. fn prepare( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, - alpha_conversion: &AlphaConversion, + renderer: &VelloRenderer, image: &SceneImage, max_texture_size: u32, ) -> Option { let key = match image { SceneImage::Pixels(data) => ImageKey::Blob(data.data.id()), + SceneImage::Raster { raster, .. } => ImageKey::Raster(raster.buffer_id()), SceneImage::Texture(texture) => ImageKey::Texture((**texture).clone()), + SceneImage::GeneratorTile { tile, .. } => ImageKey::GeneratorTile(*tile), + // Effects results are premultiplied already + SceneImage::Rendered(texture) => return Some(texture.create_view(&wgpu::TextureViewDescriptor::default())), }; if !self.textures.contains_key(&key) { let texture = match image { - SceneImage::Pixels(data) => upload_pixels(device, queue, data, max_texture_size)?, + SceneImage::Pixels(data) => { + if data.format != peniko::ImageFormat::Rgba8 { + log::error!("Unsupported image format {:?}", data.format); + return None; + } + + upload_pixels(device, queue, data.data.data(), data.width, data.height, data.alpha_type, max_texture_size)? + } + SceneImage::Raster { raster, .. } => { + let (pixels, width, height) = raster.to_flat_u8(); + upload_pixels(device, queue, &pixels, width, height, peniko::ImageAlphaType::Alpha, max_texture_size)? + } + SceneImage::GeneratorTile { pattern, tile } => { + // Rendered on the GPU, or on the CPU where 32-bit floats can't place its pixels + let (corner, texel) = tile.corner_and_texel(); + match renderer.generator_passes.render_tile(device, encoder, pattern, corner, texel, GeneratorTile::SAMPLES) { + Some(texture) => { + renderer.effect_passes.record_mipmaps(device, encoder, &texture); + texture + } + None => { + let (pixels, width, height) = tile.render(pattern).to_flat_u8(); + upload_pixels(device, queue, &pixels, width, height, peniko::ImageAlphaType::Alpha, max_texture_size)? + } + } + } + SceneImage::Rendered(_) => return None, SceneImage::Texture(texture) => create_texture( device, "vello_gpu_premultiplied_image", @@ -171,7 +455,14 @@ impl ImageTextures { wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, ), }; - self.textures.insert(key.clone(), ImageTexture { texture, last_used: 0 }); + self.textures.insert( + key.clone(), + ImageTexture { + texture, + last_used: 0, + adjusted: Vec::new(), + }, + ); } let image_texture = self.textures.get_mut(&key)?; @@ -180,40 +471,100 @@ 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); + renderer.alpha_conversion.premultiply(device, encoder, &source_view, source.format().is_srgb(), &view); } Some(view) } + /// A view of the copy of the texture of the `raster` already prepared, with the `run` of adjustments carried out on it, made with the run's latest tables. + fn prepare_adjusted(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, effects: (&EffectPasses, &mut EffectTextures), raster: &Raster, run: &[Adjustment]) -> Option { + let (effect_passes, effect_textures) = effects; + let run: Vec<&Adjustment> = run.iter().collect(); + let run_hash = adjustment_run_hash(&run); + let passes = effect_textures.adjustment_passes(device, queue, &run); + let render_count = self.render_count; + let image_texture = self.textures.get_mut(&ImageKey::Raster(raster.buffer_id()))?; + + // A run seen before has its copy, and a new run takes over the copy longest unused, as when dragging a slider makes a new run every render + let reusable = || { + image_texture + .adjusted + .iter() + .enumerate() + .filter(|(_, copy)| copy.last_used < render_count) + .min_by_key(|(_, copy)| copy.last_used) + }; + let index = match image_texture.adjusted.iter().position(|copy| copy.run_hash == run_hash) { + Some(index) => index, + None => match reusable().map(|(index, _)| index) { + Some(index) => index, + None => { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vello_gpu_adjusted_image"), + size: image_texture.texture.size(), + mip_level_count: image_texture.texture.mip_level_count(), + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TEXTURE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + image_texture.adjusted.push(AdjustedTexture { + texture, + run_hash, + passes: Arc::new(Vec::new()), + last_used: render_count, + }); + image_texture.adjusted.len() - 1 + } + }, + }; + + let copy = &mut image_texture.adjusted[index]; + if copy.run_hash != run_hash || !Arc::ptr_eq(©.passes, &passes) { + effect_passes.adjust_image(device, queue, effect_textures, &image_texture.texture, &passes, ©.texture); + copy.run_hash = run_hash; + copy.passes = passes; + } + copy.last_used = render_count; + + Some(copy.texture.create_view(&wgpu::TextureViewDescriptor::default())) + } + fn free_unused(&mut self) { let render_count = self.render_count; - self.textures.retain(|key, image_texture| { - let unused_renders = render_count - image_texture.last_used; - match key { - // CPU pixels get a new blob ID at every render, so a blob this render didn't draw is unlikely to return - ImageKey::Blob(_) => unused_renders == 0, - ImageKey::Texture(_) => unused_renders < UNUSED_IMAGE_RENDER_LIMIT, - } + let in_use = |last_used: u64| render_count - last_used < UNUSED_IMAGE_RENDER_LIMIT; + + self.textures.retain(|key, image_texture| match key { + // CPU pixels get a new blob ID at every render, so a blob this render didn't draw is unlikely to return + ImageKey::Blob(_) => image_texture.last_used == render_count, + ImageKey::Raster(_) | ImageKey::Texture(_) | ImageKey::GeneratorTile(_) => in_use(image_texture.last_used), }); + self.textures.values_mut().for_each(|image_texture| image_texture.adjusted.retain(|copy| in_use(copy.last_used))); + + // Panning and zooming across a generator leaves tiles behind faster than they go unused for long, so the least recent beyond a limit are freed too + let mut tile_ages: Vec = (self.textures.iter()) + .filter(|(key, _)| matches!(key, ImageKey::GeneratorTile(_))) + .map(|(_, image_texture)| image_texture.last_used) + .collect(); + if tile_ages.len() > MAX_CACHED_GENERATOR_TILES { + let excess = tile_ages.len() - MAX_CACHED_GENERATOR_TILES; + let (_, &mut oldest_kept, _) = tile_ages.select_nth_unstable(excess); + self.textures + .retain(|key, image_texture| !matches!(key, ImageKey::GeneratorTile(_)) || image_texture.last_used >= oldest_kept); + } } } /// Uploads CPU pixels as a premultiplied texture, downscaling them by a whole factor if they exceed `max_texture_size`. -fn upload_pixels(device: &wgpu::Device, queue: &wgpu::Queue, image: &peniko::ImageData, max_texture_size: u32) -> Option { - let (width, height) = (image.width, image.height); +fn upload_pixels(device: &wgpu::Device, queue: &wgpu::Queue, pixels: &[u8], width: u32, height: u32, alpha_type: peniko::ImageAlphaType, max_texture_size: u32) -> Option { if width == 0 || height == 0 { return None; } - if image.format != peniko::ImageFormat::Rgba8 { - log::error!("Unsupported image format {:?}", image.format); - return None; - } - let premultiplied: Vec = match image.alpha_type { - peniko::ImageAlphaType::Alpha => image - .data - .data() + let premultiplied: Vec = match alpha_type { + peniko::ImageAlphaType::Alpha => pixels .as_chunks::<4>() .0 .iter() @@ -223,7 +574,7 @@ fn upload_pixels(device: &wgpu::Device, queue: &wgpu::Queue, image: &peniko::Ima [premultiply(pixel[0]), premultiply(pixel[1]), premultiply(pixel[2]), pixel[3]] }) .collect(), - peniko::ImageAlphaType::AlphaPremultiplied => image.data.data().to_vec(), + peniko::ImageAlphaType::AlphaPremultiplied => pixels.to_vec(), }; // Box-average whole blocks of pixels down until both sides fit, which is exact to average since the pixels are premultiplied @@ -231,35 +582,83 @@ fn upload_pixels(device: &wgpu::Device, queue: &wgpu::Queue, image: &peniko::Ima let (texture_width, texture_height) = image_texture_size(width, height, max_texture_size); let pixels = if factor == 1 { premultiplied } else { box_downscale(&premultiplied, width, height, factor) }; - let texture = create_texture( - device, - "vello_gpu_image", - UVec2::new(texture_width, texture_height), - wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - ); - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &pixels, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * texture_width), - rows_per_image: Some(texture_height), - }, - wgpu::Extent3d { + // Each mipmap level halves the one before it, so a minified image averages its pixels rather than skipping over them + let level_count = texture_width.max(texture_height).ilog2() + 1; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vello_gpu_image"), + size: wgpu::Extent3d { width: texture_width, height: texture_height, depth_or_array_layers: 1, }, - ); + mip_level_count: level_count, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TEXTURE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let (mut level_pixels, mut level_width, mut level_height) = (pixels, texture_width, texture_height); + for level in 0..level_count { + if level > 0 { + (level_pixels, level_width, level_height) = halve_for_mipmap(&level_pixels, level_width, level_height); + } + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: level, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &level_pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * level_width), + rows_per_image: Some(level_height), + }, + wgpu::Extent3d { + width: level_width, + height: level_height, + depth_or_array_layers: 1, + }, + ); + } Some(texture) } +/// Halves premultiplied RGBA8 pixels to the next mipmap level's size, which rounds down, so the last pixel of each row and column of an odd side averages three. +/// O(width × height). +fn halve_for_mipmap(pixels: &[u8], width: u32, height: u32) -> (Vec, u32, u32) { + let (half_width, half_height) = ((width / 2).max(1), (height / 2).max(1)); + // The source pixels along one side that an output pixel averages + let span = |index: u32, half: u32, full: u32| { + let start = index * 2; + let end = if index + 1 == half { full } else { start + 2 }; + start..end.clamp(start + 1, full) + }; + + let mut output = Vec::with_capacity((half_width * half_height * 4) as usize); + for output_y in 0..half_height { + for output_x in 0..half_width { + let mut sums = [0_u32; 4]; + let mut count = 0; + for y in span(output_y, half_height, height) { + for x in span(output_x, half_width, width) { + let index = ((y * width + x) * 4) as usize; + sums.iter_mut().zip(&pixels[index..index + 4]).for_each(|(sum, channel)| *sum += *channel as u32); + count += 1; + } + } + output.extend(sums.map(|sum| ((sum + count / 2) / count) as u8)); + } + } + + (output, half_width, half_height) +} + /// Averages each `factor` by `factor` block of RGBA8 pixels, letting blocks at the right and bottom edges average only the pixels they have. fn box_downscale(pixels: &[u8], width: u32, height: u32, factor: u32) -> Vec { let (output_width, output_height) = (width.div_ceil(factor), height.div_ceil(factor)); @@ -465,3 +864,6 @@ impl AlphaConversion { pass.draw(0..3, 0..1); } } + +#[cfg(test)] +mod tests; diff --git a/node-graph/libraries/wgpu-executor/src/vello_render/tests.rs b/node-graph/libraries/wgpu-executor/src/vello_render/tests.rs new file mode 100644 index 00000000000..31f6fa401fe --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/vello_render/tests.rs @@ -0,0 +1,1210 @@ +//! Renders scenes that stress Vello GPU on the machine's GPU, skipping when none is available. + +use crate::WgpuExecutor; +use glam::{DAffine2, UVec2}; +use rendering::vello_scene_builder::VelloSceneBuilder; +use vello_gpu::kurbo::{Affine, BezPath, Line, Rect, Stroke}; +use vello_gpu::peniko::{self, Color, Fill}; + +/// The executor every test shares, or `None` without a GPU. +fn executor() -> Option<&'static WgpuExecutor> { + static EXECUTOR: std::sync::OnceLock> = std::sync::OnceLock::new(); + EXECUTOR.get_or_init(|| futures::executor::block_on(WgpuExecutor::new())).as_ref() +} + +/// Held while a test uses the shared GPU, since another test's work can delay or intercept a render's readback. +fn take_turn() -> std::sync::MutexGuard<'static, ()> { + static TURN: std::sync::Mutex<()> = std::sync::Mutex::new(()); + TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Renders the scene `draw` draws into straight-alpha RGBA8 pixels, or `None` without a GPU. +fn render(size: UVec2, draw: impl FnMut(&mut VelloSceneBuilder)) -> Option> { + render_timed(size, draw).map(|(pixels, _)| pixels) +} + +/// Renders the scene `draw` draws into straight-alpha RGBA8 pixels, with how long the render and its readback took, or `None` without a GPU. +fn render_timed(size: UVec2, mut draw: impl FnMut(&mut VelloSceneBuilder)) -> Option<(Vec<[u8; 4]>, std::time::Duration)> { + let executor = executor()?; + let _turn = take_turn(); + let start = std::time::Instant::now(); + + let texture = match executor.render_vello(size, DAffine2::IDENTITY, &mut draw) { + Ok(texture) => texture, + Err(error) => panic!("Render failed: {error}"), + }; + let pixels = executor.read_texture_pixels(&texture).expect("Failed to read back the render"); + + Some((pixels.as_chunks::<4>().0.to_vec(), start.elapsed())) +} + +/// A circle drawn with four cubic segments, as Graphite's vector shapes are, rather than kurbo's tolerance-dependent count. +fn circle(center: (f64, f64), radius: f64) -> BezPath { + const HANDLE: f64 = 0.552_284_749_830_793_4; + let (x, y) = center; + let (r, h) = (radius, radius * HANDLE); + + let mut path = BezPath::new(); + path.move_to((x + r, y)); + path.curve_to((x + r, y + h), (x + h, y + r), (x, y + r)); + path.curve_to((x - h, y + r), (x - r, y + h), (x - r, y)); + path.curve_to((x - r, y - h), (x - h, y - r), (x, y - r)); + path.curve_to((x + h, y - r), (x + r, y - h), (x + r, y)); + path.close_path(); + path +} + +fn pixel(pixels: &[[u8; 4]], size: UVec2, x: u32, y: u32) -> [u8; 4] { + pixels[(y * size.x + x) as usize] +} + +const RED: Color = Color::from_rgb8(255, 0, 0); +const BLUE: Color = Color::from_rgb8(0, 0, 255); + +#[test] +fn dense_strokes_render() { + let size = UVec2::new(2048, 2048); + let draw = |scene: &mut VelloSceneBuilder| { + for i in 0..40_000 { + let y = (i as f64 * 0.0511) % 2048.; + scene.stroke(&Stroke::new(1.), Affine::IDENTITY, RED, None, &Line::new((0., y), (2048., y + 3.))); + } + }; + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 1024, 1024)[3], 255); +} + +#[test] +fn huge_circle_edge_lands_on_the_right_pixel() { + let size = UVec2::new(256, 256); + let draw = |scene: &mut VelloSceneBuilder| { + for radius in [1e6, 1e9, 1e12] { + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &circle((128. - radius, 128.), radius)); + } + }; + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 120, 128)[3], 255, "inside"); + assert_eq!(pixel(&pixels, size, 136, 128)[3], 0, "outside"); +} + +#[test] +fn huge_curves_through_the_viewport_render_quickly() { + let size = UVec2::new(256, 256); + for radius in [1e13, 1e14, 1e15] { + let draw = |scene: &mut VelloSceneBuilder| { + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &circle((128. - radius, 128.), radius)); + scene.stroke(&Stroke::new(4.), Affine::IDENTITY, RED, None, &circle((200. + radius, 128.), radius)); + }; + + let Some((pixels, duration)) = render_timed(size, draw) else { return }; + println!("Radius {radius}: {duration:?}"); + assert!(duration.as_secs_f64() < 2., "radius {radius} took {duration:?}"); + assert_eq!(pixel(&pixels, size, 120, 128)[3], 255, "inside at radius {radius}"); + assert_eq!(pixel(&pixels, size, 160, 128)[3], 0, "outside at radius {radius}"); + assert_eq!(pixel(&pixels, size, 200, 128)[3], 255, "stroke at radius {radius}"); + } +} + +#[test] +fn astronomical_fills_render_quickly() { + let size = UVec2::new(256, 256); + for radius in [1e18, 1e30, 1e100, 1e300] { + let draw = |scene: &mut VelloSceneBuilder| scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &circle((0., 0.), radius)); + + let Some((pixels, duration)) = render_timed(size, draw) else { return }; + println!("Fill radius {radius}: {duration:?}"); + assert!(duration.as_secs_f64() < 2., "radius {radius} took {duration:?}"); + assert_eq!(pixel(&pixels, size, 128, 128)[3], 255, "inside at radius {radius}"); + } +} + +#[test] +fn astronomical_strokes_render_quickly() { + let size = UVec2::new(256, 256); + for radius in [1e18, 1e30, 1e100, 1e300] { + let draw = |scene: &mut VelloSceneBuilder| scene.stroke(&Stroke::new(1e3), Affine::IDENTITY, RED, None, &circle((0., 0.), radius)); + + let Some((pixels, duration)) = render_timed(size, draw) else { return }; + println!("Stroke radius {radius}: {duration:?}"); + assert!(duration.as_secs_f64() < 2., "radius {radius} took {duration:?}"); + assert_eq!(pixel(&pixels, size, 128, 128)[3], 0, "far inside at radius {radius}"); + } +} + +#[test] +fn enormous_line_renders() { + let size = UVec2::new(256, 256); + let draw = |scene: &mut VelloSceneBuilder| scene.stroke(&Stroke::new(4.), Affine::IDENTITY, RED, None, &Line::new((-1e30, 100.), (1e30, 100.))); + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 128, 100)[3], 255); +} + +#[test] +fn infinite_line_renders() { + let size = UVec2::new(256, 256); + let mut path = BezPath::new(); + path.move_to((-5., f64::INFINITY)); + path.line_to((5., f64::NEG_INFINITY)); + let draw = |scene: &mut VelloSceneBuilder| scene.stroke(&Stroke::new(4.), Affine::translate((128., 0.)), RED, None, &path); + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 128, 128)[3], 255); +} + +#[test] +fn nan_points_render_nothing_wrong() { + let size = UVec2::new(256, 256); + let mut path = BezPath::new(); + path.move_to((10., 10.)); + path.line_to((f64::NAN, 100.)); + path.line_to((100., 100.)); + path.close_path(); + let draw = |scene: &mut VelloSceneBuilder| { + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &path); + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &Rect::new(200., 200., 250., 250.)); + }; + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 225, 225)[3], 255); +} + +#[test] +fn many_gradients_render() { + let size = UVec2::new(1024, 1024); + let draw = |scene: &mut VelloSceneBuilder| { + for i in 0..250_000_u32 { + let (x, y) = ((i % 500) as f64 * 2., (i / 500) as f64 * 2.); + let gradient = peniko::Gradient::new_linear((x, y), (x + 2., y)).with_stops([Color::from_rgb8((i % 256) as u8, 0, 0), Color::from_rgb8(0, (i / 256 % 256) as u8, 255)]); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &Rect::new(x, y, x + 2., y + 2.)); + } + }; + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 500, 500)[3], 255); +} + +#[test] +fn deep_layer_nesting_renders() { + let size = UVec2::new(256, 256); + let draw = |scene: &mut VelloSceneBuilder| { + for _ in 0..2000 { + scene.push_layer(Fill::NonZero, peniko::Mix::Multiply, 1., Affine::IDENTITY, &Rect::new(0., 0., 256., 256.)); + } + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &Rect::new(0., 0., 256., 256.)); + for _ in 0..2000 { + scene.pop_layer(); + } + }; + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 128, 128)[3], 255); +} + +#[test] +fn render_larger_than_the_texture_limit_is_refused() { + let Some(executor) = executor() else { return }; + let limit = executor.context().device.limits().max_texture_dimension_2d; + let size = UVec2::new(limit + 100, 16); + let texture = executor.render_vello(size, DAffine2::IDENTITY, &mut |scene| { + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &Rect::new(0., 0., 1e6, 1e6)) + }); + assert!(texture.is_err()); +} + +#[test] +fn layer_four_times_the_layer_limit_renders_across_nested_splits() { + let Some(executor) = executor() else { return }; + let quarter = crate::MAX_LAYER_RENDER_SIZE; + if executor.context().device.limits().max_texture_dimension_2d < 4 * quarter { + return; + } + + // The layer spans the whole render, so it only fits once the render is halved twice + let size = UVec2::new(4 * quarter, 8); + let boundary = quarter * 3 / 2; + let draw = |scene: &mut VelloSceneBuilder| { + scene.push_layer(Fill::NonZero, peniko::Mix::Normal, 0.5, Affine::IDENTITY, &Rect::new(0., 0., size.x as f64, 8.)); + scene.fill(Fill::NonZero, Affine::IDENTITY, RED, None, &Rect::new(0., 0., boundary as f64, 8.)); + scene.fill(Fill::NonZero, Affine::IDENTITY, BLUE, None, &Rect::new(boundary as f64, 0., size.x as f64, 8.)); + scene.pop_layer(); + }; + + // Each side of the seams between quarters, and of the color boundary, which must land where it was drawn + let Some(pixels) = render(size, draw) else { return }; + for x in [quarter - 1, quarter, boundary - 1, boundary, 2 * quarter - 1, 2 * quarter, 3 * quarter - 1, 3 * quarter] { + let [r, g, b, a] = pixel(&pixels, size, x, 4); + let expected = if x < boundary { [255, 0, 0] } else { [0, 0, 255] }; + assert_eq!([r, g, b], expected, "color at x = {x}"); + assert!(a.abs_diff(128) <= 1, "alpha {a} at x = {x}"); + } +} + +#[test] +fn huge_image_renders() { + let size = UVec2::new(256, 256); + let (width, height) = (70_000_u32, 4_u32); + let data: Vec = (0..width * height).flat_map(|_| [255, 0, 0, 255]).collect(); + let image = peniko::ImageBrush::new(peniko::ImageData { + data: data.into(), + format: peniko::ImageFormat::Rgba8, + alpha_type: peniko::ImageAlphaType::Alpha, + width, + height, + }); + let draw = |scene: &mut VelloSceneBuilder| scene.draw_image(&image, Affine::scale_non_uniform(256. / width as f64, 64.)); + + let Some(pixels) = render(size, draw) else { return }; + assert_eq!(pixel(&pixels, size, 128, 128)[3], 255); +} + +#[test] +fn degenerate_strokes_render() { + let size = UVec2::new(256, 256); + let draw = |scene: &mut VelloSceneBuilder| { + scene.stroke(&Stroke::new(1e20), Affine::IDENTITY, RED, None, &Line::new((10., 10.), (20., 20.))); + scene.stroke(&Stroke::new(f64::NAN), Affine::IDENTITY, RED, None, &Line::new((10., 10.), (20., 20.))); + scene.stroke(&Stroke::new(5.), Affine::scale(0.), RED, None, &Line::new((10., 10.), (20., 20.))); + scene.stroke(&Stroke::new(5.), Affine::scale(f64::INFINITY), RED, None, &Line::new((10., 10.), (20., 20.))); + scene.stroke(&Stroke::new(5.), Affine::IDENTITY, RED, None, &Line::new((50., 50.), (50., 50.))); + }; + + let Some(_) = render(size, draw) else { return }; +} + +#[test] +fn dial_gradients_match_their_cpu_evaluation() { + use rendering::dial_gradient::PencilParameters; + + let size = UVec2::new(200, 200); + // Gradient space spans 4 units across the render, with the gradient line from (60, 100) to (110, 100) + let gradient_to_pixels = Affine::new([50., 0., 0., 50., 60., 100.]); + + for dials in [(1., 0.6), (0.6, 0.6), (-0.5, 0.2952), (1., 1.), (0.5, 0.25), (0.3, -0.3)] { + let parameters = PencilParameters::new(dials.0, dials.1); + let ramp = peniko::Gradient::new_linear((0., 0.), (1., 0.)).with_stops([Color::from_rgb8(0, 0, 0), Color::from_rgb8(255, 255, 255)]); + let gradient = vello_gpu::PencilGradient { + ramp, + geometry: parameters.to_vello(), + }; + + let draw = |scene: &mut VelloSceneBuilder| scene.fill_viewport(gradient.clone(), Some(gradient_to_pixels)); + + let Some(pixels) = render(size, draw) else { return }; + + let mut worst = 0_i32; + for y in (5..195).step_by(10) { + for x in (5..195).step_by(10) { + let point = gradient_to_pixels.inverse() * vello_gpu::kurbo::Point::new(x as f64 + 0.5, y as f64 + 0.5); + let t = parameters.t(glam::DVec2::new(point.x, point.y)).clamp(0., 1.); + let expected = (t * 255.).round() as i32; + let actual = pixel(&pixels, size, x, y)[0] as i32; + worst = worst.max((expected - actual).abs()); + } + } + assert!(worst <= 3, "dials {dials:?} differ from the CPU evaluation by up to {worst} levels"); + } +} + +/// Renders an opaque white square from (`min`, `min`) to (`max`, `max`) through `effects` with `fill`. +fn render_square_with_effects(effects: Vec, fill: f32, min: f64, max: f64, size: UVec2) -> Option> { + use rendering::{Render, RenderParams}; + + let square = Rect::new(min, min, max, max); + + // The paint overlays' paints, drawn as the renderer does for content placed in the scene's space + let paints: Vec = (effects.iter()) + .filter_map(|operation| match operation { + graphic_types::EffectOperation::PaintOverlay { paint, .. } => Some(paint), + _ => None, + }) + .map(|paint| graphic_types::fitted_paint(paint, [glam::DVec2::splat(min), glam::DVec2::splat(max)]).unwrap_or_else(|| paint.clone())) + .collect(); + let mut draw_paint = |index: usize, scene: &mut VelloSceneBuilder| { + if let Some(paint) = paints.get(index) { + paint.render_to_vello(scene, DAffine2::IDENTITY, &RenderParams::default().for_pattern()); + } + }; + + let effects = graphic_types::Effects::from_operations(effects); + render(size, |scene| { + scene.draw_effects(&effects, fill, Some(square), &mut draw_paint, &mut |scene| { + scene.fill(Fill::NonZero, Affine::IDENTITY, Color::WHITE, None, &square) + }); + }) +} + +/// The standard normal cumulative distribution, from the Abramowitz and Stegun approximation of the error function, accurate to 1e-7. +fn normal_cdf(x: f64) -> f64 { + let z = x.abs() / std::f64::consts::SQRT_2; + let t = 1. / (1. + 0.327_591_1 * z); + let polynomial = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1. - polynomial * (-z * z).exp(); + + if x >= 0. { 0.5 * (1. + erf) } else { 0.5 * (1. - erf) } +} + +#[test] +fn gaussian_blur_follows_the_normal_distribution() { + use graphic_types::EffectOperation; + + let size = UVec2::new(512, 256); + // Small kernels run directly, and larger ones on halved textures + for sigma in [2., 6., 20.] { + let blur = EffectOperation::Blur { + radius: sigma, + box_blur: false, + gamma: false, + }; + // A square much larger than the blur, so across its left edge at x = 100, on a row far from its other edges, the coverage is the one-dimensional profile + let Some(pixels) = render_square_with_effects(vec![blur], 1., 100., 400., size) else { return }; + + let mut worst = 0_f64; + for x in 40..160 { + let expected = normal_cdf((x as f64 + 0.5 - 100.) / sigma); + let actual = pixel(&pixels, size, x, 200)[3] as f64 / 255.; + worst = worst.max((expected - actual).abs()); + } + assert!(worst < 0.03, "σ {sigma} strays from the normal distribution by {worst}"); + } +} + +#[test] +fn shadows_and_glows_fall_where_they_should() { + use core_types::Color as GraphicColor; + use glam::DVec2; + use graphic_types::EffectOperation; + + let size = UVec2::new(128, 128); + let black = GraphicColor::BLACK; + + // A hard drop shadow offset down and right shows past the square's bottom right corner, beneath it + let shadow = EffectOperation::DropShadow { + color: black, + offset: DVec2::new(10., 10.), + blur: 0., + spread: 0., + }; + let Some(pixels) = render_square_with_effects(vec![shadow.clone()], 1., 30., 80., size) else { + return; + }; + assert_eq!(pixel(&pixels, size, 50, 50), [255, 255, 255, 255], "the content covers its shadow"); + assert_eq!(pixel(&pixels, size, 85, 85), [0, 0, 0, 255], "the shadow shows past the content"); + assert_eq!(pixel(&pixels, size, 25, 25)[3], 0, "no shadow before the offset"); + + // With no fill, the content vanishes and its shadow stays knocked out where the content would be + let Some(pixels) = render_square_with_effects(vec![shadow], 0., 30., 80., size) else { return }; + assert_eq!(pixel(&pixels, size, 50, 50)[3], 0, "the shadow is knocked out by the faded content"); + assert_eq!(pixel(&pixels, size, 85, 85), [0, 0, 0, 255], "the shadow stays at full strength"); + + // An inner shadow darkens the content's top left edges from inside and stays within it + let inner_shadow = EffectOperation::InnerShadow { + color: black, + offset: DVec2::new(6., 6.), + blur: 0., + spread: 0., + }; + let Some(pixels) = render_square_with_effects(vec![inner_shadow], 1., 30., 80., size) else { + return; + }; + assert_eq!(pixel(&pixels, size, 32, 50), [0, 0, 0, 255], "inside the left edge"); + assert_eq!(pixel(&pixels, size, 50, 50), [255, 255, 255, 255], "deep inside"); + assert_eq!(pixel(&pixels, size, 78, 50), [255, 255, 255, 255], "inside the right edge, away from the offset"); + assert_eq!(pixel(&pixels, size, 25, 50)[3], 0, "outside"); + + // A glow grown by its spread surrounds the content evenly + let glow = EffectOperation::OuterGlow { + color: GraphicColor::from_rgbaf32_unchecked(1., 0., 0., 1.), + blur: 0., + spread: 5., + }; + let Some(pixels) = render_square_with_effects(vec![glow], 1., 30., 80., size) else { return }; + for (x, y) in [(27, 55), (82, 55), (55, 27), (55, 82)] { + assert_eq!(pixel(&pixels, size, x, y), [255, 0, 0, 255], "glow at ({x}, {y})"); + } + for (x, y) in [(23, 55), (87, 55)] { + assert_eq!(pixel(&pixels, size, x, y)[3], 0, "past the glow at ({x}, {y})"); + } +} + +#[test] +fn inner_glows_satins_outlines_and_bevels_fall_where_they_should() { + use core_types::Color as GraphicColor; + use glam::DVec2; + use graphic_types::vector_types::vector::style::StrokeAlign; + use graphic_types::{BevelDirection, BevelStyle, EffectOperation, GlowSource}; + + let size = UVec2::new(128, 128); + let black = GraphicColor::BLACK; + let render_square = |effect| render_square_with_effects(vec![effect], 1., 30., 80., size); + let (white, clear) = ([255, 255, 255, 255], 0); + + // A hard inner glow from the edges covers a band inside them, and one from the center covers the rest + let inner_glow = |source| EffectOperation::InnerGlow { + color: black, + blur: 0., + choke: 3., + source, + }; + let Some(pixels) = render_square(inner_glow(GlowSource::Edge)) else { return }; + assert_eq!(pixel(&pixels, size, 31, 55), [0, 0, 0, 255], "the edge glows"); + assert_eq!(pixel(&pixels, size, 55, 55), white, "the center doesn't"); + assert_eq!(pixel(&pixels, size, 25, 55)[3], clear, "nothing outside"); + let Some(pixels) = render_square(inner_glow(GlowSource::Center)) else { return }; + assert_eq!(pixel(&pixels, size, 31, 55), white, "the edge doesn't glow"); + assert_eq!(pixel(&pixels, size, 55, 55), [0, 0, 0, 255], "the center does"); + + // Satin shades where the two casts of the silhouette disagree, which for a horizontal offset is a band along each side + let satin = EffectOperation::Satin { + color: black, + offset: DVec2::new(10., 0.), + blur: 0., + invert: false, + }; + let Some(pixels) = render_square(satin) else { return }; + assert_eq!(pixel(&pixels, size, 35, 55), [0, 0, 0, 255], "the left band"); + assert_eq!(pixel(&pixels, size, 55, 55), white, "the middle, where both casts cover"); + assert_eq!(pixel(&pixels, size, 75, 55), [0, 0, 0, 255], "the right band"); + + // An outline outside the edge rounds the corners it grows around, and one inside stays within the content + let outline = |align| EffectOperation::Outline { color: black, weight: 5., align }; + let Some(pixels) = render_square(outline(StrokeAlign::Outside)) else { return }; + assert_eq!(pixel(&pixels, size, 26, 55), [0, 0, 0, 255], "within the weight of the edge"); + assert_eq!(pixel(&pixels, size, 23, 55)[3], clear, "past the weight"); + assert_eq!(pixel(&pixels, size, 27, 27), [0, 0, 0, 255], "near the corner"); + assert!(pixel(&pixels, size, 26, 26)[3] < 128, "beyond the rounded corner, where a square would reach"); + assert_eq!(pixel(&pixels, size, 55, 55), white, "the content shows"); + let Some(pixels) = render_square(outline(StrokeAlign::Inside)) else { return }; + assert_eq!(pixel(&pixels, size, 32, 55), [0, 0, 0, 255], "inside the edge"); + assert_eq!(pixel(&pixels, size, 40, 55), white, "past the weight inside"); + assert_eq!(pixel(&pixels, size, 28, 55)[3], clear, "nothing outside"); + + // Lit from the upper left, a raised bevel's top slope catches the highlight and its bottom slope falls into shadow, which swap when it sinks + let bevel = |style, direction| EffectOperation::BevelAndEmboss { + style, + depth: 100., + direction, + size: 5., + soften: 0., + angle: 120., + altitude: 30., + highlight_color: GraphicColor::RED, + shadow_color: GraphicColor::BLUE, + }; + let reddish = |[red, green, blue, alpha]: [u8; 4]| red > 200 && green < 128 && blue < 128 && alpha > 128; + let bluish = |[red, green, blue, alpha]: [u8; 4]| blue > 200 && red < 128 && green < 128 && alpha > 128; + let Some(pixels) = render_square(bevel(BevelStyle::InnerBevel, BevelDirection::Up)) else { return }; + assert!(reddish(pixel(&pixels, size, 55, 32)), "the top slope is highlighted: {:?}", pixel(&pixels, size, 55, 32)); + assert!(bluish(pixel(&pixels, size, 55, 77)), "the bottom slope is shaded: {:?}", pixel(&pixels, size, 55, 77)); + assert_eq!(pixel(&pixels, size, 55, 55), white, "the flat top inside the slopes is unlit"); + assert_eq!(pixel(&pixels, size, 55, 20)[3], clear, "nothing outside"); + let Some(pixels) = render_square(bevel(BevelStyle::InnerBevel, BevelDirection::Down)) else { + return; + }; + assert!(bluish(pixel(&pixels, size, 55, 32)), "a sunken top slope is shaded: {:?}", pixel(&pixels, size, 55, 32)); + assert!(reddish(pixel(&pixels, size, 55, 77)), "a sunken bottom slope is highlighted: {:?}", pixel(&pixels, size, 55, 77)); + + // An outer bevel's slopes lie outside the content, beneath it + let Some(pixels) = render_square(bevel(BevelStyle::OuterBevel, BevelDirection::Up)) else { return }; + assert!(reddish(pixel(&pixels, size, 55, 27)), "the top slope outside is highlighted: {:?}", pixel(&pixels, size, 55, 27)); + assert!(bluish(pixel(&pixels, size, 55, 82)), "the bottom slope outside is shaded: {:?}", pixel(&pixels, size, 55, 82)); + assert_eq!(pixel(&pixels, size, 55, 40), white, "the content inside is unlit"); +} + +#[test] +fn paint_overlays_stretch_unplaced_paints_across_the_content() { + use core_types::Color as GraphicColor; + use core_types::blending::BlendMode; + use core_types::list::Item; + use graphic_types::raster_types::{Image, Raster}; + use graphic_types::vector_types::gradient::{Gradient, GradientStop}; + use graphic_types::{EffectOperation, Graphic}; + + let size = UVec2::new(128, 128); + let overlay = |paint: Graphic| EffectOperation::PaintOverlay { + paint, + blend_mode: BlendMode::Normal, + opacity: 100., + }; + let reddish = |[red, green, blue, alpha]: [u8; 4]| red > 200 && green < 64 && blue < 64 && alpha == 255; + let bluish = |[red, green, blue, alpha]: [u8; 4]| blue > 200 && red < 64 && green < 64 && alpha == 255; + + // A gradient from red to blue runs from the square's left edge to its right edge + let stops = [(0., GraphicColor::RED), (1., GraphicColor::BLUE)].map(|(position, color)| GradientStop { position, midpoint: 0.5, color }); + let Some(pixels) = render_square_with_effects(vec![overlay(Graphic::from(Gradient::new(stops)))], 1., 30., 80., size) else { + return; + }; + assert!(reddish(pixel(&pixels, size, 31, 55)), "the start at the left edge: {:?}", pixel(&pixels, size, 31, 55)); + assert!(bluish(pixel(&pixels, size, 78, 55)), "the end at the right edge: {:?}", pixel(&pixels, size, 78, 55)); + assert_eq!(pixel(&pixels, size, 25, 55)[3], 0, "nothing outside the content"); + + // An image of a red pixel beside a blue one covers the square, each pixel half of it + let image = Image { + width: 2, + height: 1, + data: vec![GraphicColor::RED, GraphicColor::BLUE], + }; + let Some(pixels) = render_square_with_effects(vec![overlay(Graphic::from(Item::new_from_element(Raster::new_cpu(image))))], 1., 30., 80., size) else { + return; + }; + assert!(reddish(pixel(&pixels, size, 35, 40)), "the left half: {:?}", pixel(&pixels, size, 35, 40)); + assert!(bluish(pixel(&pixels, size, 75, 70)), "the right half: {:?}", pixel(&pixels, size, 75, 70)); +} + +#[test] +fn later_shadows_stack_over_earlier_ones_beneath_the_content() { + use core_types::Color as GraphicColor; + use glam::DVec2; + use graphic_types::EffectOperation; + + let size = UVec2::new(128, 128); + let shadow = |color, offset: f64| EffectOperation::DropShadow { + color, + offset: DVec2::splat(offset), + blur: 0., + spread: 0., + }; + + // Both shadows are cast from the content rather than from each other, and the later one is on top where they overlap + let effects = vec![shadow(GraphicColor::BLACK, 10.), shadow(GraphicColor::from_rgbaf32_unchecked(0., 0., 1., 1.), 5.)]; + let Some(pixels) = render_square_with_effects(effects, 1., 30., 80., size) else { return }; + assert_eq!(pixel(&pixels, size, 82, 82), [0, 0, 255, 255], "the later shadow is on top"); + assert_eq!(pixel(&pixels, size, 88, 88), [0, 0, 0, 255], "the earlier shadow reaches further"); + assert_eq!(pixel(&pixels, size, 93, 93)[3], 0, "no shadow of a shadow"); +} + +#[test] +fn nested_effects_groups_render_inside_out() { + use core_types::Color as GraphicColor; + use glam::DVec2; + use graphic_types::{EffectOperation, Effects}; + + let size = UVec2::new(128, 128); + let square = Rect::new(30., 30., 80., 80.); + + // A hard shadow around a group whose content is itself a blurred square + let shadow = Effects::from_operations([EffectOperation::DropShadow { + color: GraphicColor::BLACK, + offset: DVec2::new(20., 0.), + blur: 0., + spread: 0., + }]); + let blur = Effects::from_operations([EffectOperation::Blur { + radius: 3., + box_blur: false, + gamma: false, + }]); + let draw = |scene: &mut VelloSceneBuilder| { + scene.draw_effects(&shadow, 1., Some(square.inflate(12., 12.)), &mut |_, _| {}, &mut |scene| { + scene.draw_effects(&blur, 1., Some(square), &mut |_, _| {}, &mut |scene| { + scene.fill(Fill::NonZero, Affine::IDENTITY, Color::WHITE, None, &square) + }); + }); + }; + + let Some(pixels) = render(size, draw) else { return }; + let edge = pixel(&pixels, size, 30, 55); + assert!((100..=155).contains(&edge[3]), "the blur softens the edge to half coverage, not {edge:?}"); + // Ten pixels past the content's blurred edge, where the shadow is cast from ten pixels inside it + let shadow = pixel(&pixels, size, 90, 55); + assert!(shadow[3] >= 250 && shadow[0] <= 5, "the shadow is cast by the blurred group, not {shadow:?}"); +} + +#[test] +fn cpu_evaluated_effects_match_the_gpu_passes() { + use core_types::Color as GraphicColor; + use core_types::blending::BlendMode; + use core_types::list::ATTR_GRADIENT_FORM; + use core_types::list::{ATTR_EFFECTS, Item}; + use core_types::{ATTR_OPACITY_FILL, ATTR_TRANSFORM}; + use glam::DVec2; + use graphic_types::adjustment::{Adjustment, LevelsRecord, LevelsSettings}; + use graphic_types::raster_types::{Image, Raster}; + use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientStop}; + use graphic_types::vector_types::vector::style::StrokeAlign; + use graphic_types::{BevelDirection, BevelStyle, EffectOperation, Effects, GlowSource, Graphic, evaluate_raster_effects}; + + let size = UVec2::new(160, 160); + let (min, max) = (50, 100); + let shadow_color = GraphicColor::from_rgbaf32_unchecked(0.1, 0.2, 0.8, 0.75); + let blur = |radius, box_blur, gamma| EffectOperation::Blur { radius, box_blur, gamma }; + let bevel = |style, direction, size, soften| EffectOperation::BevelAndEmboss { + style, + depth: 150., + direction, + size, + soften, + angle: 120., + altitude: 30., + highlight_color: GraphicColor::from_rgbaf32_unchecked(1., 0.9, 0.2, 0.75), + shadow_color, + }; + let identity = LevelsRecord { + shadows: 0., + midtones: 1., + highlights: 100., + output_minimums: 0., + output_maximums: 100., + }; + + let cases = [ + vec![blur(3., false, false)], + vec![blur(20., false, true)], + vec![blur(5.5, true, false)], + vec![EffectOperation::DropShadow { + color: shadow_color, + offset: DVec2::new(6.5, -3.), + blur: 4., + spread: 2., + }], + vec![EffectOperation::InnerShadow { + color: shadow_color, + offset: DVec2::new(4., 4.), + blur: 3., + spread: 0., + }], + vec![ + EffectOperation::OuterGlow { + color: shadow_color, + blur: 6., + spread: 3., + }, + blur(2., false, false), + ], + vec![EffectOperation::InnerGlow { + color: shadow_color, + blur: 5., + choke: 2., + source: GlowSource::Edge, + }], + vec![EffectOperation::InnerGlow { + color: shadow_color, + blur: 8., + choke: 0., + source: GlowSource::Center, + }], + vec![EffectOperation::Satin { + color: shadow_color, + offset: DVec2::new(7., 3.), + blur: 6., + invert: true, + }], + vec![EffectOperation::Satin { + color: shadow_color, + offset: DVec2::new(-4., 9.), + blur: 3., + invert: false, + }], + vec![EffectOperation::Outline { + color: shadow_color, + weight: 6., + align: StrokeAlign::Outside, + }], + vec![EffectOperation::Outline { + color: shadow_color, + weight: 5., + align: StrokeAlign::Inside, + }], + vec![EffectOperation::Outline { + color: shadow_color, + weight: 9., + align: StrokeAlign::Center, + }], + vec![bevel(BevelStyle::InnerBevel, BevelDirection::Up, 8., 0.)], + vec![bevel(BevelStyle::OuterBevel, BevelDirection::Down, 6., 2.)], + vec![bevel(BevelStyle::Emboss, BevelDirection::Up, 10., 0.)], + vec![bevel(BevelStyle::PillowEmboss, BevelDirection::Up, 12., 1.)], + // Bends alpha, so the fill must fade the content before it on both + vec![EffectOperation::Adjustment(Adjustment::Levels(Box::new(LevelsSettings { + composite: identity, + red: identity, + green: identity, + blue: identity, + alpha: LevelsRecord { midtones: 2.2, ..identity }, + })))], + ]; + + // Paint overlays over a colored backdrop, their paints placed on their own since the raster and the square fit unplaced paints to different bounds + let backdrop = EffectOperation::Adjustment(Adjustment::ColorOverlay { + color: GraphicColor::from_rgbaf32_unchecked(0.3, 0.6, 0.2, 1.), + blend_mode: BlendMode::Normal, + opacity: 100., + }); + let gradient = |form: GradientForm, transform: DAffine2| { + let stops = [(0., [0.9, 0.1, 0.4, 1.]), (0.5, [0.2, 0.3, 0.9, 0.5]), (1., [1., 0.9, 0.7, 1.])].map(|(position, [red, green, blue, alpha])| GradientStop { + position, + midpoint: 0.5, + color: GraphicColor::from_rgbaf32_unchecked(red, green, blue, alpha), + }); + let item = Item::new_from_element(Gradient::new(stops)).with_attribute(ATTR_TRANSFORM, transform); + Graphic::from(item.with_attribute(ATTR_GRADIENT_FORM, form)) + }; + let linear = gradient(GradientForm::Linear, DAffine2::from_translation(DVec2::new(45., 60.)) * DAffine2::from_scale(DVec2::splat(60.))); + let radial = gradient(GradientForm::Radial, DAffine2::from_translation(DVec2::splat(75.)) * DAffine2::from_scale(DVec2::splat(30.))); + + // A checkerboard with a pixel for each of the square's, so sampling it needs no interpolation + let checker = Image { + width: 50, + height: 50, + data: (0..2500) + .map(|index| match (index % 50 / 5 + index / 50 / 5) % 2 { + 0 => GraphicColor::RED, + _ => GraphicColor::from_rgbaf32_unchecked(0., 0., 1., 0.5), + }) + .collect(), + }; + let checker_placement = DAffine2::from_translation(DVec2::splat(50.)) * DAffine2::from_scale(DVec2::splat(50.)); + let checker = Graphic::from(Item::new_from_element(Raster::new_cpu(checker)).with_attribute(ATTR_TRANSFORM, checker_placement)); + + let overlay = |paint: &Graphic, blend_mode, opacity| { + vec![ + backdrop.clone(), + EffectOperation::PaintOverlay { + paint: paint.clone(), + blend_mode, + opacity, + }, + ] + }; + // Hard Mix's threshold and the hue of the gradient's grays turn the slightest rounding into large differences, so they take the checkerboard's exact colors instead + let ill_conditioned = [BlendMode::HardMix, BlendMode::Hue]; + let mut paint_cases: Vec<_> = (BlendMode::list().into_iter().flatten()) + .filter(|blend_mode| !ill_conditioned.contains(blend_mode)) + .map(|&blend_mode| overlay(&linear, blend_mode, 100.)) + .collect(); + paint_cases.extend(ill_conditioned.map(|blend_mode| overlay(&checker, blend_mode, 100.))); + paint_cases.extend([ + overlay(&linear, BlendMode::Normal, 40.), + overlay(&radial, BlendMode::Multiply, 100.), + overlay(&checker, BlendMode::Screen, 100.), + ]); + + for effects in cases.into_iter().chain(paint_cases) { + for fill in [1., 0.5] { + let Some(gpu) = render_square_with_effects(effects.clone(), fill, min as f64, max as f64, size) else { + return; + }; + + // The same square as a raster covering the render one pixel to one pixel + let mut image = Image::new(size.x, size.y, GraphicColor::TRANSPARENT); + for y in min..max { + for x in min..max { + image.data[(y * size.x + x) as usize] = GraphicColor::WHITE; + } + } + let item = Item::new_from_element(Raster::new_cpu(image)) + .with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(size.as_dvec2())) + .with_attribute(ATTR_EFFECTS, Effects::from_operations(effects.clone())) + .with_attribute(ATTR_OPACITY_FILL, fill as f64); + let evaluated = evaluate_raster_effects(item); + + // Reads the evaluated image at the center of each render pixel through its grown placement + let placement: DAffine2 = evaluated.attribute_cloned_or_default(ATTR_TRANSFORM); + let raster = evaluated.element(); + let document_to_pixels = DAffine2::from_scale(DVec2::new(raster.width as f64, raster.height as f64)) * placement.inverse(); + + let mut worst = 0; + for y in 0..size.y { + for x in 0..size.x { + let point = document_to_pixels.transform_point2(DVec2::new(x as f64 + 0.5, y as f64 + 0.5)).floor(); + let cpu = raster.data[(point.y as u32 * raster.width + point.x as u32) as usize]; + let [red, green, blue, alpha] = cpu.to_gamma_srgb_channels(); + let cpu = [red * alpha, green * alpha, blue * alpha, alpha].map(|channel| channel * 255.); + + // Compared premultiplied, so a nearly transparent pixel's color can't dominate + let gpu = pixel(&gpu, size, x, y); + let gpu_alpha = gpu[3] as f32 / 255.; + for channel in 0..4 { + let gpu_channel = if channel == 3 { gpu[3] as f32 } else { gpu[channel] as f32 * gpu_alpha }; + worst = worst.max((cpu[channel] - gpu_channel).abs().round() as u32); + } + } + } + // Modes that divide, or land near black where the gamma encoding is steepest, amplify the rounding of the rendered paint's eight bits + use BlendMode::*; + let tolerance = match effects.last() { + Some(EffectOperation::PaintOverlay { + blend_mode: ColorBurn | LinearBurn | ColorDodge | HardLight | VividLight | LinearLight | PinLight, + .. + }) => 8, + _ => 2, + }; + assert!(worst <= tolerance, "{effects:?} at fill {fill} differs from the GPU by up to {worst} levels"); + } + } +} + +#[test] +fn gpu_adjustment_passes_match_the_cpu_kernels() { + use core_types::Color as GraphicColor; + use core_types::list::Item; + use graphic_types::adjustment::*; + use graphic_types::raster_types::{Image, Raster}; + use graphic_types::{EffectOperation, Effects}; + + // Opaque colors spread across the RGB cube, one per pixel, on whole 8-bit levels as the content's 8-bit render holds them + let size = UVec2::new(64, 64); + let channel = |value: u32| ((value % 64) * 4) as f32 / 255.; + let colors: Vec = (0..size.x * size.y) + .map(|index| GraphicColor::from_gamma_srgb_channels(channel(index % 64), channel(index / 64), channel(index * 7 + index / 64 * 13), 1.)) + .collect(); + let raster = Raster::new_cpu(Image { + width: size.x, + height: size.y, + data: colors.clone(), + }); + + let levels_record = |shadows, midtones, highlights| LevelsRecord { + shadows, + midtones, + highlights, + output_minimums: 0., + output_maximums: 100., + }; + let identity = levels_record(0., 1., 100.); + let sliders = |hue, saturation, lightness| HueSaturationSliders { hue, saturation, lightness }; + let range = |sliders, falloff_start, range_start, range_end, falloff_end| HueRangeSettings { + sliders, + falloff_start, + range_start, + range_end, + falloff_end, + }; + let zero = sliders(0., 0., 0.); + + // Each run with the most levels it may stray: curves and luma tables by under a level, the cube by its interpolation + let runs: Vec<(Vec, u8)> = vec![ + ( + vec![ + Adjustment::Levels(Box::new(LevelsSettings { + composite: levels_record(10., 1.4, 90.), + red: identity, + green: levels_record(5., 0.8, 100.), + blue: identity, + alpha: identity, + })), + Adjustment::Exposure { + exposure: 0.5, + offset: 0., + gamma_correction: 1.2, + }, + Adjustment::Invert, + ], + 2, + ), + ( + vec![ + Adjustment::HueSaturation(Box::new(HueSaturationSettings { + master: sliders(25., 30., 5.), + colorize: false, + colorize_sliders: zero, + reds: range(sliders(-15., 40., 0.), 315., 345., 15., 45.), + yellows: range(zero, 15., 45., 75., 105.), + greens: range(zero, 75., 105., 135., 165.), + cyans: range(zero, 135., 165., 195., 225.), + blues: range(zero, 195., 225., 255., 285.), + magentas: range(zero, 255., 285., 315., 345.), + })), + Adjustment::Desaturate { method: DesaturateMethod::LumaRec601 }, + ], + 5, + ), + ( + vec![Adjustment::GradientMap { + gradient: Item::new_from_element(graphic_types::vector_types::Gradient::from(vec![ + GraphicColor::from_rgbaf32_unchecked(0.1, 0., 0.4, 1.), + GraphicColor::from_rgbaf32_unchecked(1., 0.8, 0.2, 1.), + ])), + reverse: false, + }], + 2, + ), + (vec![Adjustment::Vibrance { vibrance: 60., saturation: 20. }], 2), + // Runs of two and three passes, which hand their colors between textures + (vec![Adjustment::Vibrance { vibrance: 40., saturation: 0. }, Adjustment::Invert], 3), + (vec![Adjustment::Invert, Adjustment::Vibrance { vibrance: -30., saturation: 10. }, Adjustment::Invert], 3), + ]; + + for (run, tolerance) in runs { + let mut expected = colors.clone(); + run.iter().for_each(|adjustment| adjustment.apply(&mut expected)); + + // Carried out on the rendered content by an effects group, and on the image's own texture as it's drawn + let effects = Effects::from_operations(run.iter().cloned().map(EffectOperation::Adjustment)); + let through_effects_group = |scene: &mut VelloSceneBuilder| { + let bounds = Rect::new(0., 0., size.x as f64, size.y as f64); + scene.draw_effects(&effects, 1., Some(bounds), &mut |_, _| {}, &mut |scene| { + scene.draw_raster(raster.clone(), Vec::new(), peniko::ImageSampler::new(), Affine::IDENTITY) + }); + }; + let on_image_texture = |scene: &mut VelloSceneBuilder| scene.draw_raster(raster.clone(), run.clone(), peniko::ImageSampler::new(), Affine::IDENTITY); + let paths = [ + ("through an effects group", &through_effects_group as &dyn Fn(&mut VelloSceneBuilder)), + ("on the image's texture", &on_image_texture), + ]; + + for (path, draw) in paths { + // The first render bakes any cube as a draft, which rendering the same run again refines + let Some(_) = render(size, draw) else { return }; + let Some(gpu) = render(size, draw) else { return }; + + let mut worst = 0; + for (index, color) in expected.iter().enumerate() { + let cpu = color.to_gamma_srgb_channels().map(|channel| (channel * 255.).round() as i32); + for channel in 0..3 { + worst = worst.max((cpu[channel] - gpu[index][channel] as i32).unsigned_abs()); + } + } + let names = run.iter().map(|adjustment| adjustment.kind().to_string()).collect::>().join(" + "); + assert!(worst <= tolerance as u32, "{names} {path} differs from the CPU kernels by up to {worst} levels"); + } + } +} + +#[test] +fn minified_images_average_their_pixels() { + use graphic_types::raster_types::{Image, Raster}; + + // Stripes half black and half white in every eight columns, drawn at an eighth of their size, where sampling only the two + // columns nearest each pixel's center would find black + let stripes = Image { + width: 64, + height: 64, + data: (0..64 * 64) + .map(|index| if (2..6).contains(&(index % 8)) { core_types::Color::BLACK } else { core_types::Color::WHITE }) + .collect(), + }; + let stripes = Raster::new_cpu(stripes); + let draw = |scene: &mut VelloSceneBuilder| scene.draw_raster(stripes.clone(), Vec::new(), peniko::ImageSampler::new(), Affine::scale(1. / 8.)); + + let size = UVec2::new(8, 8); + let Some(pixels) = render(size, draw) else { return }; + for (index, pixel) in pixels.iter().enumerate() { + assert!((115..=140).contains(&pixel[0]) && pixel[3] == 255, "pixel {index} is {pixel:?} rather than the gray of its average"); + } +} + +#[test] +fn minified_adjusted_images_sample_their_adjusted_mipmaps() { + use graphic_types::Adjustment; + use graphic_types::raster_types::{Image, Raster}; + + // White inverted to black, drawn at an eighth of its size where only the smaller levels are sampled + let white = Raster::new_cpu(Image::new(64, 64, core_types::Color::WHITE)); + let draw = |scene: &mut VelloSceneBuilder| scene.draw_raster(white.clone(), vec![Adjustment::Invert], peniko::ImageSampler::new(), Affine::scale(1. / 8.)); + + let Some(pixels) = render(UVec2::new(8, 8), draw) else { return }; + for (index, pixel) in pixels.iter().enumerate() { + assert!(pixel[0] <= 1 && pixel[3] == 255, "pixel {index} is {pixel:?} rather than black"); + } +} + +#[test] +fn rasters_upload_once_across_scenes_until_modified() { + use graphic_types::raster_types::{Image, Raster}; + + let Some(executor) = executor() else { return }; + let _turn = take_turn(); + let max_texture_size = executor.context().device.limits().max_texture_dimension_2d; + let raster = Raster::new_cpu(Image::new(8, 8, core_types::Color::WHITE)); + + // Each render draws its scene anew, as the editor does whenever the graph runs + let scene_drawing = |raster: &Raster| { + let mut scene = VelloSceneBuilder::new(64, 64, Affine::IDENTITY, max_texture_size); + scene.draw_raster(raster.clone(), Vec::new(), peniko::ImageSampler::new(), Affine::scale(8.)); + scene + }; + + let mut canvas = crate::VelloCanvas::default(); + for _ in 0..3 { + executor.render_vello_scene_on_canvas(&mut canvas, scene_drawing(&raster)).unwrap(); + } + assert_eq!(canvas.images.textures.len(), 1, "a clone of the same pixels reuses their texture"); + + let mut modified = raster.clone(); + modified.data_mut().data[0] = core_types::Color::BLACK; + executor.render_vello_scene_on_canvas(&mut canvas, scene_drawing(&modified)).unwrap(); + assert_eq!(canvas.images.textures.len(), 2, "modified pixels get a texture of their own"); +} + +#[test] +fn adjusted_images_remake_one_copy_as_their_adjustments_change() { + use graphic_types::Adjustment; + use graphic_types::raster_types::{Image, Raster}; + + let Some(executor) = executor() else { return }; + let _turn = take_turn(); + let new_scene = || VelloSceneBuilder::new(64, 64, Affine::IDENTITY, executor.context().device.limits().max_texture_dimension_2d); + let raster = Raster::new_cpu(Image::new(8, 8, core_types::Color::WHITE)); + let exposure = |exposure| Adjustment::Exposure { + exposure, + offset: 0., + gamma_correction: 1., + }; + let copies = |canvas: &crate::VelloCanvas| canvas.images.textures.values().map(|image_texture| image_texture.adjusted.len()).sum::(); + + // Dragging a slider adjusts the same pixels differently in every render + let mut canvas = crate::VelloCanvas::default(); + for amount in [0.1, 0.2, 0.3] { + let mut scene = new_scene(); + scene.draw_raster(raster.clone(), vec![exposure(amount)], peniko::ImageSampler::new(), Affine::scale(8.)); + executor.render_vello_scene_on_canvas(&mut canvas, scene).unwrap(); + } + assert_eq!(copies(&canvas), 1, "each new run takes over the copy the last render drew"); + + // Two layers sharing the pixels with different adjustments need a copy each + let mut scene = new_scene(); + scene.draw_raster(raster.clone(), vec![exposure(0.4)], peniko::ImageSampler::new(), Affine::scale(4.)); + scene.draw_raster(raster.clone(), vec![Adjustment::Invert], peniko::ImageSampler::new(), Affine::scale(8.)); + executor.render_vello_scene_on_canvas(&mut canvas, scene).unwrap(); + assert_eq!(copies(&canvas), 2); +} + +/// The settings of a noise pattern with features a few units across. +fn noise_settings() -> graphic_types::generator::NoiseSettings { + use graphic_types::generator::*; + + NoiseSettings { + seed: 11, + scale: 6., + noise_type: NoiseType::OpenSimplex2, + domain_warp_type: DomainWarpType::None, + domain_warp_amplitude: 0., + fractal_type: FractalType::FBm, + fractal_octaves: 3, + fractal_lacunarity: 2., + fractal_gain: 0.5, + fractal_weighted_strength: 0., + fractal_ping_pong_strength: 2., + cellular_distance_function: CellularDistanceFunction::Euclidean, + cellular_return_type: CellularReturnType::Nearest, + cellular_jitter: 1., + } +} + +/// A noise pattern with features a few units across. +fn noise_pattern() -> graphic_types::generator::GeneratorPattern { + graphic_types::generator::GeneratorPattern::Noise(noise_settings()) +} + +/// Draws `pattern` with `pixels_per_unit` pixels to a unit, from `origin` in its space, comparing 64 by 64 pixels with the generator's own samples. +/// Returns how many pixels differ by more than 2 levels and the most any differs by, or `None` without a GPU. +fn drawing_differences(pattern: &graphic_types::generator::GeneratorPattern, pixels_per_unit: f64, origin: glam::DVec2) -> Option<(usize, u8)> { + let size = UVec2::new(80, 80); + let pixels = render(size, |scene| { + scene.draw_generator(pattern, Affine::translate((8., 8.)) * Affine::scale(pixels_per_unit) * Affine::translate((-origin.x, -origin.y))) + })?; + + // The pattern fills the plane, past the area compared + assert_eq!(pixel(&pixels, size, 2, 2)[3], 255); + assert_eq!(pixel(&pixels, size, 76, 76)[3], 255); + + let (expected, _, _) = pattern.render([origin, origin + 64. / pixels_per_unit], 64, 64).to_flat_u8(); + let differences: Vec = (0..64 * 64) + .map(|index| { + let drawn = pixel(&pixels, size, index % 64 + 8, index / 64 + 8); + let sampled = &expected[(index * 4) as usize..][..4]; + (0..3).map(|channel| drawn[channel].abs_diff(sampled[channel])).max().unwrap_or(0) + }) + .collect(); + + let mismatched = differences.iter().filter(|&&difference| difference > 2).count(); + Some((mismatched, differences.into_iter().max().unwrap_or(0))) +} + +#[test] +fn generators_draw_their_pattern_at_the_resolution_in_view() { + // Each unit of the pattern drawn across two pixels, so its tiles sample the pattern at every pixel's center + let Some((_, worst)) = drawing_differences(&noise_pattern(), 2., glam::DVec2::ZERO) else { return }; + assert!(worst <= 2, "the drawn pattern differs from the generator's own samples by up to {worst} levels"); +} + +#[test] +fn every_noise_setting_renders_on_the_gpu_like_on_the_cpu() { + use core_types::choice_type::ChoiceTypeStatic; + use graphic_types::generator::*; + + fn every() -> impl Iterator { + T::list().iter().flat_map(|section| section.iter().map(|(variant, _)| *variant)) + } + + // Each noise type, fractal type, and domain warp, and each pairing of the cellular noise's distance function and return type + let base = || NoiseSettings { + fractal_type: FractalType::None, + ..noise_settings() + }; + let warps = [FractalType::None, FractalType::DomainWarpProgressive, FractalType::DomainWarpIndependent]; + let variations = (every::().map(|noise_type| NoiseSettings { noise_type, ..base() })) + .chain(every::().map(|fractal_type| NoiseSettings { + fractal_type, + fractal_weighted_strength: 0.5, + ..base() + })) + .chain(every::().flat_map(|domain_warp_type| { + warps.map(|fractal_type| NoiseSettings { + domain_warp_type, + domain_warp_amplitude: 4., + fractal_type, + ..base() + }) + })) + .chain(every::().flat_map(|cellular_distance_function| { + every::().map(move |cellular_return_type| NoiseSettings { + noise_type: NoiseType::Cellular, + cellular_distance_function, + cellular_return_type, + ..base() + }) + })); + + // Another GPU's rounding may put a few pixels on the other side of a cell's edge + for settings in variations { + let Some((mismatched, _)) = drawing_differences(&GeneratorPattern::Noise(settings.clone()), 2., glam::DVec2::ZERO) else { + return; + }; + assert!(mismatched <= 8, "{mismatched} pixels differ from the CPU's samples with {settings:?}"); + } +} + +#[test] +fn the_mandelbrot_set_renders_on_the_gpu_like_on_the_cpu() { + use graphic_types::generator::GeneratorPattern; + + // Its whole unit square across the pixels compared, where the GPU's 32-bit floats may escape a few points on the set's edge at another iteration + let Some((mismatched, _)) = drawing_differences(&GeneratorPattern::Mandelbrot, 64., glam::DVec2::ZERO) else { + return; + }; + assert!(mismatched <= 40, "{mismatched} pixels differ from the CPU's samples"); + + // Pixels a billionth of a unit across, too fine for 32-bit floats, on the edge of the set's second largest bulb + let Some((_, worst)) = drawing_differences(&GeneratorPattern::Mandelbrot, 2_f64.powi(30), glam::DVec2::new(0.25, 0.5)) else { + return; + }; + assert!(worst <= 2, "deep zooms differ from the CPU's samples by up to {worst} levels"); +} + +#[test] +fn generator_tiles_are_reused_across_a_pan() { + let Some(executor) = executor() else { return }; + let _turn = take_turn(); + let max_texture_size = executor.context().device.limits().max_texture_dimension_2d; + let tiles = |canvas: &crate::VelloCanvas| canvas.images.textures.len(); + + // A pattern drawn two pixels to a unit, in tiles of 256 pixels, panned by part of a tile + let mut canvas = crate::VelloCanvas::default(); + let draw = |offset: f64| { + let mut scene = VelloSceneBuilder::new(512, 512, Affine::IDENTITY, max_texture_size); + scene.draw_generator(&noise_pattern(), Affine::translate((offset, 0.)) * Affine::scale(2.)); + scene + }; + executor.render_vello_scene_on_canvas(&mut canvas, draw(0.)).unwrap(); + let first = tiles(&canvas); + executor.render_vello_scene_on_canvas(&mut canvas, draw(0.)).unwrap(); + assert_eq!(tiles(&canvas), first, "drawing the same view again renders no tiles"); + + executor.render_vello_scene_on_canvas(&mut canvas, draw(-100.)).unwrap(); + let panned = tiles(&canvas) - first; + assert!(panned < first, "panning renders only the tiles newly in view, not all {first} again (rendered {panned})"); +} diff --git a/node-graph/node-macro/Cargo.toml b/node-graph/node-macro/Cargo.toml index e8ec5d3a301..01be4d2781b 100644 --- a/node-graph/node-macro/Cargo.toml +++ b/node-graph/node-macro/Cargo.toml @@ -22,7 +22,6 @@ syn = { workspace = true } proc-macro2 = { workspace = true } quote = { workspace = true } convert_case = { workspace = true } -strum = { workspace = true } indoc = { workspace = true } proc-macro-crate = "3.1.0" diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index ce6fc12f87e..f25cdc349e3 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -1,5 +1,4 @@ use crate::parsing::*; -use crate::shader_nodes::STD_FEATURE_GATE; use convert_case::{Case, Casing}; use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, format_ident, quote, quote_spanned}; @@ -174,15 +173,6 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn value => value.regular().expect("a non-node field is a value field").ty.clone(), }) .collect(); - let shader_field_types: Vec<_> = regular_fields - .iter() - .zip(field_types.iter()) - .map(|(field, field_type)| match field.ty.regular() { - Some(regular) => regular.body_ty().clone(), - None => field_type.clone(), - }) - .collect(); - let has_shader_variant = regular_fields.iter().any(|field| field.ty.regular().is_some_and(|regular| regular.narrowed_body_ty.is_some())); // Only regular fields have UI metadata (data fields are internal state) let widget_override: Vec<_> = regular_fields @@ -871,9 +861,11 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn false => quote!(None), }; - let cfg = crate::shader_nodes::modify_cfg(attributes); + let cfg = match &attributes.cfg { + Some(cfg) => quote!(#[cfg(#cfg)]), + None => quote!(), + }; let node_input_accessor = generate_node_input_references(parsed, &field_idents, core_types, &identifier, &cfg); - let ShaderTokens { shader_entry_point, gpu_node } = attributes.shader_node.as_ref().map(|n| n.codegen(crate_ident, parsed)).unwrap_or(Ok(ShaderTokens::default()))?; let mapped_node_impl = match (&mapped_struct_where_clause, &mapped_eval_impl) { (Some(mapped_where_clause), Some(mapped_eval)) => quote! { @@ -981,32 +973,11 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } } - let node_fn = |field_types: &[Type]| { - quote! { - #(#description_doc_attrs)* - #[inline] - #[allow(clippy::too_many_arguments)] - #vis #async_keyword fn #fn_name <#(#fn_generics,)*> (#input_ident: #input_type #(, #data_field_idents: #data_field_types)* #(, #field_idents: #field_types)*) -> #output_type #where_clause #body - } - }; - // A shader node's fn is emitted twice from the one body: as declared, in f64, for the CPU, and with the f32 parameter - // counterparts for the shader build, which cannot represent a 64-bit float. The body casts to f32 only where it meets a Color channel. - let node_fn = if has_shader_variant { - let cpu_fn = node_fn(&field_types); - let shader_fn = node_fn(&shader_field_types); - quote! { - #[cfg(feature = #STD_FEATURE_GATE)] - #cpu_fn - - #[cfg(not(feature = #STD_FEATURE_GATE))] - #shader_fn - } - } else { - node_fn(&field_types) - }; - Ok(quote! { - #node_fn + #(#description_doc_attrs)* + #[inline] + #[allow(clippy::too_many_arguments)] + #vis #async_keyword fn #fn_name <#(#fn_generics,)*> (#input_ident: #input_type #(, #data_field_idents: #data_field_types)* #(, #field_idents: #field_types)*) -> #output_type #where_clause #body #cfg #[automatically_derived] @@ -1116,10 +1087,6 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn NODE_METADATA.lock().unwrap().insert(#identifier(), metadata); } } - - #shader_entry_point - - #gpu_node }) } @@ -1461,7 +1428,6 @@ fn generate_register_node_impl( } use crate::crate_ident::CrateIdent; -use crate::shader_nodes::{ShaderCodegen, ShaderTokens}; use syn::visit_mut::VisitMut; use syn::{GenericArgument, Lifetime, Type}; diff --git a/node-graph/node-macro/src/crate_ident.rs b/node-graph/node-macro/src/crate_ident.rs index 60000c3bc75..7a32a6e5935 100644 --- a/node-graph/node-macro/src/crate_ident.rs +++ b/node-graph/node-macro/src/crate_ident.rs @@ -6,8 +6,6 @@ pub struct CrateIdent { gcore: syn::Result, gcore_shaders: syn::Result, graphene_core: syn::Result, - raster_types: syn::Result, - wgpu_executor: syn::Result, } impl CrateIdent { @@ -22,14 +20,6 @@ impl CrateIdent { pub fn graphene_core(&self) -> syn::Result<&TokenStream> { self.graphene_core.as_ref().map_err(Clone::clone) } - - pub fn raster_types(&self) -> syn::Result<&TokenStream> { - self.raster_types.as_ref().map_err(Clone::clone) - } - - pub fn wgpu_executor(&self) -> syn::Result<&TokenStream> { - self.wgpu_executor.as_ref().map_err(Clone::clone) - } } impl Default for CrateIdent { @@ -50,14 +40,6 @@ impl Default for CrateIdent { .map(|gstd| quote!(#gstd::graphene_core)) .map_err(|estd| syn::Error::new(Span::call_site(), format!("{ecore}\n\nFallback: {estd}"))) }); - let raster_types = find_crate("raster-types"); - let wgpu_executor = find_crate("wgpu-executor"); - Self { - gcore, - gcore_shaders, - graphene_core, - raster_types, - wgpu_executor, - } + Self { gcore, gcore_shaders, graphene_core } } } diff --git a/node-graph/node-macro/src/lib.rs b/node-graph/node-macro/src/lib.rs index 8af111145e8..28685ba3a3a 100644 --- a/node-graph/node-macro/src/lib.rs +++ b/node-graph/node-macro/src/lib.rs @@ -8,7 +8,6 @@ mod crate_ident; mod derive_choice_type; mod destructure; mod parsing; -mod shader_nodes; mod validation; /// Used to create a node definition. diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index 5199c2f1510..83fb2b26c3e 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -13,7 +13,6 @@ use syn::{ use crate::codegen::generate_node_code; use crate::crate_ident::CrateIdent; -use crate::shader_nodes::ShaderNodeType; #[derive(Clone, Debug)] pub(crate) struct Implementation { @@ -51,8 +50,6 @@ pub(crate) struct NodeFnAttributes { pub(crate) inspector_string: Option, /// whether to `#[cfg]` gate the node implementation, defaults to None pub(crate) cfg: Option, - /// if this node should get a gpu implementation, defaults to None - pub(crate) shader_node: Option, /// Custom serialization function path (e.g., "my_module::custom_serialize") pub(crate) serialize: Option, /// Whether the preprocessor should add a Memoize node after this node in the generated subnetwork @@ -156,8 +153,6 @@ impl ParsedField { number_hard_max: None, number_mode_range: false, implementations: Default::default(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -242,14 +237,6 @@ impl ParsedFieldType { } } - /// The element type `T` of a rank-0 `Item` field, or `None` for any other shape. - pub fn item_element(&self) -> Option<&Type> { - match self { - ParsedFieldType::Item { element, .. } => Some(element), - _ => None, - } - } - /// The element type `T` of a whole-list `List` field, or `None` for any other shape. pub fn list_element(&self) -> Option<&Type> { match self { @@ -373,17 +360,6 @@ pub struct RegularParsedField { /// Whether the number input renders as a draggable slider (the `#[range]` attribute) rather than the default increment field. pub number_mode_range: bool, pub implementations: Punctuated, - pub gpu_image: bool, - /// Set for a shader node's `f64` or `i64` parameter: `ty` keeps the type it was declared with, which the graph and the - /// CPU fn use, while the shader build's fn variant and the GPU uniform take this `f32` or `i32` counterpart. - pub narrowed_body_ty: Option, -} - -impl RegularParsedField { - /// The type the shader build's fn variant is handed: the 32-bit counterpart of a shader node's declared `f64` or `i64`, otherwise `ty` itself. - pub fn body_ty(&self) -> &Type { - self.narrowed_body_ty.as_ref().unwrap_or(&self.ty) - } } /// a param of `impl Node` with `#[implementation(in -> out)]` @@ -453,7 +429,6 @@ impl Parse for NodeFnAttributes { let mut skip_impl = false; let mut inspector_string = None; let mut cfg = None; - let mut shader_node = None; let mut serialize = None; let mut memoize = false; let mut footprint_invariant = false; @@ -565,17 +540,6 @@ impl Parse for NodeFnAttributes { let meta = meta.require_list()?; cfg = Some(meta.tokens.clone()); } - // Reference to a specific shader definition struct that is used to run the logic of this node on the GPU. - // - // Example usage: - // #[node_macro::node(..., shader_node(PerPixelAdjust), ...)] - "shader_node" => { - if shader_node.is_some() { - return Err(Error::new_spanned(path, "Multiple 'shader_node' attributes are not allowed")); - } - let meta = meta.require_list()?; - shader_node = Some(syn::parse2(meta.tokens.to_token_stream())?); - } // Function name for custom serialization of this node's data. This is only used by the Monitor node. // // Example usage: @@ -655,7 +619,7 @@ impl Parse for NodeFnAttributes { indoc!( r#" Unsupported attribute in `node`. - Supported attributes are 'category', 'name', 'icon', 'path', 'skip_impl', 'inspector', 'cfg', 'shader_node', 'serialize', 'memoize', 'footprint_invariant', 'inject_scope', 'placeholder', and 'no_partial'. + Supported attributes are 'category', 'name', 'icon', 'path', 'skip_impl', 'inspector', 'cfg', 'serialize', 'memoize', 'footprint_invariant', 'inject_scope', 'placeholder', and 'no_partial'. Example usage: #[node_macro::node(..., name("Test Node"), ...)] "# @@ -686,7 +650,6 @@ impl Parse for NodeFnAttributes { skip_impl, inspector_string, cfg, - shader_node, serialize, memoize, footprint_invariant, @@ -709,10 +672,7 @@ pub(crate) fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Resu let fn_generics = input_fn.sig.generics.params.into_iter().collect(); let is_async = input_fn.sig.asyncness.is_some(); - let (input, mut fields) = parse_inputs(&input_fn.sig.inputs)?; - if matches!(attributes.shader_node, Some(ShaderNodeType::PerPixelAdjust(_))) { - narrow_shader_fields(&mut fields)?; - } + let (input, fields) = parse_inputs(&input_fn.sig.inputs)?; let output_type = parse_output(&input_fn.sig.output)?; let output_element = peel_item(&output_payload(&output_type)); let where_clause = input_fn.sig.generics.where_clause; @@ -994,7 +954,6 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul .map_err(|e| Error::new_spanned(attr, format!("Invalid `step` for argument '{ident}': {e}\nUSAGE EXAMPLE: #[step(2.)]"))) }) .transpose()?; - let gpu_image = extract_attribute(attrs, "gpu_image").is_some(); let (is_node, node_input_type, node_output_type) = parse_node_type(&ty); let description = attrs @@ -1093,8 +1052,6 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul ty, value_source, implementations, - gpu_image, - narrowed_body_ty: None, }), name, description, @@ -1131,69 +1088,6 @@ fn parse_node_type(ty: &Type) -> (bool, Option, Option) { (false, None, None) } -/// A `PerPixelAdjust` shader node is declared with the catalog's `f64` and `i64` parameters, and its fn is emitted twice -/// from the one body: as declared for the CPU, and with the `f32` and `i32` counterparts recorded here for the shader -/// build, which cannot represent a 64-bit number. Declaring `f32` or `i32` directly is refused so the node's definition -/// only ever speaks the graph's types. -fn narrow_shader_fields(fields: &mut [ParsedField]) -> syn::Result<()> { - for field in fields.iter_mut() { - let Some(regular) = field.ty.regular() else { continue }; - let declared = regular.ty.clone(); - - if peel_list(&declared).is_some() { - return Err(Error::new_spanned( - &declared, - "A per-pixel shader node cannot take a `List` parameter, since its values ride in a uniform buffer and the shader cannot allocate; declare an `Item` instead", - )); - } - - let element = peel_item(&declared).unwrap_or_else(|| declared.clone()); - if is_primitive_type(&element, "f32") || is_primitive_type(&element, "i32") { - return Err(Error::new_spanned( - &declared, - "A shader node's parameters are declared as `f64` or `i64`, the graph's Number and Integer types; the macro narrows them to the `f32` or `i32` the shader computes with", - )); - } - - let counterpart: Type = if is_primitive_type(&element, "f64") { - parse_quote!(f32) - } else if is_primitive_type(&element, "i64") { - parse_quote!(i32) - } else { - continue; - }; - let body_ty = if peel_item(&declared).is_some() { - replace_wrapper_element(&declared, counterpart) - } else { - counterpart - }; - - let mut regular = regular.clone(); - regular.narrowed_body_ty = Some(body_ty); - field.ty = ParsedFieldType::classify(regular); - } - - Ok(()) -} - -/// Swaps the element type inside a wrapper like `Item`, keeping the wrapper's path as it was written. -fn replace_wrapper_element(ty: &Type, element: Type) -> Type { - let mut ty = ty.clone(); - if let Type::Path(type_path) = &mut ty - && let Some(segment) = type_path.path.segments.last_mut() - && let syn::PathArguments::AngleBracketed(arguments) = &mut segment.arguments - && let Some(syn::GenericArgument::Type(inner)) = arguments.args.first_mut() - { - *inner = element; - } - ty -} - -fn is_primitive_type(ty: &Type, name: &str) -> bool { - let Type::Path(type_path) = ty else { return false }; - type_path.path.segments.last().is_some_and(|segment| segment.ident == name) -} - fn is_integer_type(ty: &Type) -> bool { let Type::Path(type_path) = ty else { return false }; let Some(segment) = type_path.path.segments.last() else { return false }; @@ -1430,7 +1324,6 @@ mod tests { skip_impl: true, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1468,8 +1361,6 @@ mod tests { number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1509,7 +1400,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1566,8 +1456,6 @@ mod tests { number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1605,7 +1493,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1643,8 +1530,6 @@ mod tests { number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1680,7 +1565,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1723,8 +1607,6 @@ mod tests { p.push(parse_quote!(f64)); p }, - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1767,7 +1649,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1805,8 +1686,6 @@ mod tests { number_hard_max: Some(parse_quote!(500)), number_mode_range: true, implementations: Punctuated::new(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1857,7 +1736,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -1895,8 +1773,6 @@ mod tests { number_hard_max: None, number_mode_range: false, implementations: Punctuated::new(), - gpu_image: false, - narrowed_body_ty: None, }), number_display_decimal_places: None, number_step: None, @@ -1932,7 +1808,6 @@ mod tests { skip_impl: false, inspector_string: None, cfg: None, - shader_node: None, serialize: None, memoize: false, footprint_invariant: false, @@ -2067,56 +1942,6 @@ mod tests { assert_eq!(error.span().start(), problem_span.start()); } - #[test] - fn test_shader_node_parameters_narrow_for_the_body() { - let attr = quote!(category("Raster: Adjustment"), shader_node(PerPixelAdjust)); - let input = quote!( - fn adjust>(_: impl Ctx, #[gpu_image] image: Item, amount: Item, scale: f64, steps: Item) -> Item { - image - } - ); - let parsed = parse_node_fn(attr.clone(), input).unwrap(); - - let types = |index: usize| { - let regular = parsed.fields[index].ty.regular().unwrap(); - let body = regular.narrowed_body_ty.as_ref().map(|ty| ty.to_token_stream().to_string()); - (regular.ty.to_token_stream().to_string(), body) - }; - assert_eq!(types(0), ("Item < T >".to_string(), None), "the image parameter is left alone"); - assert_eq!( - types(1), - ("Item < f64 >".to_string(), Some("Item < f32 >".to_string())), - "an Item stays on the wire and reaches the body as Item" - ); - assert_eq!(types(2), ("f64".to_string(), Some("f32".to_string())), "a bare f64 reaches the body as f32"); - assert_eq!( - types(3), - ("Item < i64 >".to_string(), Some("Item < i32 >".to_string())), - "an Item stays on the wire and reaches the body as Item" - ); - - let declared_f32 = quote!( - fn adjust>(_: impl Ctx, #[gpu_image] image: Item, amount: Item) -> Item { - image - } - ); - assert!(parse_node_fn(attr.clone(), declared_f32).is_err(), "a shader node must not declare f32 itself"); - - let declared_i32 = quote!( - fn adjust>(_: impl Ctx, #[gpu_image] image: Item, steps: Item) -> Item { - image - } - ); - assert!(parse_node_fn(attr.clone(), declared_i32).is_err(), "a shader node must not declare i32 itself"); - - let declared_list = quote!( - fn adjust>(_: impl Ctx, #[gpu_image] image: Item, amounts: List) -> Item { - image - } - ); - assert!(parse_node_fn(attr, declared_list).is_err(), "a uniform cannot carry a List"); - } - #[test] fn a_kernel_is_classified_by_its_declared_return_type() { let payload: Type = parse_quote!(Item); diff --git a/node-graph/node-macro/src/shader_nodes/mod.rs b/node-graph/node-macro/src/shader_nodes/mod.rs deleted file mode 100644 index 37733762cc5..00000000000 --- a/node-graph/node-macro/src/shader_nodes/mod.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::crate_ident::CrateIdent; -use crate::parsing::{NodeFnAttributes, ParsedNodeFn}; -use crate::shader_nodes::per_pixel_adjust::PerPixelAdjust; -use proc_macro2::{Ident, TokenStream}; -use quote::quote; -use strum::VariantNames; -use syn::parse::{Parse, ParseStream}; -use syn::punctuated::Punctuated; -use syn::{Error, Token}; - -pub mod per_pixel_adjust; - -pub const STD_FEATURE_GATE: &str = "std"; -pub const SHADER_NODES_FEATURE_GATE: &str = "shader-nodes"; - -pub fn modify_cfg(attributes: &NodeFnAttributes) -> TokenStream { - let feature_gate = match &attributes.shader_node { - // shader node cfg is done on the mod - Some(ShaderNodeType::ShaderNode) => quote!(), - Some(_) => quote!(feature = #STD_FEATURE_GATE), - None => quote!(), - }; - let cfgs: Punctuated<_, Token![,]> = match &attributes.cfg { - None => [&feature_gate].into_iter().collect(), - Some(cfg) => [cfg, &feature_gate].into_iter().collect(), - }; - quote!(#[cfg(all(#cfgs))]) -} - -#[derive(Debug, Clone, VariantNames)] -pub(crate) enum ShaderNodeType { - /// Marker for this node being in a gpu node crate, but not having a gpu implementation. This is distinct from not - /// declaring `shader_node` at all, as it will wrap the CPU node with a `#[cfg(feature = "std")]` feature gate. - None, - /// Marker for this node being a generated gpu node implementation, that should not emit anything to prevent - /// recursively generating more gpu nodes. But it still counts as a gpu node and will get the - /// `#[cfg(feature = "std")]` feature gate around it's impl. - ShaderNode, - /// A per-pixel color adjustment compiled to a fragment shader. Its parameters are declared `f64` and `i64` like the rest - /// of the catalog, and the fn is emitted a second time with their `f32` and `i32` counterparts for the shader build. - PerPixelAdjust(PerPixelAdjust), -} - -impl Parse for ShaderNodeType { - fn parse(input: ParseStream) -> syn::Result { - let ident: Ident = input.parse()?; - Ok(match ident.to_string().as_str() { - "None" => ShaderNodeType::None, - "PerPixelAdjust" => ShaderNodeType::PerPixelAdjust(PerPixelAdjust::parse(input)?), - _ => return Err(Error::new_spanned(&ident, format!("attr 'shader_node' must be one of {:?}", Self::VARIANTS))), - }) - } -} - -pub trait ShaderCodegen { - fn codegen(&self, crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result; -} - -impl ShaderCodegen for ShaderNodeType { - fn codegen(&self, crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result { - match self { - ShaderNodeType::None | ShaderNodeType::ShaderNode => (), - _ => { - if parsed.is_async { - return Err(Error::new_spanned(&parsed.fn_name, "Shader nodes must not be async")); - } - } - } - - match self { - ShaderNodeType::None | ShaderNodeType::ShaderNode => Ok(ShaderTokens::default()), - ShaderNodeType::PerPixelAdjust(x) => x.codegen(crate_ident, parsed), - } - } -} - -#[derive(Clone, Default)] -pub struct ShaderTokens { - pub shader_entry_point: TokenStream, - pub gpu_node: TokenStream, -} diff --git a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs deleted file mode 100644 index 1d3372ad030..00000000000 --- a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::crate_ident::CrateIdent; -use crate::parsing::{Input, NodeFnAttributes, ParsedField, ParsedFieldType, ParsedNodeFn, ParsedValueSource, RegularParsedField, peel_item}; -use crate::shader_nodes::{SHADER_NODES_FEATURE_GATE, ShaderCodegen, ShaderNodeType, ShaderTokens}; -use convert_case::{Case, Casing}; -use proc_macro2::{Ident, Span, TokenStream}; -use quote::{ToTokens, format_ident, quote}; -use std::borrow::Cow; -use syn::parse::{Parse, ParseStream}; -use syn::punctuated::Punctuated; -use syn::{LitStr, PatIdent, Type, parse_quote}; - -#[derive(Debug, Clone)] -pub struct PerPixelAdjust {} - -impl Parse for PerPixelAdjust { - fn parse(_input: ParseStream) -> syn::Result { - Ok(Self {}) - } -} - -impl ShaderCodegen for PerPixelAdjust { - fn codegen(&self, crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result { - let fn_name = &parsed.fn_name; - - let mut params; - let has_uniform; - { - // categorize params - params = parsed - .fields - .iter() - .map(|f| { - let ident = &f.pat_ident; - if let ParsedFieldType::Node { .. } = &f.ty { - return Err(syn::Error::new_spanned(ident, "PerPixelAdjust shader nodes cannot accept other nodes as generics")); - } - let regular = f.ty.regular().expect("a non-node shader field is a value field"); - // Ranked connectors carry their bare element type in the uniform buffer and get rewrapped in the entry point - let element_ty = f.ty.item_element(); - if regular.gpu_image { - Ok(Param { - ident: Cow::Owned(format_ident!("image_{}", &ident.ident)), - ty: quote!(Image2d), - item_wrapped: element_ty.is_some(), - narrowed: false, - param_type: ParamType::Image { binding: 0 }, - }) - } else { - // The uniform holds the 32-bit types the shader computes with, so a parameter declared f64 or i64 takes its counterpart here - let body_ty = regular.body_ty(); - Ok(Param { - ident: Cow::Borrowed(&ident.ident), - item_wrapped: element_ty.is_some(), - narrowed: regular.narrowed_body_ty.is_some(), - ty: peel_item(body_ty).unwrap_or_else(|| body_ty.clone()).to_token_stream(), - param_type: ParamType::Uniform, - }) - } - }) - .collect::>>()?; - - has_uniform = params.iter().any(|p| matches!(p.param_type, ParamType::Uniform)); - - // assign image bindings - // if an arg_buffer exists, bindings for images start at 1 to leave 0 for arg buffer - let mut binding_cnt = if has_uniform { 1 } else { 0 }; - for p in params.iter_mut() { - match &mut p.param_type { - ParamType::Image { binding } => { - *binding = binding_cnt; - binding_cnt += 1; - } - ParamType::Uniform => {} - } - } - } - - let entry_point_mod = format_ident!("{}_gpu", fn_name); - let entry_point_name_ident = format_ident!("ENTRY_POINT_NAME"); - let entry_point_name = quote!(#entry_point_mod::#entry_point_name_ident); - let uniform_struct_ident = format_ident!("Uniform"); - let uniform_struct = quote!(#entry_point_mod::#uniform_struct_ident); - let shader_node_mod = format_ident!("{}_shader_node", fn_name); - - let codegen = PerPixelAdjustCodegen { - crate_ident, - parsed, - params, - has_uniform, - entry_point_mod, - entry_point_name_ident, - entry_point_name, - uniform_struct_ident, - uniform_struct, - shader_node_mod, - }; - - Ok(ShaderTokens { - shader_entry_point: codegen.codegen_shader_entry_point()?, - gpu_node: codegen.codegen_gpu_node()?, - }) - } -} - -pub struct PerPixelAdjustCodegen<'a> { - crate_ident: &'a CrateIdent, - parsed: &'a ParsedNodeFn, - params: Vec>, - has_uniform: bool, - entry_point_mod: Ident, - entry_point_name_ident: Ident, - entry_point_name: TokenStream, - uniform_struct_ident: Ident, - uniform_struct: TokenStream, - shader_node_mod: Ident, -} - -impl PerPixelAdjustCodegen<'_> { - fn codegen_shader_entry_point(&self) -> syn::Result { - let fn_name = &self.parsed.fn_name; - let gcore_shaders = self.crate_ident.gcore_shaders()?; - let reexport = quote!(#gcore_shaders::shaders::__private); - - let uniform_members = self - .params - .iter() - .filter_map(|Param { ident, ty, param_type, .. }| match param_type { - ParamType::Image { .. } => None, - ParamType::Uniform => Some(quote! {#ident: #ty}), - }) - .collect::>(); - let uniform_struct_ident = &self.uniform_struct_ident; - let uniform_struct = parse_quote! { - #[repr(C)] - #[derive(Copy, Clone)] - pub struct #uniform_struct_ident { - #(pub #uniform_members),* - } - }; - let uniform_struct_shader_struct_derive = crate::buffer_struct::derive_buffer_struct_struct(self.crate_ident, &uniform_struct)?; - - let image_params = self - .params - .iter() - .filter_map(|Param { ident, ty, param_type, .. }| match param_type { - ParamType::Image { binding } => Some(quote! {#[spirv(descriptor_set = 0, binding = #binding)] #ident: &#ty}), - ParamType::Uniform => None, - }) - .collect::>(); - // Ranked connectors compile against the no_std `Item` stand-in on the GPU, so wrapping and unwrapping here is free. - // A narrowed uniform casts to whichever width the fn variant being compiled takes: f32 for the shader, f64 on the CPU. - let call_args = self - .params - .iter() - .map(|param| { - let Param { - ident, - param_type, - item_wrapped, - narrowed, - .. - } = param; - let bare_value = match param_type { - ParamType::Image { .. } => quote!(Color::from_vec4(#ident.fetch_with(texel_coord, lod(0)))), - ParamType::Uniform if *narrowed => quote!(uniform.#ident as _), - ParamType::Uniform => quote!(uniform.#ident), - }; - if *item_wrapped { quote!(Item::new_from_element(#bare_value)) } else { bare_value } - }) - .collect::>(); - let unwrap_result = self.parsed.output_element.as_ref().map(|_| quote!(.into_element())); - let context = quote!(()); - - let entry_point_mod = &self.entry_point_mod; - let entry_point_name = &self.entry_point_name_ident; - Ok(quote! { - pub mod #entry_point_mod { - use super::*; - use #gcore_shaders::color::Color; - use #reexport::glam::{Vec4, Vec4Swizzles}; - use #reexport::spirv_std::spirv; - use #reexport::spirv_std::image::{Image2d, ImageWithMethods}; - use #reexport::spirv_std::image::sample_with::lod; - - pub const #entry_point_name: &str = core::concat!(core::module_path!(), "::entry_point"); - - #uniform_struct - #uniform_struct_shader_struct_derive - - #[spirv(fragment)] - pub fn entry_point( - #[spirv(frag_coord)] frag_coord: Vec4, - color_out: &mut Vec4, - #[spirv(descriptor_set = 0, binding = 0, storage_buffer)] uniform: &UniformBuffer, - #(#image_params),* - ) { - let uniform = ::read(*uniform); - let texel_coord = frag_coord.xy().as_uvec2(); - let color: Color = #fn_name(#context, #(#call_args),*)#unwrap_result; - *color_out = color.to_vec4(); - } - } - }) - } - - fn codegen_gpu_node(&self) -> syn::Result { - let gcore = self.crate_ident.gcore()?; - let raster_types = self.crate_ident.raster_types()?; - let wgpu_executor = self.crate_ident.wgpu_executor()?; - - // adapt fields for gpu node - let raster_gpu: Type = parse_quote!(#gcore::list::List<#raster_types::Raster<#raster_types::GPU>>); - let mut fields = self - .parsed - .fields - .iter() - .map(|f| { - if let ParsedFieldType::Node { .. } = &f.ty { - return Err(syn::Error::new_spanned(&f.pat_ident, "PerPixelAdjust shader nodes cannot accept other nodes as generics")); - } - let regular = f.ty.regular().expect("a non-node shader field is a value field"); - let pat_ident = PatIdent { - mutability: None, - by_ref: None, - ..f.pat_ident.clone() - }; - // A gpu_image parameter is fed the whole GPU raster list, so its wire is re-typed and re-classified - let ty = if regular.gpu_image { - ParsedFieldType::classify(RegularParsedField { - ty: raster_gpu.clone(), - implementations: Punctuated::default(), - ..regular.clone() - }) - } else { - ParsedFieldType::classify(RegularParsedField { - narrowed_body_ty: None, - ..regular.clone() - }) - }; - Ok(ParsedField { pat_ident, ty, ..f.clone() }) - }) - .collect::>>()?; - - // insert wgpu_executor field - let executor = format_ident!("__wgpu_executor"); - fields.push(ParsedField { - pat_ident: PatIdent { - attrs: vec![], - by_ref: None, - mutability: None, - ident: parse_quote!(#executor), - subpat: None, - }, - name: None, - description: "".to_string(), - widget_override: Default::default(), - ty: ParsedFieldType::classify(RegularParsedField { - ty: parse_quote!(#gcore::list::Item), - exposed: true, - value_source: ParsedValueSource::Scope(Box::new(parse_quote!("graphene_std::platform_application_io::WgpuExecutorNode"))), - number_soft_min: None, - number_soft_max: None, - number_hard_min: None, - number_hard_max: None, - number_mode_range: false, - implementations: Default::default(), - gpu_image: false, - narrowed_body_ty: None, - }), - number_display_decimal_places: None, - number_step: None, - unit: None, - multiline: false, - progression: false, - is_data_field: false, - }); - - // find exactly one gpu_image field, runtime doesn't support more than 1 atm - let gpu_image_field = { - let mut iter = fields.iter().filter(|f| f.ty.regular().is_some_and(|regular| regular.gpu_image)); - match (iter.next(), iter.next()) { - (Some(v), None) => Ok(v), - (Some(_), Some(more)) => Err(syn::Error::new_spanned(&more.pat_ident, "No more than one parameter must be annotated with `#[gpu_image]`")), - (None, _) => Err(syn::Error::new_spanned(&self.parsed.fn_name, "At least one parameter must be annotated with `#[gpu_image]`")), - }? - }; - let gpu_image = &gpu_image_field.pat_ident.ident; - - // uniform buffer struct construction - let has_uniform = self.has_uniform; - let uniform_buffer = if has_uniform { - let uniform_struct = &self.uniform_struct; - let uniform_members = self - .params - .iter() - .filter_map(|p| match p.param_type { - ParamType::Image { .. } => None, - ParamType::Uniform => { - let ident = p.ident.as_ref(); - let value = if p.item_wrapped { quote!(#ident.into_element()) } else { quote!(#ident) }; - // A parameter declared f64 or i64 narrows to the shader's 32-bit counterpart as it enters the uniform; an - // integer saturates rather than wrapping, matching how the float overflows to infinity instead of flipping - let ty = &p.ty; - Some(match (p.narrowed, ty.to_string().as_str()) { - (true, "i32") => quote!(#ident: (#value).clamp(i32::MIN as _, i32::MAX as _) as i32), - (true, _) => quote!(#ident: #value as #ty), - (false, _) => quote!(#ident: #value), - }) - } - }) - .collect::>(); - quote!(Some(&super::#uniform_struct { - #(#uniform_members),* - })) - } else { - // explicit generics placed here cause it's easier than explicitly writing `run_per_pixel_adjust::<()>` - quote!(Option::<&()>::None) - }; - - // node function body - let entry_point_name = &self.entry_point_name; - let body = quote! { - { - #executor.into_element().run_per_pixel_adjust(&::wgpu_executor::shader_runtime::per_pixel_adjust_runtime::Shaders { - wgsl_shader: crate::WGSL_SHADER, - fragment_shader_name: super::#entry_point_name, - has_uniform: #has_uniform, - }, #gpu_image, #uniform_buffer) - } - }; - - // call node codegen - let display_name = self.parsed.attributes.display_name.clone(); - let display_name = display_name.unwrap_or_else(|| LitStr::new(&self.shader_node_mod.to_string().strip_suffix("_shader_node").unwrap().to_case(Case::Title), Span::call_site())); - let display_name = LitStr::new(&format!("{} GPU", display_name.value()), display_name.span()); - let mut parsed_node_fn = ParsedNodeFn { - vis: self.parsed.vis.clone(), - attributes: NodeFnAttributes { - display_name: Some(display_name), - shader_node: Some(ShaderNodeType::ShaderNode), - ..self.parsed.attributes.clone() - }, - fn_name: self.shader_node_mod.clone(), - struct_name: format_ident!("{}", self.shader_node_mod.to_string().to_case(Case::Pascal)), - mod_name: self.shader_node_mod.clone(), - fn_generics: Vec::new(), - where_clause: None, - input: Input { - pat_ident: self.parsed.input.pat_ident.clone(), - ty: parse_quote!(impl #gcore::context::Ctx), - implementations: Default::default(), - context_features: self.parsed.input.context_features.clone(), - }, - output_type: raster_gpu, - output_element: None, - is_async: false, - fields, - body, - description: self.parsed.description.clone(), - }; - parsed_node_fn.replace_impl_trait_in_input(); - let gpu_node_impl = crate::codegen::generate_node_code(self.crate_ident, &parsed_node_fn)?; - - // wrap node in `mod #gpu_node_mod` - let shader_node_mod = &self.shader_node_mod; - Ok(quote! { - #[cfg(feature = #SHADER_NODES_FEATURE_GATE)] - mod #shader_node_mod { - use super::*; - use #wgpu_executor::WgpuExecutor; - - #gpu_node_impl - } - }) - } -} - -struct Param<'a> { - ident: Cow<'a, Ident>, - ty: TokenStream, - item_wrapped: bool, - /// Whether the parameter is declared `f64` or `i64` and narrows to the shader's 32-bit counterpart as it enters the uniform. - narrowed: bool, - param_type: ParamType, -} - -enum ParamType { - Image { binding: u32 }, - Uniform, -} diff --git a/node-graph/nodes/blending/src/effects.rs b/node-graph/nodes/blending/src/effects.rs new file mode 100644 index 00000000000..174498682b5 --- /dev/null +++ b/node-graph/nodes/blending/src/effects.rs @@ -0,0 +1,391 @@ +use core_types::blending::BlendMode; +use core_types::list::Item; +use core_types::{Color, Ctx}; +use glam::DVec2; +use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::{BevelDirection, BevelStyle, Effect, EffectOperation, Generator, GlowSource, Graphic, Vector, evaluate_color_effects, stamp_effect}; +use vector_types::Gradient; +use vector_types::vector::style::StrokeAlign; + +/// Blurs the input graphics, whether vector shapes, images, or text. +#[node_macro::node(category("Raster: Filter"), icon("NodeBlur"))] +fn blur( + _: impl Ctx, + /// The content to blur. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// How far the blur spreads each point of the content. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + radius: Item, + /// Use a box kernel instead of a circular Gaussian kernel, which averages evenly and leaves boxy artifacts. + box_blur: Item, + /// Opt to incorrectly blend the colors in gamma space for compatibility with the results from other software. + gamma: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::Blur { + radius: *radius.element(), + box_blur: *box_blur.element(), + gamma: *gamma.element(), + }), + ); + content +} + +/// Casts a soft shadow of the input graphics' silhouette beneath them. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn drop_shadow( + _: impl Ctx, + /// The content casting the shadow. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The shadow's color, with its opacity. + #[default("#00000080")] + color: Item, + /// How far the shadow sits from the content. + #[unit(" px")] + #[default(4., 4.)] + offset: Item, + /// How soft the shadow's edges are. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(4.)] + blur: Item, + /// How far the shadow grows past the content's silhouette before it softens. + #[unit(" px")] + #[hard(0..)] + spread: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::DropShadow { + color: evaluate_color_effects(color).into_element(), + offset: *offset.element(), + blur: *blur.element(), + spread: *spread.element(), + }), + ); + content +} + +/// Casts a soft shadow inward from the edges of the input graphics, as if they were cut out of the surface. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn inner_shadow( + _: impl Ctx, + /// The content the shadow falls inside. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The shadow's color, with its opacity. + #[default("#00000080")] + color: Item, + /// How far the shadow sits from the edges it falls from. + #[unit(" px")] + #[default(4., 4.)] + offset: Item, + /// How soft the shadow's edges are. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(4.)] + blur: Item, + /// How far the shadow reaches in from the content's edges before it softens. + #[unit(" px")] + #[hard(0..)] + spread: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::InnerShadow { + color: evaluate_color_effects(color).into_element(), + offset: *offset.element(), + blur: *blur.element(), + spread: *spread.element(), + }), + ); + content +} + +/// Surrounds the input graphics with a soft glow of their silhouette. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn outer_glow( + _: impl Ctx, + /// The content to glow around. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The glow's color, with its opacity. + #[default("#ffffbebf")] + color: Item, + /// How soft the glow's edges are. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(8.)] + blur: Item, + /// How far the glow grows past the content's silhouette before it softens. + #[unit(" px")] + #[hard(0..)] + spread: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::OuterGlow { + color: evaluate_color_effects(color).into_element(), + blur: *blur.element(), + spread: *spread.element(), + }), + ); + content +} + +/// Glows inward from the edges of the input graphics, or outward from their center. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn inner_glow( + _: impl Ctx, + /// The content to glow within. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The glow's color, with its opacity. + #[default("#ffffbebf")] + color: Item, + /// How soft the glow's edges are. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(8.)] + blur: Item, + /// How far the glow grows into the content before it softens. + #[unit(" px")] + #[hard(0..)] + choke: Item, + /// Whether the glow starts from the edges or the center. + source: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::InnerGlow { + color: evaluate_color_effects(color).into_element(), + blur: *blur.element(), + choke: *choke.element(), + source: *source.element(), + }), + ); + content +} + +/// Shades the inside of the input graphics with soft, glossy bands that follow their shape. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn satin( + _: impl Ctx, + /// The content to shade. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The shading's color, with its opacity. + #[default("#00000080")] + color: Item, + /// How far apart the bands are drawn, and in which direction. + #[unit(" px")] + #[default(10., 4.)] + offset: Item, + /// How soft the bands are. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(14.)] + blur: Item, + /// Shade where the bands overlap rather than where they don't. + #[default(true)] + invert: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::Satin { + color: evaluate_color_effects(color).into_element(), + offset: *offset.element(), + blur: *blur.element(), + invert: *invert.element(), + }), + ); + content +} + +/// Draws a band of color along the edge of the input graphics, outside, inside, or centered on it. +#[node_macro::node(category("Raster: Effect"), icon("NodeBlur"))] +fn outline( + _: impl Ctx, + /// The content to outline. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The outline's color, with its opacity. + #[default(Color::BLACK)] + color: Item, + /// How thick the outline is. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..100)] + #[default(3.)] + weight: Item, + /// Whether the outline is drawn outside the edge, inside it, or centered on it. + #[default(StrokeAlign::Outside)] + align: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::Outline { + color: evaluate_color_effects(color).into_element(), + weight: *weight.element(), + align: *align.element(), + }), + ); + content +} + +/// Raises the input graphics into a lit relief, with highlights on the slopes facing the light and shadows on those facing away. +#[node_macro::node(name("Bevel & Emboss"), category("Raster: Effect"), icon("NodeBlur"))] +fn bevel_and_emboss( + _: impl Ctx, + /// The content to raise. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// Where the slope lies: inside the edge, outside it, across it, or across it with the edge pressed in. + style: Item, + /// How steep the slope is. + #[unit("%")] + #[range] + #[hard(0..)] + #[soft(..1000)] + #[default(100.)] + depth: Item, + /// Whether the slope rises out of the surface or sinks into it. + direction: Item, + /// How wide the slope is. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..250)] + #[default(5.)] + size: Item, + /// How much the shading is blurred. + #[unit(" px")] + #[range] + #[hard(0..)] + #[soft(..16)] + soften: Item, + /// The direction the light comes from. + #[unit("°")] + #[range] + #[soft(-180..180)] + #[default(120.)] + angle: Item, + /// How high above the surface the light is, from the horizon to straight overhead. + #[unit("°")] + #[range] + #[hard(0..90)] + #[default(30.)] + altitude: Item, + /// The highlight's color, with its opacity. + #[default("#ffffffbf")] + highlight_color: Item, + /// The shadow's color, with its opacity. + #[default("#000000bf")] + shadow_color: Item, +) -> Item { + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::BevelAndEmboss { + style: *style.element(), + depth: *depth.element(), + direction: *direction.element(), + size: *size.element(), + soften: *soften.element(), + angle: *angle.element(), + altitude: *altitude.element(), + highlight_color: evaluate_color_effects(highlight_color).into_element(), + shadow_color: evaluate_color_effects(shadow_color).into_element(), + }), + ); + content +} + +/// Paints over the input graphics within their shape, mixing the paint with their colors by a blend mode. +#[node_macro::node(category("Raster: Effect"), icon("NodeColorOverlay"))] +fn paint_overlay( + _: impl Ctx, + /// The content to paint over. + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] + content: Item, + /// The color, gradient, image, or other graphic to paint with. A gradient or image without its own placement stretches across the content. + #[default(Color::BLACK)] + paint: Item, + /// How the paint mixes with the content's colors. + blend_mode: Item, + /// How strongly the paint shows. + #[unit("%")] + #[range] + #[hard(0..100)] + #[default(100.)] + opacity: Item, +) -> Item { + // The paint is the element alone, since the wire envelope's attributes would nest it as a group + let paint = match paint.into_element() { + Graphic::None(_) | Graphic::NoneList(_) => return content, + Graphic::Color(color) => Graphic::from(evaluate_color_effects(color).into_element()), + paint => paint, + }; + + let mut content = content; + stamp_effect( + &mut content, + Effect::new(&EffectOperation::PaintOverlay { + paint, + blend_mode: blend_mode.into_element(), + opacity: opacity.into_element(), + }), + ); + content +} + +#[cfg(test)] +mod test { + use core_types::Color; + use core_types::blending::BlendMode; + use core_types::list::Item; + use graphic_types::Graphic; + use graphic_types::raster_types::{Image, Raster}; + + #[test] + fn paint_overlay_multiplies_a_color_into_the_content() { + let image_color = Color::from_rgbaf32_unchecked(0.7, 0.6, 0.5, 0.4); + let image = Image::new(1, 1, image_color); + + let result = super::paint_overlay( + (), + Item::new_from_element(Raster::new_cpu(image)), + Item::new_from_element(Graphic::from(Color::GREEN)), + BlendMode::Multiply.into(), + 100_f64.into(), + ); + let result = graphic_types::evaluate_raster_effects(result).into_element(); + + // Multiplying by green keeps only the green channel, and the content keeps its own alpha + assert_eq!(result.data[0], Color::from_rgbaf32_unchecked(0., image_color.g(), 0., image_color.a())); + } +} diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index 406c99d1021..d2fa57fca2f 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -1,8 +1,10 @@ +pub mod effects; + use core_types::list::Item; use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_OPACITY, ATTR_OPACITY_FILL, BlendMode, Color, Ctx}; -use graphic_types::Graphic; use graphic_types::Vector; use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::{Generator, Graphic}; use vector_types::Gradient; /// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. @@ -10,7 +12,7 @@ use vector_types::Gradient; fn blend_mode( _: impl Ctx, /// The content that will be composited when rendering. - #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String)] + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] content: Item, /// The choice of equation that controls how brightness and color blends between overlapping pixels. blend_mode: Item, @@ -29,7 +31,7 @@ fn blend_mode( fn opacity( _: impl Ctx, /// The content that will be composited when rendering. - #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String)] + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] content: Item, /// Whether the *Opacity* property is enabled, multiplying the existing opacity by the chosen percentage. #[widget(ParsedWidgetOverride::Hidden)] @@ -76,7 +78,7 @@ fn opacity( fn clipping_mask( _: impl Ctx, /// The content that will be composited when rendering. - #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String)] + #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Generator)] content: Item, /// Whether the content inherits the alpha of the content beneath it. clip: Item, diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 7891549ff81..6773c71cade 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -3,6 +3,7 @@ use core_types::transform::Footprint; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, Interrupt, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphene_animation::AnimationCurve; +use graphic_types::Generator; use graphic_types::vector_types::Gradient; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -89,6 +90,7 @@ fn step_real_time( Context -> Item>, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> List, Context -> List, @@ -99,6 +101,7 @@ fn step_real_time( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] value: impl Node, Output = T>, @@ -135,6 +138,7 @@ fn step_animation_time( Context -> Item>, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> List, Context -> List, @@ -145,6 +149,7 @@ fn step_animation_time( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] value: impl Node, Output = T>, diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index d98e54646d5..0d4a32fd511 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -5,6 +5,7 @@ use core_types::transform::Footprint; use core_types::{Color, Interrupt, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphene_animation::AnimationCurve; +use graphic_types::Generator; use graphic_types::vector_types::Gradient; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -30,6 +31,7 @@ fn context_modification( Context -> Item, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> Item, Context -> Item, @@ -43,6 +45,7 @@ fn context_modification( Context -> List, Context -> List, Context -> List, + Context -> List, Context -> List, Context -> ListDyn, )] diff --git a/node-graph/nodes/graphic/src/artboard.rs b/node-graph/nodes/graphic/src/artboard.rs index 9a4498a69fa..c659d862317 100644 --- a/node-graph/nodes/graphic/src/artboard.rs +++ b/node-graph/nodes/graphic/src/artboard.rs @@ -2,6 +2,7 @@ use core_types::list::{Item, List}; use core_types::transform::TransformMut; use core_types::{ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_LOCATION, CloneVarArgs, Color, Context, Ctx, ExtractAll, Interrupt, OwnedContextImpl}; use glam::{DAffine2, DVec2}; +use graphic_types::Generator; use graphic_types::graphic::{Graphic, IntoGraphicList}; use graphic_types::{Artboard, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -20,6 +21,7 @@ fn create_artboard( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> Item, // TODO: Remove this )] content: impl Node, Output = T>, @@ -51,7 +53,7 @@ fn create_artboard( let normalized_location = location.min(location + dimensions); let normalized_dimensions = dimensions.abs().max(DVec2::ONE); - let background = background.into_element(); + let background = graphic_types::evaluate_color_effects(background).into_element(); // Name is not stored here, it's resolved live from the parent layer's display name Ok(Item::new_from_element(Artboard::new(content)) diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index dad639a8b32..33e90b3016d 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -1,10 +1,12 @@ use brush_types::Stroke; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; -use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, Interrupt, OwnedContextImpl}; +use core_types::{ + ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EFFECTS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, Interrupt, OwnedContextImpl, +}; use glam::{DAffine2, DVec2}; use graphic_types::graphic::{Graphic, IntoGraphicList, is_lone_anonymous_leaf}; -use graphic_types::{Artboard, Vector}; +use graphic_types::{Artboard, Effects, Generator, Vector, value_effects}; use rand::SeedableRng; use rand::seq::SliceRandom; use raster_types::{CPU, GPU, Raster}; @@ -31,6 +33,7 @@ fn remove_at_index( List>, List, List, + List, List, )] list: T, @@ -64,6 +67,7 @@ fn item_at_index( List>, List, List, + List, List, )] list: List, @@ -102,6 +106,7 @@ fn filter( List>, List, List, + List, List, )] list: List, @@ -135,6 +140,7 @@ fn reverse( List>, List, List, + List, List, )] list: List, @@ -160,6 +166,7 @@ fn shift( List>, List, List, + List, List, )] list: List, @@ -205,6 +212,7 @@ fn shuffle( List>, List, List, + List, List, )] list: List, @@ -277,6 +285,7 @@ fn slice( List>, List, List, + List, List, )] list: List, @@ -398,6 +407,7 @@ fn map( List>, List, List, + List, List, )] content: List, @@ -414,6 +424,7 @@ fn map( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] mapped: impl Node, Output = List>, @@ -532,6 +543,7 @@ fn write_attribute( List>, List, List, + List, List, List, List, @@ -829,6 +841,7 @@ fn extend( List>, List, List, + List, List, List, )] @@ -848,6 +861,7 @@ fn extend( List>, List, List, + List, List, List, )] @@ -865,9 +879,9 @@ fn extend( #[node_macro::node(category(""))] fn legacy_layer_extend( _: impl Ctx, - #[implementations(List, List, List, List, List>, List>, List, List)] base: List, + #[implementations(List, List, List, List, List>, List>, List, List, List)] base: List, #[expose] - #[implementations(List, List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List, List)] new: List, nested_node_path: Item, ) -> List { @@ -899,6 +913,7 @@ fn into_group>( List>, List, List, + List, List, List, Item, // TODO: Remove this @@ -1020,7 +1035,14 @@ fn colors_to_gradient(_: impl Ctx, colors: List) -> Item { /// Unwraps a gradient into a `Color[]` of its stops, keeping any `position` and `midpoint` attributes that place them along the ramp. Attributes belonging to the gradient as a whole (like spread and interpolation), rather than its individual color stops, are not preserved. #[node_macro::node(category("Gradient"), icon("NodeGradientToColors"), name("Gradient to Colors"))] fn gradient_to_colors(_: impl Ctx, gradient: Item) -> List { - gradient.into_element().into_color_list() + // The adjustments the gradient carries change each stop's color + let adjustments = gradient.attribute::(ATTR_EFFECTS).map(value_effects::adjustments).unwrap_or_default(); + + let mut colors = gradient.into_element().into_color_list(); + for color in colors.iter_element_values_mut() { + *color = value_effects::adjusted_color(*color, &adjustments); + } + colors } #[cfg(test)] diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 2f295003bac..496efca56da 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -36,7 +36,6 @@ wasm = [ ] image-compare = [] vello = ["gpu"] -shader-nodes = ["raster-nodes/shader-nodes"] [dependencies] # Local dependencies diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index bcfb22b1583..044aab3ea72 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -13,7 +13,7 @@ pub use graphene_application_io as application_io; pub use graphene_core; pub use graphene_core::debug; pub use graphic_nodes; -pub use graphic_types::{Appearance, Artboard, Cover, CoverPlacement, Coverage, Graphic, Vector, stamp_coverage}; +pub use graphic_types::{Appearance, Artboard, BevelDirection, BevelStyle, Cover, CoverPlacement, Coverage, Effect, EffectKind, EffectOperation, Effects, GlowSource, Graphic, Vector, stamp_coverage}; pub use math_nodes; pub use path_bool_nodes; pub use raster_nodes; @@ -60,6 +60,10 @@ pub mod gradient { pub use vector_types::{Gradient, GradientStop}; } +pub mod generator { + pub use graphic_types::generator::*; +} + pub mod transform { pub use core_types::transform::*; pub use vector_types::ReferencePoint; diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 67e5a44a110..dddc046f632 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -9,7 +9,7 @@ use core_types::list::Item; use core_types::list::List; #[cfg(target_family = "wasm")] use core_types::math::bbox::Bbox; -use core_types::ops::Convert; +#[cfg(target_family = "wasm")] use core_types::transform::Footprint; #[cfg(target_family = "wasm")] use core_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM}; @@ -21,7 +21,7 @@ pub use graphene_canvas_utils as canvas_utils; #[cfg(target_family = "wasm")] use graphic_types::Graphic; use graphic_types::raster_types::Image; -use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::raster_types::{CPU, Raster}; #[cfg(target_family = "wasm")] use rendering::{Render, RenderParams, RenderSvgSegmentList, SvgRender}; use std::sync::Arc; @@ -119,7 +119,7 @@ fn string_to_bytes(_: impl Ctx, string: Item) -> Item { /// Converts extracted raw RGBA pixel data from an input image. Each pixel becomes 4 sequential bytes. Useful for transmission over HTTP or writing to files. #[node_macro::node(category("Data: I/O"), icon("NodeImageToBytes"), name("Image to Bytes"))] fn image_to_bytes(_: impl Ctx, image: Item>) -> Item { - let bytes: Vec = image + let bytes: Vec = graphic_types::evaluate_raster_effects(image) .element() .data .iter() @@ -295,12 +295,3 @@ fn try_wgpu_executor(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: I let executor = editor_api.into_element().application_io.as_ref().and_then(|application_io| application_io.gpu_executor()).cloned(); Item::new_from_element(executor) } - -/// Uploads image data from CPU memory into a GPU texture so that GPU-based nodes can process it. -#[node_macro::node(category("Raster"), icon("NodeUploadTexture"), memoize)] -fn upload_texture(_: impl Ctx, content: Item>, #[scope(wgpu_executor::IDENTIFIER)] executor: Item<::wgpu_executor::WgpuExecutor>) -> Item> { - let executor = executor.into_element(); - let (raster, attributes) = content.into_parts(); - - Item::from_parts(raster.convert(Footprint::DEFAULT, executor), attributes) -} diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 6e520565048..0feb0ec295e 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -5,10 +5,9 @@ use core_types::math::bbox::AxisAlignedBbox; use core_types::transform::{Footprint, RenderQuality, Transform}; use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, Generations, OwnedContextImpl, Poll}; use glam::{DAffine2, DVec2, IVec2, UVec2}; -use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::hash::Hash; use std::sync::{Arc, Mutex}; use vector_types::vector::style::RenderMode; @@ -35,7 +34,6 @@ pub struct CachedRegion { #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub struct CacheKey { - pub max_region_area: u32, pub render_mode_hash: u64, pub zoom: u64, pub rotation: u64, @@ -53,7 +51,6 @@ pub struct CacheKey { impl CacheKey { #[expect(clippy::too_many_arguments)] fn new( - max_region_area: u32, render_mode_hash: u64, zoom: f64, rotation: f64, @@ -79,7 +76,6 @@ impl CacheKey { let quantization_amount = 10f64.powi(ROTATION_QUANTIZATION_DIGITS); let quantized_rotation = (rotation * quantization_amount).round() * quantization_amount.recip(); Self { - max_region_area, render_mode_hash, zoom: zoom.to_bits(), rotation: quantized_rotation.to_bits(), @@ -100,8 +96,8 @@ impl CacheKey { pub struct TileCache(Arc>); impl TileCache { - pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { - self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_area) + pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_size: u32) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_size) } pub fn store_regions(&self, regions: Vec) { @@ -165,7 +161,7 @@ fn tiles_bounds(tiles: &[TileCoord]) -> AxisAlignedBbox { } impl TileCacheImpl { - fn query(&mut self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { + fn query(&mut self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_size: u32) -> CacheQuery { if &self.cache_key != cache_key { self.invalidate_all(); self.cache_key = cache_key.clone(); @@ -187,7 +183,7 @@ impl TileCacheImpl { } let missing_tiles: Vec<_> = required_tiles.into_iter().filter(|t| !covered_tiles.contains(t)).collect(); - let missing_regions = group_into_regions(&missing_tiles, max_region_area); + let missing_regions = group_into_regions(&missing_tiles, max_region_size); CacheQuery { cached_regions, missing_regions } } @@ -218,34 +214,57 @@ impl TileCacheImpl { } } -fn group_into_regions(tiles: &[TileCoord], max_region_area: u32) -> Vec { - if tiles.is_empty() { - return Vec::new(); - } +/// Groups tiles into rectangular regions no wider or taller than `max_region_size` pixels. +/// A region renders its whole bounding box, so rectangles render no more than the tiles asked for. +fn group_into_regions(tiles: &[TileCoord], max_region_size: u32) -> Vec { + tile_rectangles(tiles).into_iter().flat_map(|region| split_oversized_region(region, max_region_size)).collect() +} - let tile_set: HashSet<_> = tiles.iter().cloned().collect(); - let mut visited = HashSet::new(); - let mut regions = Vec::new(); +/// Splits tiles into rectangles, each a run of adjacent tiles in a row extended down through the rows below with the same run. O(n log n) for n tiles. +fn tile_rectangles(tiles: &[TileCoord]) -> Vec { + let mut sorted = tiles.to_vec(); + sorted.sort_unstable_by_key(|tile| (tile.y, tile.x)); + sorted.dedup(); + + // Each row's runs of adjacent tiles, as their row and first and last columns + let mut runs: Vec<(i32, i32, i32)> = Vec::new(); + for tile in sorted { + match runs.last_mut() { + Some((row, _, last)) if *row == tile.y && *last + 1 == tile.x => *last = tile.x, + _ => runs.push((tile.y, tile.x, tile.x)), + } + } - for &tile in tiles { - if visited.contains(&tile) { - continue; + // The top and bottom rows of the rectangle each run of columns extends, which closes once a row lacks that run + let mut open: HashMap<(i32, i32), (i32, i32)> = HashMap::new(); + let mut rectangles = Vec::new(); + for (row, first, last) in runs { + match open.get_mut(&(first, last)) { + Some((_, bottom)) if *bottom + 1 == row => *bottom = row, + _ => { + if let Some(rows) = open.insert((first, last), (row, row)) { + rectangles.push(((first, last), rows)); + } + } } - let region_tiles = flood_fill(&tile, &tile_set, &mut visited); - let region = RenderRegion { tiles: region_tiles }; - regions.extend(split_oversized_region(region, max_region_area)); } - regions + rectangles.extend(open); + + rectangles + .into_iter() + .map(|((first, last), (top, bottom))| RenderRegion { + tiles: (top..=bottom).flat_map(|y| (first..=last).map(move |x| TileCoord { x, y })).collect(), + }) + .collect() } -/// Recursively subdivides a region until all sub-regions have area <= max_region_area. +/// Recursively subdivides a region until no sub-region is wider or taller than `max_region_size` pixels. /// Uses axis-aligned splits on the longest dimension. -fn split_oversized_region(region: RenderRegion, max_region_area: u32) -> Vec { +fn split_oversized_region(region: RenderRegion, max_region_size: u32) -> Vec { let pixel_size = tiles_bounds(®ion.tiles).size(); - let area = (pixel_size.x * pixel_size.y) as u32; // Base case: region is small enough - if area <= max_region_area { + if pixel_size.x <= max_region_size as f64 && pixel_size.y <= max_region_size as f64 { return vec![region]; } @@ -291,43 +310,17 @@ fn split_oversized_region(region: RenderRegion, max_region_area: u32) -> Vec, visited: &mut HashSet) -> Vec { - let mut result = Vec::new(); - let mut stack = vec![*start]; - - while let Some(current) = stack.pop() { - if visited.contains(¤t) || !tile_set.contains(¤t) { - continue; - } - visited.insert(current); - result.push(current); - - for neighbor in [ - TileCoord { x: current.x - 1, y: current.y }, - TileCoord { x: current.x + 1, y: current.y }, - TileCoord { x: current.x, y: current.y - 1 }, - TileCoord { x: current.x, y: current.y + 1 }, - ] { - if tile_set.contains(&neighbor) && !visited.contains(&neighbor) { - stack.push(neighbor); - } - } - } - result -} - #[node_macro::node(category(""))] fn render_output_cache( ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + ExtractPointerPosition + Sync, #[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Item>, - #[scope(crate::platform_application_io::editor_api::IDENTIFIER)] editor_api: Item>, data: impl Node, Output = Item> + Send + Sync, #[data] tile_cache: TileCache, ) -> Poll> { @@ -342,10 +335,11 @@ fn render_output_cache( // Fall back to direct render for non-Vello or zero-size viewports let physical_resolution = footprint.resolution; - if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || physical_resolution.x == 0 || physical_resolution.y == 0 { + let executor = executor.into_element(); + let Some(executor) = executor.filter(|_| matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) && physical_resolution.x > 0 && physical_resolution.y > 0) else { let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); return data.eval(&context.into_context()); - } + }; let zoom = footprint.scale_magnitudes().x; let rotation = footprint.decompose_rotation(); @@ -356,10 +350,25 @@ fn render_output_cache( end: footprint.resolution.as_dvec2() - device_origin_offset, }; - let max_region_area = editor_api.into_element().editor_preferences.max_render_region_area(); + // Each region renders into a texture of its own, which Vello GPU can also draw layers across in full + let max_texture_size = executor.context().device.limits().max_texture_dimension_2d; + let max_region_size = max_texture_size.min(wgpu_executor::MAX_LAYER_RENDER_SIZE); + + // Outputs too large for one texture (in practice, large exports) are assembled on the CPU, which only works in native builds + if physical_resolution.x > max_texture_size || physical_resolution.y > max_texture_size { + let output = render_regions_to_buffer( + &viewport_bounds_device, + |ctx| data.eval(&ctx), + ctx.clone(), + &render_params, + &footprint.transform, + max_region_size, + &executor, + ); + return output.map(Item::new_from_element); + } let cache_key = CacheKey::new( - max_region_area, render_params.render_mode as u64, zoom, rotation, @@ -374,7 +383,7 @@ fn render_output_cache( ctx.try_generations().cloned().unwrap_or_default(), ); - let cache_query = tile_cache.query(&viewport_bounds_device, &cache_key, max_region_area); + let cache_query = tile_cache.query(&viewport_bounds_device, &cache_key, max_region_size); // If no regions, fall back to direct render if cache_query.cached_regions.is_empty() && cache_query.missing_regions.iter().all(|region| region.tiles.is_empty()) { @@ -401,7 +410,6 @@ fn render_output_cache( Poll::Final(_) | Poll::Partial(_) | Poll::Pending | Poll::Error(_) => None, }); - let executor = executor.into_element().expect("GPU executor not available"); let output_texture = executor.request_texture(physical_resolution); let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, &executor); @@ -483,42 +491,31 @@ fn composite_cached_regions( unreachable!("composite_cached_regions: expected texture output from Vello render"); }; - let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); - - // Convert global tile position to physical pixel offset in the output texture - let offset_pixels = min_tile * TILE_SIZE as i32 + device_origin_offset.round().as_ivec2(); - - let (src_x, dst_x, width) = if offset_pixels.x >= 0 { - (0, offset_pixels.x as u32, region.size.x.min(output_resolution.x.saturating_sub(offset_pixels.x as u32))) - } else { - let skip = (-offset_pixels.x) as u32; - (skip, 0, region.size.x.saturating_sub(skip).min(output_resolution.x)) - }; - - let (src_y, dst_y, height) = if offset_pixels.y >= 0 { - (0, offset_pixels.y as u32, region.size.y.min(output_resolution.y.saturating_sub(offset_pixels.y as u32))) - } else { - let skip = (-offset_pixels.y) as u32; - (skip, 0, region.size.y.saturating_sub(skip).min(output_resolution.y)) - }; - - if width > 0 && height > 0 { + if let Some(placement) = region_placement(region, device_origin_offset, output_resolution) { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: texture.as_ref(), mip_level: 0, - origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 }, + origin: wgpu::Origin3d { + x: placement.source.x, + y: placement.source.y, + z: 0, + }, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { texture: output_texture, mip_level: 0, - origin: wgpu::Origin3d { x: dst_x, y: dst_y, z: 0 }, + origin: wgpu::Origin3d { + x: placement.destination.x, + y: placement.destination.y, + z: 0, + }, aspect: wgpu::TextureAspect::All, }, wgpu::Extent3d { - width, - height, + width: placement.size.x, + height: placement.size.y, depth_or_array_layers: 1, }, ); @@ -532,3 +529,146 @@ fn composite_cached_regions( queue.submit([encoder.finish()]); combined_metadata } + +/// The part of a region's texture that lands inside the output. +struct RegionPlacement { + /// The origin of the part within the region's texture. + source: UVec2, + /// The origin of the part within the output. + destination: UVec2, + size: UVec2, +} + +fn region_placement(region: &CachedRegion, device_origin_offset: &DVec2, output_resolution: UVec2) -> Option { + let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + + // Convert global tile position to physical pixel offset in the output texture + let offset_pixels = min_tile * TILE_SIZE as i32 + device_origin_offset.round().as_ivec2(); + + let (source_x, destination_x, width) = if offset_pixels.x >= 0 { + (0, offset_pixels.x as u32, region.size.x.min(output_resolution.x.saturating_sub(offset_pixels.x as u32))) + } else { + let skip = (-offset_pixels.x) as u32; + (skip, 0, region.size.x.saturating_sub(skip).min(output_resolution.x)) + }; + + let (source_y, destination_y, height) = if offset_pixels.y >= 0 { + (0, offset_pixels.y as u32, region.size.y.min(output_resolution.y.saturating_sub(offset_pixels.y as u32))) + } else { + let skip = (-offset_pixels.y) as u32; + (skip, 0, region.size.y.saturating_sub(skip).min(output_resolution.y)) + }; + + (width > 0 && height > 0).then_some(RegionPlacement { + source: UVec2::new(source_x, source_y), + destination: UVec2::new(destination_x, destination_y), + size: UVec2::new(width, height), + }) +} + +/// Renders an output too large for one texture into RGBA8 pixels on the CPU, rendering one region at a time and copying it in, so only one region's texture is in use at a time. +fn render_regions_to_buffer( + viewport_bounds_device: &AxisAlignedBbox, + render_fn: F, + ctx: impl Ctx + ExtractAll + CloneVarArgs, + render_params: &RenderParams, + viewport_transform: &DAffine2, + max_region_size: u32, + executor: &WgpuExecutor, +) -> Poll +where + F: Fn(Context<'static>) -> Poll>, +{ + let device_origin_offset = viewport_transform.translation; + let resolution = (viewport_bounds_device.end - viewport_bounds_device.start).round().as_uvec2(); + let row_bytes = resolution.x as usize * BYTES_PER_PIXEL; + + let Some(buffer_size) = row_bytes.checked_mul(resolution.y as usize) else { + return Poll::error(format!("An output of {resolution} pixels is too large to hold in memory")); + }; + let mut pixels = vec![0_u8; buffer_size]; + let mut metadata = rendering::RenderMetadata::default(); + let mut partial = false; + let mut error = None; + + for region in group_into_regions(&bounds_to_tiles(viewport_bounds_device), max_region_size) { + let rendered = render_missing_region(®ion, &render_fn, ctx.clone(), render_params, viewport_transform, &device_origin_offset); + let output = match &rendered.output { + Poll::Final(output) => output, + Poll::Partial(output) => { + partial = true; + output + } + Poll::Fallback(boxed) => { + error.get_or_insert_with(|| boxed.1.clone()); + &boxed.0 + } + Poll::Pending => return Poll::Pending, + Poll::Error(error) => return Poll::Error(error.clone()), + }; + + let mut region_metadata = output.metadata.clone(); + region_metadata.apply_transform(*viewport_transform); + metadata.merge(®ion_metadata); + + let Some(placement) = region_placement(&rendered, &device_origin_offset, resolution) else { + continue; + }; + let RenderOutputType::Texture(texture) = &output.data else { + log::error!("Expected a texture from rendering a region with Vello"); + continue; + }; + if (texture.width(), texture.height()) != (rendered.size.x, rendered.size.y) { + return Poll::error("A rendered region's texture doesn't match the region's size"); + } + // A failed readback, as always happens in a browser, fails the whole output rather than leaving a hole + let region_pixels = match executor.read_texture_pixels(texture) { + Ok(region_pixels) => region_pixels, + Err(error) => return Poll::error(error.context("Failed to read back a rendered region")), + }; + + // Copy the overlapping rows of the region into place + let region_row_bytes = texture.width() as usize * BYTES_PER_PIXEL; + let copied_bytes = placement.size.x as usize * BYTES_PER_PIXEL; + for row in 0..placement.size.y as usize { + let source_start = (placement.source.y as usize + row) * region_row_bytes + placement.source.x as usize * BYTES_PER_PIXEL; + let destination_start = (placement.destination.y as usize + row) * row_bytes + placement.destination.x as usize * BYTES_PER_PIXEL; + pixels[destination_start..destination_start + copied_bytes].copy_from_slice(®ion_pixels[source_start..source_start + copied_bytes]); + } + } + + let output = RenderOutput { + data: RenderOutputType::Buffer { + data: pixels, + width: resolution.x, + height: resolution.y, + }, + metadata, + }; + match (partial, error) { + (true, _) => Poll::Partial(output), + (false, Some(error)) => Poll::Fallback(Box::new((output, error))), + (false, None) => Poll::Final(output), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_tiles_in_an_l_shape_group_into_two_full_rectangles() { + // The tiles a diagonal pan uncovers along the top and left of a viewport 10 tiles wide and 6 tall + let tiles: Vec<_> = (0..6).flat_map(|y| (0..10).map(move |x| TileCoord { x, y })).filter(|tile| tile.y == 0 || tile.x < 2).collect(); + + let regions = group_into_regions(&tiles, 4096); + assert_eq!(regions.len(), 2); + assert_eq!(regions.iter().map(|region| region.tiles.len()).sum::(), tiles.len()); + + // A region renders its whole bounding box, so each must be filled by its tiles + for region in ®ions { + let size = tiles_bounds(®ion.tiles).size() / TILE_SIZE as f64; + assert_eq!(region.tiles.len(), (size.x * size.y) as usize); + } + } +} diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 195932f1fe0..a693251ba24 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -4,8 +4,10 @@ use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs}; use core_types::{Color, Context, Ctx, ExtractFootprint, Interrupt, OwnedContextImpl, WasmNotSend}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphene_application_io::{ExportFormat, RenderConfig}; +use graphic_types::Generator; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; +use rendering::vello_scene_builder::VelloSceneBuilder; use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput}; use std::sync::Arc; use vector_types::Gradient; @@ -33,6 +35,7 @@ fn render_intermediate( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] data: impl Node, Output = T>, @@ -100,16 +103,24 @@ fn render( RenderOutputType::Svg { svg: output.svg, - image_data: output.image_data.into_iter().map(|(image, id)| (id, image.0)).collect(), + image_data: output.image_data.into_iter().collect(), } } (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { let executor = executor.into_element().expect("GPU executor not available"); - let mut scene = executor.vello_scene(footprint.resolution, footprint.transform).expect("Failed to create Vello scene"); - data.render_to_vello(&mut scene, Default::default(), &render_params); - - let texture = executor.render_vello_scene(scene).expect("Failed to render Vello scene"); + let mut draw = |scene: &mut VelloSceneBuilder| data.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 + Err(error) => { + log::error!("Failed to render Vello scene: {error}"); + let limit = glam::UVec2::splat(executor.context().device.limits().max_texture_dimension_2d); + executor + .render_vello(footprint.resolution.min(limit), footprint.transform, &mut |_| {}) + .expect("Failed to render an empty Vello scene") + } + }; RenderOutputType::Texture(texture) } _ => unreachable!("Render node did not receive its requested data type"), diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 60d2b326820..f791443ec7c 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -6,7 +6,7 @@ use core_types::{Color, Ctx, OwnedContextImpl, num_traits}; use core_types::{Context, Interrupt}; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::raster_types::{CPU, GPU, Raster}; -use graphic_types::{Artboard, Graphic, Vector}; +use graphic_types::{Artboard, Generator, Graphic, Vector}; use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; @@ -1394,6 +1394,7 @@ fn switch( Context -> Item>, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> Item>, Context -> Item>, @@ -1424,6 +1425,7 @@ fn switch( Context -> Item>, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> Item>, Context -> Item>, @@ -1583,11 +1585,25 @@ fn gradient_value(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHIT gradient } -/// Sets the form (linear or radial) of each gradient in the input list. -#[node_macro::node(category("Gradient"), icon("NodeGradientForm"))] -fn gradient_form(_: impl Ctx, gradient: Item, gradient_form: Item) -> Item { +/// Curves the contours at the start and end of each gradient in the input list between straight lines, circles, and points, with the contours between following along. +/// +/// Each end runs from -1 to 1. At 0 its contour is a straight line, at 1 or -1 it shrinks to the end's own point, and between it is a circle through the end that bows toward the other end when positive or away from it when negative. A linear gradient is 0 at both ends, and a radial gradient is 1 at its start and 0.5 at its end. +#[node_macro::node(category("Gradient"), icon("NodeGradientForm"), inspector("gradient_curvature_inspector"))] +fn gradient_curvature( + _: impl Ctx, + gradient: Item, + /// The curve of the contour through the start. + #[range] + #[soft(-1..1)] + start_curvature: Item, + /// The curve of the contour through the end. + #[range] + #[soft(-1..1)] + end_curvature: Item, +) -> Item { let mut gradient = gradient; - gradient.set_attribute(core_types::ATTR_GRADIENT_FORM, *gradient_form.element()); + gradient.set_attribute(core_types::ATTR_GRADIENT_START_CURVATURE, *start_curvature.element()); + gradient.set_attribute(core_types::ATTR_GRADIENT_END_CURVATURE, *end_curvature.element()); gradient } @@ -1715,6 +1731,7 @@ fn evaluate_gradient( #[soft(0..1)] position: Item, ) -> Item { + let gradient = graphic_types::evaluate_gradient_effects(gradient); let settings = vector_types::GradientSettings::from(&gradient); let color = gradient.element().evaluate(*position.element(), settings); Item::new_from_element(color) diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index e470f4fb94e..5405ebe4330 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -273,9 +273,9 @@ fn flatten_vector(graphic_list: &List) -> List { boolean_operation_on_vector_list(&flattened, BooleanOperation::Union).into_iter().collect::>() } } - // Rasters, colors, and gradients bound no region, so they contribute no operand - Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) => Vec::new(), - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) => Vec::new(), + // Rasters, colors, gradients, and generators bound no region, so they contribute no operand + Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::Generator(_) => Vec::new(), + Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) | Graphic::GeneratorList(_) => Vec::new(), // Strokes have no vector outline representation; a brush node renders them to rasters Graphic::StrokeList(_) => Vec::new(), // Normalized to GraphicList above diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index dc489867d30..54ceea38843 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -15,28 +15,11 @@ publish.workspace = true workspace = true [features] -default = ["std"] -serde = ["dep:serde", "core-types?/serde", "raster-types?/serde", "vector-types?/serde"] -shader-nodes = ["std", "dep:raster-nodes-shaders", "dep:wgpu-executor"] -std = [ - "serde", - "dep:core-types", - "dep:graphene-core", - "dep:dyn-any", - "dep:graphene-resource", - "dep:graphene-hash", - "dep:raster-types", - "dep:vector-types", - "dep:image", - "dep:ndarray", - "dep:rand", - "dep:rand_chacha", - "dep:fastnoise-lite", - "dep:kurbo", - "dep:color", -] +default = ["serde"] +serde = ["dep:serde", "core-types/serde", "graphic-types/serde", "raster-types/serde", "vector-types/serde"] wasm = [ "core-types/wasm", + "graphic-types/wasm", "raster-types/wasm", "vector-types/wasm", "tsify", @@ -47,34 +30,23 @@ wasm = [ # Local dependencies no-std-types = { workspace = true } node-macro = { workspace = true } - -# Local std dependencies -dyn-any = { workspace = true, optional = true } -core-types = { workspace = true, optional = true } -graphene-core = { workspace = true, optional = true } -graphene-resource = { workspace = true, optional = true } -graphene-hash = { workspace = true, optional = true } -raster-types = { workspace = true, optional = true } -vector-types = { workspace = true, optional = true } -wgpu-executor = { workspace = true, optional = true } -raster-nodes-shaders = { path = "./shaders", optional = true } +dyn-any = { workspace = true } +core-types = { workspace = true } +graphene-core = { workspace = true } +graphene-resource = { workspace = true } +graphene-hash = { workspace = true } +graphic-types = { workspace = true } +raster-types = { workspace = true } +vector-types = { workspace = true } # Workspace dependencies bytemuck = { workspace = true } glam = { workspace = true } -spirv-std = { workspace = true } -num-traits = { workspace = true } -num_enum = { workspace = true } - -# Workspace std dependencies -image = { workspace = true, optional = true } -ndarray = { workspace = true, optional = true } -rand = { workspace = true, optional = true } -rand_chacha = { workspace = true, optional = true } -fastnoise-lite = { workspace = true, optional = true } +image = { workspace = true } +ndarray = { workspace = true } serde = { workspace = true, optional = true } -kurbo = { workspace = true, optional = true } -color = { workspace = true, optional = true } +kurbo = { workspace = true } +color = { workspace = true } # Workspace Wasm dependencies tsify = { workspace = true, optional = true } diff --git a/node-graph/nodes/raster/shaders/Cargo.toml b/node-graph/nodes/raster/shaders/Cargo.toml index 04eecc014a5..40d66180cfc 100644 --- a/node-graph/nodes/raster/shaders/Cargo.toml +++ b/node-graph/nodes/raster/shaders/Cargo.toml @@ -14,9 +14,13 @@ publish.workspace = true [lints] workspace = true +[features] +# Installs the rust-gpu toolchain and compiles the entrypoint crate to WGSL +compile = ["dep:cargo-gpu-install", "dep:env_logger", "dep:log"] + [dependencies] [build-dependencies] -cargo-gpu-install = { workspace = true } -env_logger = { workspace = true } -log = { workspace = true } +cargo-gpu-install = { workspace = true, optional = true } +env_logger = { workspace = true, optional = true } +log = { workspace = true, optional = true } diff --git a/node-graph/nodes/raster/shaders/build.rs b/node-graph/nodes/raster/shaders/build.rs index f80677a3470..2c767f57235 100644 --- a/node-graph/nodes/raster/shaders/build.rs +++ b/node-graph/nodes/raster/shaders/build.rs @@ -1,8 +1,12 @@ -use cargo_gpu_install::install::{Install, InstalledBackend}; -use cargo_gpu_install::spirv_builder::SpirvMetadata; -use std::path::PathBuf; +#[cfg(not(feature = "compile"))] +pub fn main() {} +#[cfg(feature = "compile")] pub fn main() -> Result<(), Box> { + use cargo_gpu_install::install::{Install, InstalledBackend}; + use cargo_gpu_install::spirv_builder::SpirvMetadata; + use std::path::PathBuf; + env_logger::builder().filter_level(log::LevelFilter::Debug).init(); // Skip building the shaders if they are provided externally diff --git a/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml b/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml index 49d35767293..e00b4bf8fd9 100644 --- a/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml +++ b/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml @@ -13,6 +13,3 @@ publish.workspace = true [lints] workspace = true - -[dependencies] -raster-nodes = { path = "../..", default-features = false } diff --git a/node-graph/nodes/raster/shaders/entrypoint/src/lib.rs b/node-graph/nodes/raster/shaders/entrypoint/src/lib.rs index 8a0881de526..eba9cba932b 100644 --- a/node-graph/nodes/raster/shaders/entrypoint/src/lib.rs +++ b/node-graph/nodes/raster/shaders/entrypoint/src/lib.rs @@ -1,2 +1,2 @@ +//! Kernels re-exported here are compiled to WGSL with rust-gpu by the parent crate's `compile` feature. #![no_std] -pub use raster_nodes::*; diff --git a/node-graph/nodes/raster/shaders/src/lib.rs b/node-graph/nodes/raster/shaders/src/lib.rs index 37432e9a65b..08eec6a3157 100644 --- a/node-graph/nodes/raster/shaders/src/lib.rs +++ b/node-graph/nodes/raster/shaders/src/lib.rs @@ -1 +1,2 @@ +#[cfg(feature = "compile")] pub const WGSL_SHADER: &str = include_str!(env!("RASTER_NODES_SHADER_PATH")); diff --git a/node-graph/nodes/raster/src/adjust.rs b/node-graph/nodes/raster/src/adjust.rs deleted file mode 100644 index 650b81d1fde..00000000000 --- a/node-graph/nodes/raster/src/adjust.rs +++ /dev/null @@ -1,30 +0,0 @@ -use no_std_types::color::Color; - -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); - } -} - -#[cfg(feature = "std")] -mod adjust_std { - use super::*; - use raster_types::{CPU, Raster}; - use vector_types::Gradient; - - 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 cbc2751fa1d..51d755a82ba 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -1,127 +1,37 @@ #![allow(clippy::too_many_arguments)] -use crate::adjust::Adjust; -#[cfg(feature = "std")] -use crate::color_lookup_table::LutCache; -use core::fmt::Debug; -#[cfg(feature = "std")] use core_types::list::{Item, List}; -#[cfg(feature = "std")] -use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator}; -#[cfg(feature = "std")] +use core_types::transfer_curve::TransferCurve; use glam::DVec2; -use glam::Vec3; -#[cfg(feature = "std")] use graphene_resource::Resource; -use no_std_types::color::{Color, linear_to_srgb, set_luminosity, srgb_to_linear}; +use graphic_types::adjustment::{ + Adjustment, BlackAndWhiteSettings, ChannelMixerRow, ChannelMixerSettings, ColorBalanceSettings, ColorBalanceShifts, CurvesSettings, HueRangeSettings, HueSaturationSettings, HueSaturationSliders, + LevelsRecord, LevelsSettings, SelectiveColorInks, SelectiveColorSettings, +}; +pub use graphic_types::adjustment::{DesaturateMethod, RelativeAbsolute}; +pub use graphic_types::generator::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, NoiseType}; +use graphic_types::{Effect, EffectOperation, Generator, Graphic, Vector, evaluate_color_effects, evaluate_gradient_effects, stamp_effect}; +use no_std_types::color::Color; use no_std_types::context::Ctx; -#[cfg(not(feature = "std"))] -use no_std_types::list::ShaderItem as Item; -use node_macro::BufferStruct; -use num_enum::{FromPrimitive, IntoPrimitive}; -#[cfg(not(feature = "std"))] -use num_traits::float::Float; -#[cfg(feature = "std")] -use raster_types::{CPU, Image, Raster}; -#[cfg(feature = "std")] +use raster_types::{CPU, GPU, Image, Raster}; use vector_types::Gradient; -/// Conversion from a color to grayscale. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)] -#[widget(Dropdown)] -#[repr(u32)] -pub enum DesaturateMethod { - /// Light level of the color, the Y (luminance) of Rec. 709, which weights the linear-light RGB channels by `0.2126, 0.7152, 0.0722`. - /// - /// Accessibility contrast ratios and SVG luminance masks use this. - #[default] - #[label("Luminance (Rec. 709)")] - #[cfg_attr(feature = "serde", serde(alias = "SRGB"))] - LuminanceRec709, - /// Light level approximation for the color, the Y′ (luma) of Rec. 709, which weights the gamma-encoded RGB channels by `0.2126, 0.7152, 0.0722`. - /// - /// CSS filter functions such as `grayscale()` use this. - #[label("Luma (Rec. 709)")] - LumaRec709, - /// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`. - /// - /// The Luminosity family of blend modes uses this, rounded to `0.3, 0.59, 0.11`. - #[label("Luma (Rec. 601)")] - LumaRec601, - /// Perceptually uniform scale from black to white, the L (lightness) of OkLab. - #[label("Lightness (OkLab)")] - #[cfg_attr(feature = "serde", serde(alias = "Perceptual"))] - LightnessOkLab, - /// Mean of the three linear-light RGB channels. - #[menu_separator] - #[cfg_attr(feature = "serde", serde(alias = "AverageChannels"))] - ChannelsAverage, - /// Smallest of the three linear-light RGB channels. - #[cfg_attr(feature = "serde", serde(alias = "MinimumChannels"))] - ChannelsMinimum, - /// Largest of the three linear-light RGB channels, the V (value) of HSV. - #[cfg_attr(feature = "serde", serde(alias = "MaximumChannels"))] - ChannelsMaximum, - /// Midpoint of the largest and smallest gamma-encoded RGB channels, the L (lightness) of HSL. - /// - /// The classic "Desaturate" command of many image editors uses this. - #[label("Lightness (HSL)")] - LightnessHsl, +/// Stamps the adjustment onto the item's effects, to be carried out wherever its content is rendered or read. +pub(crate) fn adjust(item: Item, adjustment: Adjustment) -> Item { + let mut item = item; + stamp_effect(&mut item, Effect::new(&EffectOperation::Adjustment(adjustment))); + item } -#[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"), shader_node(PerPixelAdjust))] -fn desaturate>( - _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, - method: Item, -) -> Item { - let mut input = input; - let method = method.into_element(); - - input.element_mut().adjust(|color| { - // Gamma-encoded formulas are decoded as if they were a gray - let gamma = || color.to_gamma_srgb_channels(); - let luminance = match method { - DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(), - DesaturateMethod::LumaRec709 => { - let [r, g, b, _] = gamma(); - srgb_to_linear(0.2126 * r + 0.7152 * g + 0.0722 * b) - } - DesaturateMethod::LumaRec601 => { - let [r, g, b, _] = gamma(); - srgb_to_linear(0.299 * r + 0.587 * g + 0.114 * b) - } - DesaturateMethod::LightnessOkLab => { - // A gray's OkLab lightness is the cube root of its linear value, so cubing gives the gray of equal lightness - let lightness = color.lightness_oklab(); - lightness * lightness * lightness - } - DesaturateMethod::ChannelsAverage => color.average_rgb_channels(), - DesaturateMethod::ChannelsMinimum => color.minimum_rgb_channels(), - DesaturateMethod::ChannelsMaximum => color.maximum_rgb_channels(), - DesaturateMethod::LightnessHsl => { - // The transfer curve is monotonic, so the extremes are found first and only they are encoded - let max = linear_to_srgb(color.maximum_rgb_channels()); - let min = linear_to_srgb(color.minimum_rgb_channels()); - srgb_to_linear((max + min) / 2.) - } - }; - color.map_rgb(|_| luminance) - }); - input +#[node_macro::node(category("Raster: Adjustment"), icon("NodeDesaturate"))] +fn desaturate(_: impl Ctx, #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, method: Item) -> Item { + adjust(input, Adjustment::Desaturate { method: method.into_element() }) } -#[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"), shader_node(PerPixelAdjust))] -fn gamma_correction>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeGammaCorrection"))] +fn gamma_correction( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, #[default(2.2)] #[range] #[hard(0.0001..)] @@ -129,17 +39,16 @@ fn gamma_correction>( gamma: Item, inverse: Item, ) -> Item { - let mut input = input; - 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 + adjust( + input, + Adjustment::GammaCorrection { + gamma: gamma.into_element(), + inverse: inverse.into_element(), + }, + ) } /// The red, green, blue, and alpha channels of an image, split into separate node outputs. -#[cfg(feature = "std")] #[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] pub struct ImageChannels { /// The red channel of the image, as a grayscale image. @@ -153,10 +62,9 @@ pub struct ImageChannels { } /// Separates an image into its red, green, blue, and alpha channels, each provided as a grayscale image. -#[cfg(feature = "std")] #[node_macro::node(category("Raster"), icon("NodeImageToChannels"), name("Image to Channels"))] fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { - let (image, attributes) = image.into_parts(); + let (image, attributes) = graphic_types::evaluate_raster_effects(image).into_parts(); let (width, height) = (image.width, image.height); // O(4 × pixels), since all four channels are written even when only some outputs are connected @@ -169,122 +77,16 @@ fn image_to_channels(_: impl Ctx, image: Item>) -> ImageChannels { // Each channel image keeps the source image's attributes, such as its transform let [red, green, blue, alpha] = channels.map(|data| { - let channel_image = Image { - width, - height, - data, - base64_string: None, - }; + let channel_image = Image { width, height, data }; Item::from_parts(Raster::new_cpu(channel_image), attributes.clone()) }); ImageChannels { red, green, blue, alpha } } -#[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"), shader_node(PerPixelAdjust))] -fn make_opaque>( - _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, -) -> Item { - let mut input = input; - input.element_mut().adjust(|color| color.with_alpha(1.)); - input -} - -/// Remaps a gamma-space channel through the stages of a Levels adjustment: the input range, the midtones gamma, and the output range. -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 = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); - let value = ((value - input_shadows).max(0.) / highlights_minus_shadows).min(1.); - let value = value.powf(inverse_gamma); - - value * (output_maximum - output_minimum) + output_minimum -} - -/// The classic Brightness/Contrast algorithm: a Levels remap around the pivot, adding the brightness before a positive -/// contrast stretch and after a negative contrast squeeze. -fn brightness_contrast_classic(value: f32, brightness: f32, contrast: f32, pivot: f32) -> f32 { - // Full contrast is a hard step, sending values at the pivot or above to white (with half a 16-bit step of slack for float ties) - if contrast >= 1. { - return if value + brightness >= pivot - 1. / 65536. { 1. } else { 0. }; - } - - let result = if contrast > 0. { - let input_shadows = pivot * contrast - brightness; - apply_levels(value, input_shadows, input_shadows + 1. - contrast, 1., 0., 1.) - } else { - let output_minimum = brightness - pivot * contrast; - apply_levels(value, 0., 1., 1., output_minimum, output_minimum + 1. + contrast) - }; - - 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, -} - -impl BrightnessCurve { - fn new(brightness: f32) -> Self { - let slope = 2_f32.powf(brightness / 110.); - let knee = 0.5 / slope; - let end_slope = (1. / (1. + 12. * (slope - 1.))).max(0.1); - - Self { slope, knee, end_slope } - } - - /// Evaluates the Hermite segment at its parameter t in 0..1, returning the value and its derivative with respect to x. - fn hermite(&self, t: f32) -> (f32, f32) { - let length = 1. - self.knee; - let start_tangent = length * self.slope; - let end_tangent = length * self.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; - - (value, derivative) - } - - fn forward(&self, x: f32) -> f32 { - if x < self.knee { - return self.slope * x; - } - - let t = ((x - self.knee) / (1. - self.knee)).min(1.); - self.hermite(t).0.min(1.) - } - - /// Inverts the curve, solving the monotone Hermite segment with a bracketed Newton iteration. - fn inverse(&self, y: f32) -> f32 { - if y <= 0.5 { - return y / self.slope; - } - - let mut low = 0.; - let mut high = 1.; - let mut t = (y - 0.5) * 2.; - for _ in 0..8 { - let (value, derivative) = self.hermite(t); - let error = value - y; - if error > 0. { - high = t; - } else { - low = t; - } - - let step = t - error / (derivative * (1. - self.knee)); - t = if step >= low && step <= high { step } else { (low + high) * 0.5 }; - } - - self.knee + t * (1. - self.knee) - } +#[node_macro::node(category("Raster: Adjustment"), icon("NodeMakeOpaque"))] +fn make_opaque(_: impl Ctx, #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item) -> Item { + adjust(input, Adjustment::MakeOpaque) } // Aims for interoperable compatibility with: @@ -295,18 +97,10 @@ 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"), - shader_node(PerPixelAdjust) -)] -fn brightness_contrast>( +#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), icon("NodeBrightnessContrast"), inspector("brightness_contrast_inspector"))] +fn brightness_contrast( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, #[unit("%")] #[range] #[hard(-150..150)] @@ -321,47 +115,20 @@ fn brightness_contrast>( #[default(127.)] classic_pivot: Item, ) -> Item { - let mut input = input; - let use_classic = use_classic.into_element(); - let classic_pivot = (classic_pivot.into_element() / 255.) as f32; - - // Each algorithm has its own slider range, matched here and in the Inspector - let (brightness_extent, contrast_minimum) = if use_classic { (100., -100.) } else { (150., -50.) }; - let brightness = (brightness.into_element() as f32).clamp(-brightness_extent, brightness_extent); - let contrast = (contrast.into_element() as f32).clamp(contrast_minimum, 100.) / 100.; - - // Beyond a magnitude of 100, the curve for 100 is applied first and the curve for the remainder after it - let magnitude = brightness.abs(); - let first_curve = BrightnessCurve::new(magnitude.min(100.)); - let second_curve = BrightnessCurve::new((magnitude - 100.).max(0.)); - - input.element_mut().adjust(|color| { - color.map_gamma_rgb(|c| { - if use_classic { - return brightness_contrast_classic(c, brightness / 255., contrast, classic_pivot); - } - - // Negative brightness runs the same curves in reverse - let brightened = if brightness >= 0. { - second_curve.forward(first_curve.forward(c)) - } else { - first_curve.inverse(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); - contrasted.clamp(0., 1.) - }) - }); - - input + adjust( + input, + Adjustment::BrightnessContrast { + brightness: brightness.into_element(), + contrast: contrast.into_element(), + use_classic: use_classic.into_element(), + classic_pivot: classic_pivot.into_element(), + }, + ) } -#[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] #[widget(Dropdown)] /// The channel whose settings are shown, with RGB adjusting all three color channels together. pub enum AdjustmentChannel { @@ -374,101 +141,15 @@ pub enum AdjustmentChannel { Alpha, } -/// One Levels record in the node's units: percentage input and output points and the gamma value. -#[derive(Clone, Copy)] -struct LevelsRecord { - shadows: f32, - midtones: f32, - highlights: f32, - output_minimums: f32, - 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, -} - -impl LevelsRecord { - fn new(shadows: f32, midtones: f32, highlights: f32, output_minimums: f32, output_maximums: f32) -> Self { - Self { - shadows, - midtones, - highlights, - output_minimums, - output_maximums, - } - } - - fn stage(&self, gamma: f32) -> LevelsStage { - LevelsStage { - curve: LevelsCurve::from_points(self.shadows * 2.55, self.highlights * 2.55, gamma), - output_minimum: self.output_minimums / 100., - output_maximum: self.output_maximums / 100., - } - } -} - -impl LevelsStage { - fn apply(&self, value: f32) -> f32 { - self.curve.apply(value) * (self.output_maximum - self.output_minimum) + self.output_minimum - } -} - -/// A channel's record followed by the composite record. -#[derive(Clone, Copy)] -struct LevelsChain { - first: LevelsStage, - second: LevelsStage, - two_stages: bool, -} - -impl LevelsChain { - fn new(channel: LevelsRecord, composite: LevelsRecord) -> Self { - // For PSD interop, two power functions with nothing between them (the composite's input points and the - // channel's output range at their defaults) merge into one curve with the product of the gammas, toe included - let nothing_between = composite.shadows == 0. && composite.highlights == 100. && channel.output_minimums == 0. && channel.output_maximums == 100.; - if nothing_between { - let merged = LevelsRecord { - output_minimums: composite.output_minimums, - output_maximums: composite.output_maximums, - ..channel - }; - let stage = merged.stage(channel.midtones * composite.midtones); - Self { - first: stage, - second: stage, - two_stages: false, - } - } else { - Self { - first: channel.stage(channel.midtones), - second: composite.stage(composite.midtones), - two_stages: true, - } - } - } - - fn apply(&self, value: f32) -> f32 { - let value = self.first.apply(value); - if self.two_stages { self.second.apply(value) } else { value } - } -} - // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html -#[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"), shader_node(PerPixelAdjust))] -fn levels>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeLevels"), inspector("levels_inspector"))] +fn levels( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -611,62 +292,22 @@ fn levels>( alpha_output_maximums: Item, _channel: Item, ) -> Item { - let mut image = image; - let composite = LevelsRecord::new( - shadows.into_element() as f32, - midtones.into_element() as f32, - highlights.into_element() as f32, - output_minimums.into_element() as f32, - output_maximums.into_element() as f32, - ); - let red = LevelsChain::new( - LevelsRecord::new( - red_shadows.into_element() as f32, - red_midtones.into_element() as f32, - red_highlights.into_element() as f32, - red_output_minimums.into_element() as f32, - red_output_maximums.into_element() as f32, - ), - composite, - ); - let green = LevelsChain::new( - LevelsRecord::new( - green_shadows.into_element() as f32, - green_midtones.into_element() as f32, - green_highlights.into_element() as f32, - green_output_minimums.into_element() as f32, - green_output_maximums.into_element() as f32, - ), - composite, - ); - let blue = LevelsChain::new( - LevelsRecord::new( - blue_shadows.into_element() as f32, - blue_midtones.into_element() as f32, - blue_highlights.into_element() as f32, - blue_output_minimums.into_element() as f32, - blue_output_maximums.into_element() as f32, - ), - composite, - ); - - // Alpha stands apart from the composite record that the three color channels pass through - let alpha = LevelsRecord::new( - alpha_shadows.into_element() as f32, - alpha_midtones.into_element() as f32, - alpha_highlights.into_element() as f32, - alpha_output_minimums.into_element() as f32, - alpha_output_maximums.into_element() as f32, - ); - let alpha = alpha.stage(alpha.midtones); - - image.element_mut().adjust(|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 + let record = |shadows: Item, midtones: Item, highlights: Item, output_minimums: Item, output_maximums: Item| LevelsRecord { + shadows: shadows.into_element(), + midtones: midtones.into_element(), + highlights: highlights.into_element(), + output_minimums: output_minimums.into_element(), + output_maximums: output_maximums.into_element(), + }; + let settings = LevelsSettings { + composite: record(shadows, midtones, highlights, output_minimums, output_maximums), + red: record(red_shadows, red_midtones, red_highlights, red_output_minimums, red_output_maximums), + green: record(green_shadows, green_midtones, green_highlights, green_output_minimums, green_output_maximums), + blue: record(blue_shadows, blue_midtones, blue_highlights, blue_output_minimums, blue_output_maximums), + alpha: record(alpha_shadows, alpha_midtones, alpha_highlights, alpha_output_minimums, alpha_output_maximums), + }; + + adjust(image, Adjustment::Levels(Box::new(settings))) } // Aims for interoperable compatibility with: @@ -676,12 +317,10 @@ fn levels>( // Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost // points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages // stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. -// Needs the heap for its curves, so it stays off the shader build for now. -#[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), icon("NodeCurves"), inspector("transfer_curves_inspector"))] -fn curves + Send>( +fn curves( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, curve: Item, #[name("(Red) Curve")] red_curve: Item, #[name("(Green) Curve")] green_curve: Item, @@ -689,29 +328,18 @@ fn curves + Send>( #[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(); - - // 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; - - Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a) - }); + let settings = CurvesSettings { + composite: curve.into_element(), + red: red_curve.into_element(), + green: green_curve.into_element(), + blue: blue_curve.into_element(), + alpha: alpha_curve.into_element(), + }; - image + adjust(image, Adjustment::Curves(Box::new(settings))) } /// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them. -#[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), icon("NodePointsToTransferCurve"), name("Points to Transfer Curve"))] fn points_to_transfer_curve( _: impl Ctx, @@ -729,18 +357,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"), - shader_node(PerPixelAdjust) -)] -fn black_and_white>( +#[node_macro::node(name("Black & White"), category("Raster: Adjustment"), icon("NodeBlackAndWhite"), inspector("black_and_white_inspector"))] +fn black_and_white( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, use_tint: Item, #[default("#e1d3b3")] tint: Item, #[unit("%")] @@ -774,64 +394,23 @@ fn black_and_white>( #[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| { - // 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; - let green_part = g - gray_base; - let blue_part = b - gray_base; - - let additional = if red_part == 0. { - let cyan_part = green_part.min(blue_part); - cyan_part * cyans + (green_part - cyan_part) * greens + (blue_part - cyan_part) * blues - } else if green_part == 0. { - let magenta_part = red_part.min(blue_part); - magenta_part * magentas + (red_part - magenta_part) * reds + (blue_part - magenta_part) * blues - } else { - let yellow_part = red_part.min(green_part); - yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens - }; - - let luminance = (gray_base + additional).clamp(0., 1.); - if !use_tint { - return Color::from_gamma_srgb_channels(luminance, luminance, luminance, alpha_part); - } - - // The tint takes on the gray's luminosity the way the Luminosity blend mode would - let [tint_r, tint_g, tint_b, _] = tint.to_gamma_srgb_channels(); - let tint_luma = luma_rec_601_fixed_point(tint_r, tint_g, tint_b); - let [tinted_r, tinted_g, tinted_b] = set_luminosity(tint_r, tint_g, tint_b, tint_luma, luminance); + let settings = BlackAndWhiteSettings { + use_tint: use_tint.into_element(), + tint: evaluate_color_effects(tint).into_element(), + reds: reds.into_element(), + yellows: yellows.into_element(), + greens: greens.into_element(), + cyans: cyans.into_element(), + blues: blues.into_element(), + magentas: magentas.into_element(), + }; - Color::from_gamma_srgb_channels(tinted_r, tinted_g, tinted_b, alpha_part) - }); - image + adjust(image, Adjustment::BlackAndWhite(settings)) } -#[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] #[widget(Dropdown)] pub enum HueSaturationRange { #[default] @@ -844,192 +423,15 @@ pub enum HueSaturationRange { Magentas, } -/// HSL of gamma-encoded channels: hue in degrees, saturation and lightness in 0..1. -fn gamma_rgb_to_hsl(r: f32, g: f32, b: f32) -> [f32; 3] { - let maximum = r.max(g).max(b); - let minimum = r.min(g).min(b); - let chroma = maximum - minimum; - let lightness = (maximum + minimum) / 2.; - if chroma <= 0. { - return [0., 0., lightness]; - } - - let saturation = chroma / (1. - (2. * lightness - 1.).abs()).max(1e-6); - [hexagon_hue_degrees(r, g, b), saturation.min(1.), lightness] -} - -/// Hexagon hue in degrees of three channels in any encoding, 0 for gray. -fn hexagon_hue_degrees(r: f32, g: f32, b: f32) -> f32 { - let maximum = r.max(g).max(b); - let chroma = maximum - r.min(g).min(b); - if chroma <= 0. { - return 0.; - } - - let sector = if maximum == r { - wrap_positive((g - b) / chroma, 6.) - } else if maximum == g { - (b - r) / chroma + 2. - } else { - (r - g) / chroma + 4. - }; - sector * 60. -} - -/// `value` wrapped into the range from 0 to `modulus`. -fn wrap_positive(value: f32, modulus: f32) -> f32 { - value - (value / modulus).floor() * modulus -} - -/// Gamma-encoded channels from a hue in degrees and saturation and lightness in 0..1. -fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { - let chroma = (1. - (2. * lightness - 1.).abs()) * saturation; - let sector = wrap_positive(hue, 360.) / 60.; - let x = chroma * (1. - (wrap_positive(sector, 2.) - 1.).abs()); - let (r, g, b) = if sector < 1. { - (chroma, x, 0.) - } else if sector < 2. { - (x, chroma, 0.) - } else if sector < 3. { - (0., chroma, x) - } else if sector < 4. { - (0., x, chroma) - } else if sector < 5. { - (x, 0., chroma) - } else { - (chroma, 0., x) - }; - let m = lightness - chroma / 2.; - - [(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, -} - -impl HueSaturationSettings { - fn new(hue: f32, saturation_percent: f32, lightness_percent: f32) -> Self { - Self { - hue, - saturation: saturation_percent / 100., - 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, -} - -impl HueSaturationRangeSettings { - fn new(falloff_start: f32, range_start: f32, range_end: f32, falloff_end: f32, settings: HueSaturationSettings) -> Self { - // For PSD interop, each edge rounds to 1536 hue units per turn over 359 rather than 360 degrees, landing up to a degree late - let edge = |degrees: f32| (degrees * 1536. / 359.).round() * 360. / 1536.; - - Self { - falloff_start: edge(falloff_start), - range_start: edge(range_start), - range_end: edge(range_end), - falloff_end: edge(falloff_end), - settings, - } - } - - fn weight(&self, hue: f32) -> f32 { - let distance = |from: f32, to: f32| wrap_positive(to - from, 360.); - if distance(self.range_start, hue) <= distance(self.range_start, self.range_end) { - return 1.; - } - let start_falloff = distance(self.falloff_start, self.range_start); - let end_falloff = distance(self.range_end, self.falloff_end); - if distance(self.falloff_start, hue) < start_falloff { - return distance(self.falloff_start, hue) / start_falloff; - } - if distance(self.range_end, hue) < end_falloff { - return 1. - distance(self.range_end, hue) / end_falloff; - } - 0. - } -} - -/// The six ranges' combined effect on one pixel, gathered before the master sliders apply. -struct HueSaturationRangeEffect { - hue_shift: f32, - saturation_factor: f32, - fully_saturate: bool, - rgb: [f32; 3], -} - -impl HueSaturationRangeEffect { - fn apply(&mut self, range: &HueSaturationRangeSettings, original_hue: f32, original_saturation: f32) { - // Range weights come from the original hue, and grays belong to no range - let weight = if original_saturation > 0. { range.weight(original_hue) } else { 0. }; - if weight <= 0. { - return; - } - - self.hue_shift += range.settings.hue * weight; - // For PSD interop, +100 saturates fully from the very edge of the falloff rather than scaling with the weight - if range.settings.saturation >= 1. { - self.fully_saturate = true; - } else { - self.saturation_factor *= 1. + (saturation_gain(range.settings.saturation) - 1.) * weight; - } - self.rgb = lightness_toward_max_or_min(self.rgb, range.settings.lightness * weight); - } -} - -/// The factor a saturation amount in -1..1 applies to HSL saturation, quantized for PSD interop: 1 - trunc(256 a) / 256 below zero and floor(65280 / (255 - trunc(254 a))) / 256 above. -fn saturation_gain(amount: f32) -> f32 { - if amount < 0. { - 1. - (-amount * 256.).trunc() / 256. - } else { - (65280. / (255. - (amount * 254.).trunc())).floor() / 256. - } -} - -/// Blends toward white for a positive amount and toward black for a negative one, as the master lightness slider does. -fn lightness_toward_white_or_black(value: f32, amount: f32) -> f32 { - if amount >= 0. { value + (1. - value) * amount } else { value * (1. + amount) } -} - -/// A range's lightness moves the channels toward the color's own maximum (positive) or minimum (negative) instead. -fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { - let maximum = rgb[0].max(rgb[1]).max(rgb[2]); - let minimum = rgb[0].min(rgb[1]).min(rgb[2]); - let toward = if amount >= 0. { maximum } else { minimum }; - let blend = |value: f32| value + (toward - value) * amount.abs(); - [blend(rgb[0]), blend(rgb[1]), blend(rgb[2])] -} - // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // 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"), - shader_node(PerPixelAdjust) -)] -fn hue_saturation>( +#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), icon("NodeHueSaturation"), inspector("hue_saturation_inspector"))] +fn hue_saturation( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, #[unit("°")] #[range] #[soft(-180..180)] @@ -1297,134 +699,82 @@ fn hue_saturation>( magentas_falloff_end: Item, _range: Item, ) -> Item { - let mut input = input; - 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); - let (reds, yellows, greens, cyans, blues, magentas) = ( - HueSaturationRangeSettings::new( - reds_falloff_start.into_element() as f32, - reds_range_start.into_element() as f32, - reds_range_end.into_element() as f32, - reds_falloff_end.into_element() as f32, - HueSaturationSettings::new(reds_hue.into_element() as f32, reds_saturation.into_element() as f32, reds_lightness.into_element() as f32), + let sliders = |hue: Item, saturation: Item, lightness: Item| HueSaturationSliders { + hue: hue.into_element(), + saturation: saturation.into_element(), + lightness: lightness.into_element(), + }; + let range = |sliders: HueSaturationSliders, falloff_start: Item, range_start: Item, range_end: Item, falloff_end: Item| HueRangeSettings { + sliders, + falloff_start: falloff_start.into_element(), + range_start: range_start.into_element(), + range_end: range_end.into_element(), + falloff_end: falloff_end.into_element(), + }; + let settings = HueSaturationSettings { + master: sliders(hue, saturation, lightness), + colorize: colorize.into_element(), + colorize_sliders: sliders(colorize_hue, colorize_saturation, colorize_lightness), + reds: range( + sliders(reds_hue, reds_saturation, reds_lightness), + reds_falloff_start, + reds_range_start, + reds_range_end, + reds_falloff_end, ), - HueSaturationRangeSettings::new( - yellows_falloff_start.into_element() as f32, - yellows_range_start.into_element() as f32, - yellows_range_end.into_element() as f32, - yellows_falloff_end.into_element() as f32, - HueSaturationSettings::new(yellows_hue.into_element() as f32, yellows_saturation.into_element() as f32, yellows_lightness.into_element() as f32), + yellows: range( + sliders(yellows_hue, yellows_saturation, yellows_lightness), + yellows_falloff_start, + yellows_range_start, + yellows_range_end, + yellows_falloff_end, ), - HueSaturationRangeSettings::new( - greens_falloff_start.into_element() as f32, - greens_range_start.into_element() as f32, - greens_range_end.into_element() as f32, - greens_falloff_end.into_element() as f32, - HueSaturationSettings::new(greens_hue.into_element() as f32, greens_saturation.into_element() as f32, greens_lightness.into_element() as f32), + greens: range( + sliders(greens_hue, greens_saturation, greens_lightness), + greens_falloff_start, + greens_range_start, + greens_range_end, + greens_falloff_end, ), - HueSaturationRangeSettings::new( - cyans_falloff_start.into_element() as f32, - cyans_range_start.into_element() as f32, - cyans_range_end.into_element() as f32, - cyans_falloff_end.into_element() as f32, - HueSaturationSettings::new(cyans_hue.into_element() as f32, cyans_saturation.into_element() as f32, cyans_lightness.into_element() as f32), + cyans: range( + sliders(cyans_hue, cyans_saturation, cyans_lightness), + cyans_falloff_start, + cyans_range_start, + cyans_range_end, + cyans_falloff_end, ), - HueSaturationRangeSettings::new( - blues_falloff_start.into_element() as f32, - blues_range_start.into_element() as f32, - blues_range_end.into_element() as f32, - blues_falloff_end.into_element() as f32, - HueSaturationSettings::new(blues_hue.into_element() as f32, blues_saturation.into_element() as f32, blues_lightness.into_element() as f32), + blues: range( + sliders(blues_hue, blues_saturation, blues_lightness), + blues_falloff_start, + blues_range_start, + blues_range_end, + blues_falloff_end, ), - HueSaturationRangeSettings::new( - magentas_falloff_start.into_element() as f32, - magentas_range_start.into_element() as f32, - magentas_range_end.into_element() as f32, - magentas_falloff_end.into_element() as f32, - HueSaturationSettings::new(magentas_hue.into_element() as f32, magentas_saturation.into_element() as f32, magentas_lightness.into_element() as f32), + magentas: range( + sliders(magentas_hue, magentas_saturation, magentas_lightness), + magentas_falloff_start, + magentas_range_start, + magentas_range_end, + magentas_falloff_end, ), - ); - - input.element_mut().adjust(|color| { - let [r, g, b, alpha] = color.to_gamma_srgb_channels(); - - if colorize { - let [_, _, lightness] = gamma_rgb_to_hsl(r, g, b); - let lightness = lightness_toward_white_or_black(lightness, colorize_settings.lightness); - let [r, g, b] = hsl_to_gamma_rgb(colorize_settings.hue, colorize_settings.saturation, lightness); - return Color::from_gamma_srgb_channels(r, g, b, alpha); - } - - // Each range weights its sliders by its falloff around the original hue: hue shifts add and saturation gains multiply - let [original_hue, original_saturation, _] = gamma_rgb_to_hsl(r, g, b); - let mut effect = HueSaturationRangeEffect { - hue_shift: master.hue, - saturation_factor: 1., - fully_saturate: false, - rgb: [r, g, b], - }; - effect.apply(&reds, original_hue, original_saturation); - effect.apply(&yellows, original_hue, original_saturation); - effect.apply(&greens, original_hue, original_saturation); - effect.apply(&cyans, original_hue, original_saturation); - effect.apply(&blues, original_hue, original_saturation); - effect.apply(&magentas, original_hue, original_saturation); - let HueSaturationRangeEffect { - hue_shift, - mut saturation_factor, - mut fully_saturate, - rgb, - } = effect; - if master.saturation >= 1. { - fully_saturate = true; - } else { - saturation_factor *= saturation_gain(master.saturation); - } - - // The master lightness blends toward white or black before the hue and saturation, which work in HSL of the gamma channels - let rgb = [ - lightness_toward_white_or_black(rgb[0], master.lightness), - lightness_toward_white_or_black(rgb[1], master.lightness), - lightness_toward_white_or_black(rgb[2], master.lightness), - ]; - let [hue, saturation, lightness] = gamma_rgb_to_hsl(rgb[0], rgb[1], rgb[2]); - let saturation = if saturation <= 0. { - 0. - } else if fully_saturate { - 1. - } else { - (saturation * saturation_factor).min(1.) - }; - let [r, g, b] = hsl_to_gamma_rgb(hue + hue_shift, saturation, lightness); + }; - Color::from_gamma_srgb_channels(r, g, b, alpha) - }); - input + adjust(input, Adjustment::HueSaturation(Box::new(settings))) } // 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"), shader_node(PerPixelAdjust))] -fn invert>( - _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - 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"))] +fn invert(_: impl Ctx, #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item) -> Item { + adjust(input, Adjustment::Invert) } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient -#[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"), shader_node(PerPixelAdjust))] -fn threshold>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeThreshold"), inspector("threshold_inspector"))] +fn threshold( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[unit("%")] #[range] #[hard(0..100)] @@ -1436,19 +786,13 @@ fn threshold>( #[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; - - image.element_mut().adjust(|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 }; - output.with_alpha(color.a()) - }); - image + adjust( + image, + Adjustment::Threshold { + min_luminance: min_luminance.into_element(), + max_luminance: max_luminance.into_element(), + }, + ) } // Aims for interoperable compatibility with: @@ -1464,41 +808,29 @@ 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. -#[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), icon("NodeGradientMap"))] -fn gradient_map + Send>( +fn gradient_map( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[default(Color::BLACK, Color::WHITE)] gradient: Item, reverse: Item, ) -> Item { - let mut image = image; - let settings = vector_types::GradientSettings::from(&gradient); - let evaluator = gradient.into_element().evaluator(settings); - let reverse = reverse.into_element(); - - image.element_mut().adjust(|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 }; - - // The source alpha is kept and the gradient's own alpha stops are ignored - evaluator.evaluate(intensity as f64).with_alpha(alpha) - }); - - image + adjust( + image, + Adjustment::GradientMap { + gradient: evaluate_gradient_effects(gradient), + reverse: reverse.into_element(), + }, + ) } // 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"), shader_node(PerPixelAdjust))] -fn vibrance>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeVibrance"), inspector("vibrance_inspector"))] +fn vibrance( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[unit("%")] #[range] #[hard(-100..100)] @@ -1508,79 +840,18 @@ fn vibrance>( #[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; - - // Vibrance then saturation, both in linear light, which equals applying each alone in turn - image.element_mut().adjust(|color| { - 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 after_vibrance = Color::from_rgbaf32_unchecked( - scale_about_maximum(r, maximum, chroma_factor, brightness_factor), - scale_about_maximum(g, maximum, chroma_factor, brightness_factor), - scale_about_maximum(b, maximum, chroma_factor, brightness_factor), - color.a(), - ); - - // 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 -} - -fn scale_about_maximum(channel: f32, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> f32 { - (brightness_factor * (maximum + chroma_factor * (channel - maximum))).clamp(0., 1.) -} - -/// Share of the vibrance boost the skin-tone protection removes at full weight, a fitted constant. -const VIBRANCE_PROTECTION_LOSS: f32 = 0.4857; - -/// Vibrance on linear SDR channels as `[chroma factor about the max, brightness multiply]` for an amount in -1..1, both fading out toward black. -/// Negative desaturates and darkens low-chroma colors most. Positive boosts them, brightens a little, and spares reds. -fn vibrance_factors(r: f32, g: f32, b: f32, amount: f32) -> [f32; 2] { - let maximum = r.max(g).max(b); - let minimum = r.min(g).min(b); - if maximum <= 0. { - return [1., 1.]; - } - let ratio = minimum / maximum; - let saturation = 1. - ratio; - let q = ratio * saturation; - let toe = 1. - (16. * maximum).min(1.); - let rolloff = 1. - toe * toe; - let brightness = (2. * q - q * q) * (1. - maximum) * rolloff; - - if amount < 0. { - let amount = -amount; - let chroma_factor = (1. - amount / 4.) * (1. - amount * (1. - rolloff * saturation * (1. + saturation) / 2.)); - return [chroma_factor, 1. - amount * brightness]; - } - - let protection = skin_tone_window(hexagon_hue_degrees(r, g, b)) * (1. - saturation * saturation); - let amount = amount * (1. - protection * (1. - amount)); - let boost = (5. / 6.) * (1. - VIBRANCE_PROTECTION_LOSS * protection) * amount * ratio * (1. - minimum) * rolloff; - [1. / (1. - boost), 1. + amount * brightness / 4.] + adjust( + image, + Adjustment::Vibrance { + vibrance: vibrance.into_element(), + saturation: saturation.into_element(), + }, + ) } -/// How fully a hue falls under the skin-tone protection: all of it from red to 30 degrees, fading out by 45, and back in from 300. -fn skin_tone_window(hue: f32) -> f32 { - if hue < 45. { - ((45. - hue) / 15.).min(1.) - } else if hue >= 300. { - (hue - 300.) / 60. - } else { - 0. - } -} - -#[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] #[widget(Radio)] pub enum RedGreenBlue { #[default] @@ -1590,9 +861,8 @@ pub enum RedGreenBlue { } // Kept only to deserialize the channel input of old Extract Channel nodes for document migration -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny)] pub enum RedGreenBlueAlpha { #[default] Red, @@ -1601,104 +871,15 @@ pub enum RedGreenBlueAlpha { Alpha, } -/// Style of noise pattern. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] -#[widget(Dropdown)] -pub enum NoiseType { - #[default] - Perlin, - #[label("OpenSimplex2")] - OpenSimplex2, - #[label("OpenSimplex2S")] - OpenSimplex2S, - Cellular, - ValueCubic, - Value, - WhiteNoise, -} - -/// Style of layered levels of the noise pattern. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] -pub enum FractalType { - #[default] - None, - #[label("Fractional Brownian Motion")] - FBm, - Ridged, - PingPong, - #[label("Progressive (Domain Warp Only)")] - DomainWarpProgressive, - #[label("Independent (Domain Warp Only)")] - DomainWarpIndependent, -} - -/// Distance function used by the cellular noise. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] -pub enum CellularDistanceFunction { - #[default] - Euclidean, - #[label("Euclidean Squared (Faster)")] - EuclideanSq, - Manhattan, - Hybrid, -} - -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] -pub enum CellularReturnType { - CellValue, - #[default] - #[label("Nearest (F1)")] - Nearest, - #[label("Next Nearest (F2)")] - NextNearest, - #[label("Average (F1 / 2 + F2 / 2)")] - Average, - #[label("Difference (F2 - F1)")] - Difference, - #[label("Product (F2 * F1 / 2)")] - Product, - #[label("Division (F1 / F2)")] - Division, -} - -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)] -#[widget(Dropdown)] -pub enum DomainWarpType { - #[default] - None, - #[label("OpenSimplex2")] - OpenSimplex2, - #[label("OpenSimplex2 Reduced")] - OpenSimplex2Reduced, - BasicGrid, -} - // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer // 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"), shader_node(PerPixelAdjust))] -fn channel_mixer>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeChannelMixer"), inspector("channel_mixer_inspector"))] +fn channel_mixer( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, monochrome: Item, @@ -1805,69 +986,26 @@ fn channel_mixer>( // Display-only parameters (not used within the node) _output_channel: Item, ) -> Item { - let mut image = image; - let monochrome = monochrome.into_element(); - let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = ( - monochrome_r.into_element() as f32, - monochrome_g.into_element() as f32, - monochrome_b.into_element() as f32, - monochrome_c.into_element() as f32, - ); - let (red_r, red_g, red_b, red_c) = (red_r.into_element() as f32, red_g.into_element() as f32, red_b.into_element() as f32, red_c.into_element() as f32); - let (green_r, green_g, green_b, green_c) = ( - green_r.into_element() as f32, - green_g.into_element() as f32, - green_b.into_element() as f32, - green_c.into_element() as f32, - ); - 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| { - 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.); - - (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.); - - (red, green, blue) - }; - - Color::from_gamma_srgb_channels(out_r, out_g, out_b, a) - }); - image -} + let row = |red: Item, green: Item, blue: Item, constant: Item| ChannelMixerRow { + red: red.into_element(), + green: green.into_element(), + blue: blue.into_element(), + constant: constant.into_element(), + }; + let settings = ChannelMixerSettings { + monochrome: monochrome.into_element(), + monochrome_mix: row(monochrome_r, monochrome_g, monochrome_b, monochrome_c), + red: row(red_r, red_g, red_b, red_c), + green: row(green_r, green_g, green_b, green_c), + blue: row(blue_r, blue_g, blue_b, blue_c), + }; -#[repr(u32)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] -#[widget(Radio)] -pub enum RelativeAbsolute { - #[default] - Relative, - Absolute, + adjust(image, Adjustment::ChannelMixer(Box::new(settings))) } -#[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] pub enum SelectiveColorChoice { #[default] Reds, @@ -1889,12 +1027,10 @@ pub enum SelectiveColorChoice { // // Algorithm based on: // https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html -#[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"), shader_node(PerPixelAdjust))] -fn selective_color>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeSelectiveColor"), inspector("selective_color_inspector"))] +fn selective_color( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, mode: Item, @@ -2089,139 +1225,39 @@ fn selective_color>( _colors: Item, ) -> Item { - let mut image = image; - 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); - let (g_c, g_m, g_y, g_k) = (g_c.into_element() as f32, g_m.into_element() as f32, g_y.into_element() as f32, g_k.into_element() as f32); - let (c_c, c_m, c_y, c_k) = (c_c.into_element() as f32, c_m.into_element() as f32, c_y.into_element() as f32, c_k.into_element() as f32); - let (b_c, b_m, b_y, b_k) = (b_c.into_element() as f32, b_m.into_element() as f32, b_y.into_element() as f32, b_k.into_element() as f32); - let (m_c, m_m, m_y, m_k) = (m_c.into_element() as f32, m_m.into_element() as f32, m_y.into_element() as f32, m_k.into_element() as f32); - let (w_c, w_m, w_y, w_k) = (w_c.into_element() as f32, w_m.into_element() as f32, w_y.into_element() as f32, w_k.into_element() as f32); - 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| { - let [r, g, b, a] = color.to_gamma_srgb_channels(); - - let min = |a: f32, b: f32, c: f32| a.min(b).min(c); - let max = |a: f32, b: f32, c: f32| a.max(b).max(c); - let med = |a: f32, b: f32, c: f32| a + b + c - min(a, b, c) - max(a, b, c); - - let max_channel = max(r, g, b); - let min_channel = min(r, g, b); - - let pixel_color_range = |choice| match choice { - SelectiveColorChoice::Reds => max_channel == r, - SelectiveColorChoice::Yellows => min_channel == b, - SelectiveColorChoice::Greens => max_channel == g, - SelectiveColorChoice::Cyans => min_channel == r, - SelectiveColorChoice::Blues => max_channel == b, - SelectiveColorChoice::Magentas => min_channel == g, - SelectiveColorChoice::Whites => r > 0.5 && g > 0.5 && b > 0.5, - // Every pixel, since the neutrals scale factor already vanishes at black, white, and fully saturated colors - SelectiveColorChoice::Neutrals => true, - SelectiveColorChoice::Blacks => r < 0.5 && g < 0.5 && b < 0.5, - }; - - let color_parameter_group_scale_factor_rgb = max(r, g, b) - med(r, g, b); - 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 { - 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; - // Indexed because the shader compiler cannot lower array iterators - #[allow(clippy::needless_range_loop)] - for i in 0..array.len() { - let (color_parameter_group, (c, m, y, k)) = array[i]; - - // 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 - if (c == 0. && m == 0. && y == 0. && k == 0.) || !pixel_color_range(color_parameter_group) { - continue; - } - - let color_parameter_group_scale_factor = match color_parameter_group { - SelectiveColorChoice::Reds | SelectiveColorChoice::Greens | SelectiveColorChoice::Blues => color_parameter_group_scale_factor_rgb, - SelectiveColorChoice::Cyans | SelectiveColorChoice::Magentas | SelectiveColorChoice::Yellows => color_parameter_group_scale_factor_cmy, - SelectiveColorChoice::Whites => min(r, g, b) * 2. - 1., - SelectiveColorChoice::Neutrals => 1. - ((max(r, g, b) - 0.5).abs() + (min(r, g, b) - 0.5).abs()), - 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 = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; - let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; - let offset_b = f32::clamp(ink(y) * slope_b, -b, -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); - } - - let rgb = Vec3::new(r, g, b); - let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE); + let inks = |cyan: Item, magenta: Item, yellow: Item, black: Item| SelectiveColorInks { + cyan: cyan.into_element(), + magenta: magenta.into_element(), + yellow: yellow.into_element(), + black: black.into_element(), + }; + let settings = SelectiveColorSettings { + mode: mode.into_element(), + reds: inks(r_c, r_m, r_y, r_k), + yellows: inks(y_c, y_m, y_y, y_k), + greens: inks(g_c, g_m, g_y, g_k), + cyans: inks(c_c, c_m, c_y, c_k), + blues: inks(b_c, b_m, b_y, b_k), + magentas: inks(m_c, m_m, m_y, m_k), + whites: inks(w_c, w_m, w_y, w_k), + neutrals: inks(n_c, n_m, n_y, n_k), + blacks: inks(k_c, k_m, k_y, k_k), + }; - Color::from_gamma_srgb_channels(out.x, out.y, out.z, a) - }); - image + adjust(image, Adjustment::SelectiveColor(Box::new(settings))) } // 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"), shader_node(PerPixelAdjust))] -fn posterize>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodePosterize"))] +fn posterize( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, #[default(4)] #[hard(2..)] levels: Item, ) -> Item { - let mut input = input; - let levels = levels.into_element() 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 + adjust(input, Adjustment::Posterize { levels: levels.into_element() }) } // Aims for interoperable compatibility with: @@ -2230,12 +1266,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"), shader_node(PerPixelAdjust))] -fn exposure>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodeExposure"), inspector("exposure_inspector"))] +fn exposure( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] input: Item, #[range] #[soft(-20..20)] exposure: Item, @@ -2248,33 +1282,19 @@ fn exposure>( #[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.)) - }; - - 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 + adjust( + input, + Adjustment::Exposure { + exposure: exposure.into_element(), + offset: offset.into_element(), + gamma_correction: gamma_correction.into_element(), + }, + ) } -#[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType)] #[widget(Dropdown)] pub enum TonalRange { Shadows, @@ -2283,95 +1303,15 @@ 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, -} - -impl LevelsCurve { - fn new(black: i32, white: i32, gamma: f32) -> Self { - Self::from_points(black as f32, white as f32, gamma) - } - - /// `gamma` is the Levels dialog value (pixel exponent 1/gamma). - fn from_points(black: f32, white: f32, gamma: f32) -> Self { - let gamma = gamma.max(0.01); - let black = black.min(white - 1.); - let exponent = 1. / gamma; - - let mut curve = Self { - black, - white, - exponent, - toe_end: 0., - toe_value: 0., - toe_slope_start: 0., - toe_slope_end: 0., - }; - if gamma > 1. { - let slope = 2_f32.powf(gamma); - let intersection = 255. * slope.powf(-1. / (1. - exponent)); - // The cubic toe joins the power curve at twice the intersection - if intersection > 1e-3 { - let toe_end = 2. * intersection; - curve.toe_end = toe_end; - curve.toe_value = 255. * (toe_end / 255.).powf(exponent); - curve.toe_slope_start = slope; - curve.toe_slope_end = exponent * (toe_end / 255.).powf(exponent - 1.); - } - } - curve - } - - /// Maps one gamma-space channel value in 0..1. - fn apply(&self, value: f32) -> f32 { - let t = ((value * 255. - self.black) * 255. / (self.white - self.black)).clamp(0., 255.); - let y = if t < self.toe_end { - let u = t / self.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; - hermite_start * self.toe_end * self.toe_slope_start + hermite_end_value * self.toe_value + hermite_end_slope * self.toe_end * self.toe_slope_end - } else { - 255. * (t / 255.).powf(self.exponent) - }; - y / 255. - } -} - -/// One channel's Levels parameters from its own slider values, and (with preserve_luminosity) the slider -/// extremes across all three channels. The halvings truncate toward zero, as PSD interop requires. -fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i32, h_min: i32, preserve_luminosity: bool) -> LevelsCurve { - let (black, white, tone) = if preserve_luminosity { - (s_max - s, 255 - (h - h_min), m - (m_max + m_min) / 2) - } else { - (0.max(-s), 255 - 0.max(h), (s + h) / 2 + m) - }; - - // Rounding the derived gamma to hundredths, the precision of a PSD Levels record, is needed for compatible results - let gamma = (2_f32.powf(tone as f32 / 100.) * 100.).round() / 100.; - LevelsCurve::new(black, white, gamma) -} - // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance // // 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"), shader_node(PerPixelAdjust))] -fn color_balance>( +// the nine sliders, see `color_balance_curve` in the adjustment kernels. +#[node_macro::node(category("Raster: Adjustment"), icon("NodeColorBalance"), inspector("color_balance_inspector"))] +fn color_balance( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[name("(Shadows) Cyan-Red")] #[unit("%")] @@ -2426,52 +1366,28 @@ fn color_balance>( // Display-only parameter (not used within the node) _tone: Item, ) -> Item { - let mut image = image; - let preserve_luminosity = preserve_luminosity.into_element(); - - // The derivation below is integer arithmetic, so the sliders round to whole percentages first - let slider = |value: f32| value.round() as i32; - let (s_r, s_g, s_b) = ( - slider(shadows_cyan_red.into_element() as f32), - slider(shadows_magenta_green.into_element() as f32), - slider(shadows_yellow_blue.into_element() as f32), - ); - let (m_r, m_g, m_b) = ( - slider(midtones_cyan_red.into_element() as f32), - slider(midtones_magenta_green.into_element() as f32), - slider(midtones_yellow_blue.into_element() as f32), - ); - let (h_r, h_g, h_b) = ( - slider(highlights_cyan_red.into_element() as f32), - slider(highlights_magenta_green.into_element() as f32), - slider(highlights_yellow_blue.into_element() as f32), - ); - - let s_max = s_r.max(s_g).max(s_b); - let m_max = m_r.max(m_g).max(m_b); - let m_min = m_r.min(m_g).min(m_b); - let h_min = h_r.min(h_g).min(h_b); - let red = color_balance_curve(s_r, m_r, h_r, s_max, m_max, m_min, h_min, preserve_luminosity); - 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| { - // 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 + let shifts = |cyan_red: Item, magenta_green: Item, yellow_blue: Item| ColorBalanceShifts { + cyan_red: cyan_red.into_element(), + magenta_green: magenta_green.into_element(), + yellow_blue: yellow_blue.into_element(), + }; + let settings = ColorBalanceSettings { + shadows: shifts(shadows_cyan_red, shadows_magenta_green, shadows_yellow_blue), + midtones: shifts(midtones_cyan_red, midtones_magenta_green, midtones_yellow_blue), + highlights: shifts(highlights_cyan_red, highlights_magenta_green, highlights_yellow_blue), + preserve_luminosity: preserve_luminosity.into_element(), + }; + + adjust(image, Adjustment::ColorBalance(settings)) } // 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"), shader_node(PerPixelAdjust))] -fn photo_filter>( +#[node_macro::node(category("Raster: Adjustment"), icon("NodePhotoFilter"))] +fn photo_filter( _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - image: Item, + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] image: Item, #[default("#ec8a00")] color: Item, #[unit("%")] #[range] @@ -2480,53 +1396,14 @@ fn photo_filter>( density: Item, #[default(true)] preserve_luminosity: Item, ) -> Item { - let mut image = image; - let color = color.into_element(); - let density = (density.into_element() / 100.) as f32; - let preserve_luminosity = preserve_luminosity.into_element(); - - // The image is multiplied in XYZ by the filter color normalized to the white point, with density easing that multiplier toward 1 - let filter_xyz = multiply_matrix(&SRGB_TO_XYZ_D50, [color.r(), color.g(), color.b()]); - let factor = [ - 1. + density * (filter_xyz[0] / WHITE_XYZ_D50[0] - 1.), - 1. + density * (filter_xyz[1] / WHITE_XYZ_D50[1] - 1.), - 1. + density * (filter_xyz[2] / WHITE_XYZ_D50[2] - 1.), - ]; - - image.element_mut().adjust(|pixel| { - 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]]); - let mut r = linear_to_srgb(filtered[0].clamp(0., 1.)); - let mut g = linear_to_srgb(filtered[1].clamp(0., 1.)); - let mut b = linear_to_srgb(filtered[2].clamp(0., 1.)); - - if preserve_luminosity { - let luma_in = luma_rec_601_fixed_point(r_in.clamp(0., 1.), g_in.clamp(0., 1.), b_in.clamp(0., 1.)); - [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed_point(r, g, b), luma_in); - } - - Color::from_gamma_srgb_channels(r, g, b, alpha) - }); - image -} - -// sRGB colorants adapted to D50 as in the sRGB IEC61966-2.1 ICC profile, row major, and their inverse -pub(crate) const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.43607, 0.38515, 0.14307], [0.22249, 0.71687, 0.06061], [0.01392, 0.09708, 0.71410]]; -pub(crate) const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.134096, -1.6174, -0.490638], [-0.978793, 1.916295, 0.033454], [0.071971, -0.228987, 1.40538]]; -pub(crate) const WHITE_XYZ_D50: [f32; 3] = [0.96420, 1., 0.82491]; - -pub(crate) fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] { - [ - matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2], - matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2], - matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2], - ] -} - -/// The Rec. 601 luma in the 14-bit fixed point that PSD interop depends on. -fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { - (4915. * r + 9667. * g + 1802. * b) / 16384. + adjust( + image, + Adjustment::PhotoFilter { + color: evaluate_color_effects(color).into_element(), + density: density.into_element(), + preserve_luminosity: preserve_luminosity.into_element(), + }, + ) } // Aims for interoperable compatibility with: @@ -2535,57 +1412,26 @@ 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. -#[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), icon("NodeColorLookup"))] -fn color_lookup + Send>( +fn color_lookup( _: impl Ctx, /// The image whose colors are remapped by the LUT (lookup table). - #[implementations(Raster, Color, Gradient)] + #[implementations(Raster, Color, Gradient, Graphic, Vector, Raster, String, Generator)] 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, - #[data] lut_cache: LutCache, ) -> Item { - let mut image = image; - let Ok(lut) = lut_cache.parse(lut_file.element()) else { return image }; - - image.element_mut().adjust(|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]); - - Color::from_gamma_srgb_channels(r, g, b, a) - }); - - image + adjust(image, Adjustment::ColorLookup { lut_file: lut_file.into_element() }) } -#[cfg(feature = "std")] mod _graphene_hash_impls { - use super::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, - RelativeAbsolute, SelectiveColorChoice, TonalRange, - }; - graphene_hash::impl_via_hash!( - DesaturateMethod, - RedGreenBlue, - RedGreenBlueAlpha, - NoiseType, - FractalType, - CellularDistanceFunction, - CellularReturnType, - DomainWarpType, - RelativeAbsolute, - SelectiveColorChoice, - AdjustmentChannel, - TonalRange, - HueSaturationRange - ); + use super::{AdjustmentChannel, HueSaturationRange, RedGreenBlue, RedGreenBlueAlpha, SelectiveColorChoice, TonalRange}; + graphene_hash::impl_via_hash!(RedGreenBlue, RedGreenBlueAlpha, SelectiveColorChoice, AdjustmentChannel, TonalRange, HueSaturationRange); } -#[cfg(all(feature = "std", test))] +#[cfg(test)] mod tests { use super::*; @@ -2606,7 +1452,7 @@ mod tests { 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. + evaluate_color_effects(result).into_element().to_gamma_srgb_channels()[0] * 255. } #[test] @@ -2704,7 +1550,7 @@ mod tests { 100_f64.into(), AdjustmentChannel::Rgb.into(), ); - let [r, g, _, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, _, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255.] } @@ -2749,7 +1595,7 @@ mod tests { 20_f64.into(), 80_f64.into(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2859,7 +1705,7 @@ mod tests { 345_f64.into(), HueSaturationRange::Master.into(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -2948,7 +1794,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 = evaluate_color_effects(invert((), Item::new_from_element(color))).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}"); @@ -2959,7 +1805,7 @@ mod tests { 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()); - result.into_element().r() == 1. + evaluate_color_effects(result).into_element().r() == 1. } #[test] @@ -2988,7 +1834,7 @@ mod tests { 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 [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -3044,7 +1890,7 @@ mod tests { 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(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -3071,7 +1917,10 @@ 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. + evaluate_color_effects(posterize((), Item::new_from_element(pixel), levels.into())) + .into_element() + .to_gamma_srgb_channels()[0] + * 255. } #[test] @@ -3095,7 +1944,7 @@ mod tests { 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. + evaluate_color_effects(result).into_element().to_gamma_srgb_channels()[0] * 255. } #[test] @@ -3147,7 +1996,7 @@ mod tests { preserve_luminosity.into(), TonalRange::Midtones.into(), ); - let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -3192,7 +2041,7 @@ mod tests { 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 [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + let [r, g, b, _] = evaluate_color_effects(result).into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } @@ -3218,4 +2067,77 @@ 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 adjustments_stamp_rows_rather_than_changing_the_value() { + use core_types::list::ATTR_EFFECTS; + use graphic_types::{EffectKind, Effects}; + + let color = Color::from_rgbaf32_unchecked(0.2, 0.4, 0.6, 1.); + let result = posterize((), invert((), Item::new_from_element(color)), 3_i64.into()); + assert_eq!(*result.element(), color, "the color itself is left alone"); + + let kinds: Vec = result + .attribute::(ATTR_EFFECTS) + .map(|effects| effects.effects().map(|effect| effect.kind()).collect()) + .unwrap_or_default(); + assert_eq!(kinds, [EffectKind::Invert, EffectKind::Posterize]); + assert_eq!( + result + .attribute::(ATTR_EFFECTS) + .and_then(|effects| effects.effects().nth(1)) + .and_then(|row| row.0.attribute::("levels")), + Some(&3) + ); + } + + #[test] + fn posterizing_a_gradient_bands_its_ramp() { + use graphic_types::evaluate_gradient_effects; + use vector_types::GradientSettings; + + // Adjusting only the two end stops would leave a smooth ramp from black to white + let result = evaluate_gradient_effects(posterize((), Item::new_from_element(Gradient::from(vec![Color::BLACK, Color::WHITE])), 2_i64.into())); + let settings = GradientSettings::from_item_attributes(&result); + + for t in [0.05, 0.2] { + assert_eq!(result.element().evaluate(t, settings), Color::BLACK, "dark band at {t}"); + } + for t in [0.8, 0.95] { + assert_eq!(result.element().evaluate(t, settings), Color::WHITE, "light band at {t}"); + } + } + + #[test] + fn adjusting_a_raster_follows_its_earlier_effects() { + use core_types::ATTR_TRANSFORM; + use glam::DAffine2; + use graphic_types::{Effect, EffectOperation, evaluate_raster_effects, stamp_effect}; + + // A white square in the middle of a transparent image, placed one pixel per document unit, then blurred + let mut image = Image::new(20, 20, Color::TRANSPARENT); + for y in 5..15 { + for x in 5..15 { + image.data[y * 20 + x] = Color::WHITE; + } + } + let mut item = Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(20.))); + stamp_effect( + &mut item, + Effect::new(&EffectOperation::Blur { + radius: 1., + box_blur: false, + gamma: false, + }), + ); + let result = evaluate_raster_effects(super::make_opaque((), item)); + + // Just outside the square, the blur's faint white becomes opaque white, where making it opaque first would have blurred in black + let transform: DAffine2 = result.attribute_cloned_or_default(ATTR_TRANSFORM); + let raster = result.element(); + let pixel = (DAffine2::from_scale(DVec2::new(raster.width as f64, raster.height as f64)) * transform.inverse()).transform_point2(DVec2::new(4.5, 10.5)); + let color = raster.data[pixel.y as usize * raster.width as usize + pixel.x as usize]; + assert_eq!(color.a(), 1.); + assert!(color.r() > 0.99 && color.g() > 0.99 && color.b() > 0.99, "expected white, got {color:?}"); + } } diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index eb3ad45ed5a..1faf2f5c6d6 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -1,68 +1,86 @@ -use crate::adjust::Adjust; -#[cfg(feature = "std")] +use core::cmp::Ordering; use core_types::list::Item; +use glam::{DAffine2, DVec2}; use no_std_types::Ctx; use no_std_types::blending::BlendMode; use no_std_types::color::{Color, Pixel}; -#[cfg(not(feature = "std"))] -use no_std_types::list::ShaderItem as Item; -#[cfg(feature = "std")] -use raster_types::{CPU, Raster}; -#[cfg(feature = "std")] +use raster_types::parallel_rows::fill_rows; +use raster_types::{CPU, Image, Raster}; use vector_types::{Gradient, GradientStop}; -pub trait Blend { - fn blend(&self, under: &Self, blend_fn: impl Fn(P, P) -> P) -> Self; +pub trait Blend: Sized { + /// Blends `self` over `under`, each placed by its transform where placement matters. + fn blend(&self, transform: DAffine2, under: &Self, under_transform: DAffine2, blend_fn: impl Fn(P, P) -> P + Sync) -> Self; + + /// The item with any effects it carries carried out into its content, so blending sees what it looks like. + fn with_effects_applied(item: Item) -> Item; } impl Blend for Color { - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { + fn with_effects_applied(item: Item) -> Item { + graphic_types::evaluate_color_effects(item) + } + + fn blend(&self, _transform: DAffine2, under: &Self, _under_transform: DAffine2, blend_fn: impl Fn(Color, Color) -> Color + Sync) -> Self { blend_fn(*self, *under) } } -#[cfg(feature = "std")] -mod blend_std { - use super::*; - use core::cmp::Ordering; - use raster_types::Image; - use raster_types::Raster; +impl Blend for Raster { + fn with_effects_applied(item: Item) -> Item { + graphic_types::evaluate_raster_effects(item) + } - impl Blend for Raster { - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - let data = self.data.iter().zip(under.data.iter()).map(|(a, b)| blend_fn(*a, *b)).collect(); + /// Each pixel blends with what lies beneath its center in `under`, so the two images needn't share a size or placement. O(width × height). + fn blend(&self, transform: DAffine2, under: &Self, under_transform: DAffine2, blend_fn: impl Fn(Color, Color) -> Color + Sync) -> Self { + // Maps this image's pixel coordinates onto the under image's, through the unit square each transform places + let pixels_to_unit = |image: &Image| DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); + let under_placement = (under_transform.matrix2.determinant() != 0.).then(|| pixels_to_unit(under).inverse() * under_transform.inverse() * transform * pixels_to_unit(self)); + + let mut data = vec![Color::TRANSPARENT; self.data.len()]; + fill_rows(&mut data, self.width as usize, |y, row| { + for (x, pixel) in row.iter_mut().enumerate() { + let over = self.data[y * self.width as usize + x]; + let center = DVec2::new(x as f64 + 0.5, y as f64 + 0.5); + let beneath = under_placement.map_or(Color::TRANSPARENT, |placement| under.sample_bilinear(placement.transform_point2(center))); + *pixel = blend_fn(over, beneath); + } + }); + + Raster::new_cpu(Image { + data, + width: self.width, + height: self.height, + }) + } +} - Raster::new_cpu(Image { - data, - width: self.width, - height: self.height, - base64_string: None, - }) - } +impl Blend for Gradient { + fn with_effects_applied(item: Item) -> Item { + graphic_types::evaluate_gradient_effects(item) } - impl Blend for Gradient { - // TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away; - // TODO: it evaluates both sources with default whole-ramp attributes rather than their own (which this element-level impl cannot read); - // TODO: and the output keeps over's attributes despite being sampled with defaults - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - let mut combined_stops = self.positions(false).into_iter().chain(under.positions(false)).collect::>(); - combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); - combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let over_evaluator = self.evaluator(Default::default()); - let under_evaluator = under.evaluator(Default::default()); - let stops = combined_stops.into_iter().map(|position| { - let color = blend_fn(over_evaluator.evaluate(position), under_evaluator.evaluate(position)); - GradientStop { position, midpoint: 0.5, color } - }); - - // Positions stay explicit because eliding them needs the cyclic flag this impl can't read, and a wrong guess would relocate the stops - Gradient::new(stops) - } + + // TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away; + // TODO: it evaluates both sources with default whole-ramp attributes rather than their own (which this element-level impl cannot read); + // TODO: and the output keeps over's attributes despite being sampled with defaults + fn blend(&self, _transform: DAffine2, under: &Self, _under_transform: DAffine2, blend_fn: impl Fn(Color, Color) -> Color + Sync) -> Self { + let mut combined_stops = self.positions(false).into_iter().chain(under.positions(false)).collect::>(); + combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let over_evaluator = self.evaluator(Default::default()); + let under_evaluator = under.evaluator(Default::default()); + let stops = combined_stops.into_iter().map(|position| { + let color = blend_fn(over_evaluator.evaluate(position), under_evaluator.evaluate(position)); + GradientStop { position, midpoint: 0.5, color } + }); + + // Positions stay explicit because eliding them needs the cyclic flag this impl can't read, and a wrong guess would relocate the stops + Gradient::new(stops) } } pub use no_std_types::blending::{apply_blend_mode, blend_colors}; -#[node_macro::node(category("Raster"), icon("NodeMix"), cfg(feature = "std"))] +#[node_macro::node(category("Raster"), icon("NodeMix"), memoize)] fn mix + Send>( _: impl Ctx, #[implementations( @@ -70,7 +88,6 @@ fn mix + Send>( Color, Gradient, )] - #[gpu_image] over: Item, #[expose] #[implementations( @@ -78,7 +95,6 @@ fn mix + Send>( Color, Gradient, )] - #[gpu_image] under: Item, blend_mode: Item, #[unit("%")] @@ -87,77 +103,42 @@ fn mix + Send>( #[default(100.)] opacity: Item, ) -> Item { - let mut over = over; + let mut over = T::with_effects_applied(over); + let under = T::with_effects_applied(under); let blend_mode = blend_mode.into_element(); let opacity = (opacity.into_element() / 100.) as f32; - let blended = over.element().blend(under.element(), |a, b| blend_colors(a, b, blend_mode, opacity)); + let over_transform: DAffine2 = over.attribute_cloned_or_default(core_types::ATTR_TRANSFORM); + let under_transform: DAffine2 = under.attribute_cloned_or_default(core_types::ATTR_TRANSFORM); + let blended = over.element().blend(over_transform, under.element(), under_transform, |a, b| blend_colors(a, b, blend_mode, opacity)); *over.element_mut() = blended; over } -// 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"), shader_node(PerPixelAdjust))] -fn color_overlay>( - _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] - #[gpu_image] - image: Item, - #[default(Color::BLACK)] color: Item, - blend_mode: Item, - #[unit("%")] - #[range] - #[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; - - image.element_mut().adjust(|pixel| { - let overlay = apply_blend_mode(color, *pixel, blend_mode); - let mix = |image: f32, overlay: f32| image + (overlay - image) * opacity; - - Color::from_rgbaf32_unchecked(mix(pixel.r(), overlay.r()), mix(pixel.g(), overlay.g()), mix(pixel.b(), overlay.b()), pixel.a()) - }); - image -} - -#[cfg(all(feature = "std", test))] +#[cfg(test)] mod test { - use core_types::blending::BlendMode; use core_types::color::Color; - use core_types::list::Item; use raster_types::Image; use raster_types::Raster; #[test] - fn color_overlay_multiply() { - let image_color = Color::from_rgbaf32_unchecked(0.7, 0.6, 0.5, 0.4); - let image = Image::new(1, 1, image_color); - - // Color { red: 0., green: 1., blue: 0., alpha: 1. } - let overlay_color = Color::GREEN; - - // 100% of the output should come from the multiplied value - let opacity = 100.; - - let result = super::color_overlay( - (), - Item::new_from_element(Raster::new_cpu(image.clone())), - overlay_color.into(), - BlendMode::Multiply.into(), - opacity.into(), - ); - let result = result.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())); + fn mix_places_each_image_by_its_transform() { + use super::Blend; + use glam::{DAffine2, DVec2}; + + // A 2x2 image over a 2-unit square, and a 1x1 red image covering only its bottom right unit + let over = Raster::new_cpu(Image::new(2, 2, Color::WHITE)); + let under = Raster::new_cpu(Image::new(1, 1, Color::RED)); + let over_transform = DAffine2::from_scale(DVec2::splat(2.)); + let under_transform = DAffine2::from_translation(DVec2::ONE); + + // Reports what lies beneath each pixel + let beneath = over.blend(over_transform, &under, under_transform, |_, under| under); + + assert_eq!((beneath.width, beneath.height), (2, 2)); + assert_eq!(beneath.data[3], Color::RED, "the bottom right pixel's center lies within the under image"); + for pixel in &beneath.data[..3] { + assert_eq!(*pixel, Color::TRANSPARENT, "the other pixels lie outside it"); + } } } diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 7b79b096891..4c5e3b2b97f 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -1,12 +1,13 @@ use core_types::context::Ctx; use core_types::list::Item; +use graphic_types::evaluate_raster_effects; 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 std::cmp::{max, min}; -#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"))] +#[node_macro::node(category("Raster: Filter"), icon("NodeDehaze"), memoize)] fn dehaze( _: impl Ctx, image_frame: Item>, @@ -17,7 +18,7 @@ fn dehaze( ) -> Item> { let strength = *strength.element(); - let (image, attributes) = image_frame.into_parts(); + let (image, attributes) = evaluate_raster_effects(image_frame).into_parts(); let (width, height) = (image.width, image.height); // Prepare the image data for processing @@ -31,12 +32,7 @@ fn dehaze( // Prepare the image data for returning let buffer = dehazed_dynamic_image.to_rgba32f().into_raw(); let color_vec = bytemuck::cast_vec(buffer); - let dehazed_image = Image { - width, - height, - data: color_vec, - base64_string: None, - }; + let dehazed_image = Image { width, height, data: color_vec }; Item::from_parts(Raster::new_cpu(dehazed_image), attributes) } @@ -78,29 +74,16 @@ fn compute_dark_channel(image: &DynamicImage) -> DynamicImage { } } - let mut eroded_channel = RgbaImage::new(width, height); - - for y in 0..height { - for x in 0..width { - let mut local_min = u8::MAX; - - for dy in 0..PATCH_SIZE { - for dx in 0..PATCH_SIZE { - let nx = x as i32 + dx as i32 - half_patch as i32; - let ny = y as i32 + dy as i32 - half_patch as i32; - - if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { - let intensity = dark_channel.get_pixel(nx as u32, ny as u32)[0]; - if intensity < local_min { - local_min = intensity; - } - } - } - } - let alpha = image.get_pixel(x, y)[3]; - eroded_channel.put_pixel(x, y, Rgba([local_min, local_min, local_min, alpha])); - } - } + // The minimum over each square patch is the minimum along its columns of the minimum along its rows, so two passes do it in O(width × height × patch size) + let row_minimums = GrayImage::from_fn(width, height, |x, y| { + let (start, end) = (x.saturating_sub(half_patch), (x + half_patch).min(width - 1)); + Luma([(start..=end).map(|nx| dark_channel.get_pixel(nx, y)[0]).min().unwrap_or(u8::MAX)]) + }); + let eroded_channel = RgbaImage::from_fn(width, height, |x, y| { + let (start, end) = (y.saturating_sub(half_patch), (y + half_patch).min(height - 1)); + let local_min = (start..=end).map(|ny| row_minimums.get_pixel(x, ny)[0]).min().unwrap_or(u8::MAX); + Rgba([local_min, local_min, local_min, image.get_pixel(x, y)[3]]) + }); DynamicImage::ImageRgba8(eroded_channel) } @@ -269,3 +252,36 @@ fn ndarray_to_image(array: &Array2, width: u32, height: u32) -> DynamicImag } img } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dark_channel_is_the_minimum_over_each_patch() { + // Varied channels, with patches clipped where they overhang the edges + let (width, height) = (37, 23); + let image = DynamicImage::ImageRgba8(RgbaImage::from_fn(width, height, |x, y| { + let value = ((x * 97 + y * 57) % 251) as u8; + Rgba([value, value.wrapping_mul(3), value.wrapping_add(40), 200]) + })); + + let dark_channel = compute_dark_channel(&image); + + let half_patch = PATCH_SIZE / 2; + for y in 0..height { + for x in 0..width { + let mut expected = u8::MAX; + for ny in y.saturating_sub(half_patch)..=(y + half_patch).min(height - 1) { + for nx in x.saturating_sub(half_patch)..=(x + half_patch).min(width - 1) { + let pixel = image.get_pixel(nx, ny); + expected = expected.min(pixel[0].min(pixel[1]).min(pixel[2])); + } + } + + let pixel = dark_channel.get_pixel(x, y); + assert_eq!((pixel[0], pixel[3]), (expected, 200), "at ({x}, {y})"); + } + } + } +} diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index 91d0cafe04a..7ebce200c32 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -1,126 +1,14 @@ -use bytemuck::{Pod, Zeroable}; -use core_types::color::{Alpha, Color, Pixel, RGB}; +use core_types::color::Color; use core_types::context::Ctx; use core_types::list::Item; +use graphic_types::evaluate_raster_effects; +use raster_types::Bitmap; use raster_types::Image; -use raster_types::{Bitmap, BitmapMut}; +use raster_types::parallel_rows::fill_rows; use raster_types::{CPU, Raster}; -/// 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). -#[repr(C)] -#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)] -struct PremultipliedGammaPixel { - r: f32, - g: f32, - b: f32, - a: f32, -} - -impl Pixel for PremultipliedGammaPixel {} - -impl RGB for PremultipliedGammaPixel { - type ColorChannel = f32; - fn red(&self) -> f32 { - self.r - } - fn green(&self) -> f32 { - self.g - } - fn blue(&self) -> f32 { - self.b - } -} - -impl Alpha for PremultipliedGammaPixel { - type AlphaChannel = f32; - const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. }; - fn alpha(&self) -> f32 { - self.a - } - fn multiplied_alpha(&self, mult: f32) -> Self { - Self { - r: self.r * mult, - g: self.g * mult, - b: self.b * mult, - a: self.a * mult, - } - } -} - -fn premultiply_gamma(buffer: Image) -> Image { - Image { - width: buffer.width, - height: buffer.height, - data: buffer - .data - .into_iter() - .map(|px| { - let [r, g, b, a] = px.to_gamma_srgb_channels(); - PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a } - }) - .collect(), - base64_string: None, - } -} - -fn unpremultiply_gamma_to_linear(buffer: Image) -> Image { - Image { - width: buffer.width, - height: buffer.height, - data: buffer - .data - .into_iter() - .map(|px| { - if px.a > 0. { - let inv_a = 1. / px.a; - Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a) - } else { - Color::TRANSPARENT - } - }) - .collect(), - base64_string: None, - } -} - -/// Blurs the image with a Gaussian or box blur kernel filter. -#[node_macro::node(category("Raster: Filter"), icon("NodeBlur"))] -fn blur( - _: impl Ctx, - /// The image to be blurred. - image_frame: Item>, - /// The radius of the blur kernel. - #[unit(" px")] - #[range] - #[hard(0..)] - #[soft(..100)] - radius: Item, - /// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts. - 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> { - let (radius, box_blur, gamma) = (*radius.element(), *box_blur.element(), *gamma.element()); - - 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 - // window that varies continuously rather than snapping to whole source pixels - let blurred_image = if radius < 0.1 { - // Minimum blur radius - image - } else if box_blur { - Raster::new_cpu(box_blur_algorithm(image.into_data(), radius, gamma)) - } else { - Raster::new_cpu(gaussian_blur_algorithm(image.into_data(), radius, gamma)) - }; - - Item::from_parts(blurred_image, attributes) -} - /// Applies a median filter to reduce noise while preserving edges. -#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"))] +#[node_macro::node(category("Raster: Filter"), icon("NodeMedianFilter"), memoize)] fn median_filter( _: impl Ctx, /// The image to be filtered. @@ -134,7 +22,7 @@ fn median_filter( ) -> Item> { let radius = *radius.element(); - let (image, attributes) = image_frame.into_parts(); + let (image, attributes) = evaluate_raster_effects(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 // window that varies continuously rather than snapping to whole source pixels @@ -148,165 +36,22 @@ fn median_filter( Item::from_parts(filtered_image, attributes) } -// 1D gaussian kernel -fn gaussian_kernel(radius: f64) -> Vec { - // Given radius, compute the size of the kernel that's approximately three times the radius - let kernel_radius = (3. * radius).ceil() as usize; - let kernel_size = 2 * kernel_radius + 1; - let mut gaussian_kernel: Vec = vec![0.; kernel_size]; - - // Kernel values - let two_radius_squared = 2. * radius * radius; - let sum = gaussian_kernel - .iter_mut() - .enumerate() - .map(|(i, value_at_index)| { - let x = i as f64 - kernel_radius as f64; - let exponent = -(x * x) / two_radius_squared; - *value_at_index = exponent.exp(); - *value_at_index - }) - .sum::(); - - // Normalize - gaussian_kernel.iter_mut().for_each(|value_at_index| *value_at_index /= sum); - - gaussian_kernel -} - -fn gaussian_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image { - let kernel = gaussian_kernel(radius); - if gamma { - let working = premultiply_gamma(buffer); - let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); - unpremultiply_gamma_to_linear(blurred) - } else { - let mut working = buffer; - working.map_pixels(|px| px.to_associated_alpha()); - let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked); - blurred.map_pixels(|px| px.to_unassociated_alpha()); - blurred - } -} - -fn box_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image { - if gamma { - let working = premultiply_gamma(buffer); - let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); - unpremultiply_gamma_to_linear(blurred) - } else { - let mut working = buffer; - working.map_pixels(|px| px.to_associated_alpha()); - let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked); - blurred.map_pixels(|px| px.to_unassociated_alpha()); - blurred - } -} - -fn gaussian_separable(buffer: Image

, kernel: &[f64], construct: F) -> Image

-where - P: Pixel + Copy + RGB + Alpha, - F: Fn(f32, f32, f32, f32) -> P, -{ - let (width, height) = buffer.dimensions(); - let half_kernel = kernel.len() / 2; - - let mut x_axis = Image::new(width, height, P::default()); - let mut y_axis = Image::new(width, height, P::default()); - - for pass in [false, true] { - let (max, old_buffer, current_buffer) = match pass { - false => (width, &buffer, &mut x_axis), - true => (height, &x_axis, &mut y_axis), - }; - let pass = pass as usize; - - for y in 0..height { - for x in 0..width { - let (mut r_sum, mut g_sum, mut b_sum, mut a_sum, mut weight_sum) = (0., 0., 0., 0., 0.); - - for (i, &weight) in kernel.iter().enumerate() { - let p = [x, y][pass] as i32 + (i as i32 - half_kernel as i32); - - if p >= 0 - && p < max as i32 && let Some(px) = old_buffer.get_pixel([p as u32, x][pass], [y, p as u32][pass]) - { - r_sum += px.r() as f64 * weight; - g_sum += px.g() as f64 * weight; - b_sum += px.b() as f64 * weight; - a_sum += px.a() as f64 * weight; - weight_sum += weight; - } - } - - let (r, g, b, a) = if weight_sum > 0. { - ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32) - } else { - let px = old_buffer.get_pixel(x, y).unwrap(); - (px.r(), px.g(), px.b(), px.a()) - }; - current_buffer.set_pixel(x, y, construct(r, g, b, a)); - } - } - } - - y_axis -} - -fn box_separable(buffer: Image

, radius: f64, construct: F) -> Image

-where - P: Pixel + Copy + RGB + Alpha, - F: Fn(f32, f32, f32, f32) -> P, -{ - let (width, height) = buffer.dimensions(); - let mut x_axis = Image::new(width, height, P::default()); - let mut y_axis = Image::new(width, height, P::default()); - - for pass in [false, true] { - let (max, old_buffer, current_buffer) = match pass { - false => (width, &buffer, &mut x_axis), - true => (height, &x_axis, &mut y_axis), - }; - let pass = pass as usize; - - for y in 0..height { - for x in 0..width { - let (mut r_sum, mut g_sum, mut b_sum, mut a_sum, mut weight_sum) = (0., 0., 0., 0., 0.); - - let i = [x, y][pass]; - for d in (i as i32 - radius as i32).max(0)..=(i as i32 + radius as i32).min(max as i32 - 1) { - if let Some(px) = old_buffer.get_pixel([d as u32, x][pass], [y, d as u32][pass]) { - let weight = 1.; - r_sum += px.r() as f64 * weight; - g_sum += px.g() as f64 * weight; - b_sum += px.b() as f64 * weight; - a_sum += px.a() as f64 * weight; - weight_sum += weight; - } - } - - let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32); - current_buffer.set_pixel(x, y, construct(r, g, b, a)); - } - } - } - - y_axis -} - 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); - - // Pre-allocate and reuse buffers outside the loops to avoid repeated allocations. let window_capacity = ((2 * radius + 1).pow(2)) as usize; - let mut r_vals: Vec = Vec::with_capacity(window_capacity); - let mut g_vals: Vec = Vec::with_capacity(window_capacity); - let mut b_vals: Vec = Vec::with_capacity(window_capacity); - let mut a_vals: Vec = Vec::with_capacity(window_capacity); - for y in 0..height { - for x in 0..width { + fill_rows(&mut output.data, width as usize, |y, row| { + let y = y as u32; + + // Allocated once for the row and reused for each of its pixels + let mut r_vals: Vec = Vec::with_capacity(window_capacity); + let mut g_vals: Vec = Vec::with_capacity(window_capacity); + let mut b_vals: Vec = Vec::with_capacity(window_capacity); + let mut a_vals: Vec = Vec::with_capacity(window_capacity); + + for (x, output_pixel) in row.iter_mut().enumerate() { + let x = x as u32; r_vals.clear(); g_vals.clear(); b_vals.clear(); @@ -332,9 +77,9 @@ fn median_filter_algorithm(original_buffer: Image, radius: u32) -> Image< let b = median_quickselect(&mut b_vals); let a = median_quickselect(&mut a_vals); - output.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); + *output_pixel = Color::from_rgbaf32_unchecked(r, g, b, a); } - } + }); output } diff --git a/node-graph/nodes/raster/src/fullscreen_vertex.rs b/node-graph/nodes/raster/src/fullscreen_vertex.rs deleted file mode 100644 index 50014248bd4..00000000000 --- a/node-graph/nodes/raster/src/fullscreen_vertex.rs +++ /dev/null @@ -1,29 +0,0 @@ -use glam::{Vec2, Vec4}; -use spirv_std::spirv; - -/// WebGPU NDC is like OpenGL: (-1..1, -1..1, 0..1) -/// https://www.w3.org/TR/webgpu/#coordinate-systems -/// -/// So to make a fullscreen triangle around a box at (-1..1): -/// -/// ```text -/// 3 + -/// |\ -/// 2 | \ -/// | \ -/// 1 +-----+ -/// | |\ -/// 0 | 0 | \ -/// | | \ -/// -1 +-----+-----+ -/// -1 0 1 2 3 -/// ``` -const FULLSCREEN_VERTICES: [Vec2; 3] = [Vec2::new(-1., -1.), Vec2::new(-1., 3.), Vec2::new(3., -1.)]; - -#[spirv(vertex)] -pub fn fullscreen_vertex(#[spirv(vertex_index)] vertex_index: u32, #[spirv(position)] gl_position: &mut Vec4) { - // broken on edition 2024 branch - // let vertex = unsafe { *FULLSCREEN_VERTICES.index_unchecked(vertex_index as usize) }; - let vertex = FULLSCREEN_VERTICES[vertex_index as usize]; - *gl_position = Vec4::from((vertex, 0., 1.)); -} diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index c636caadc32..b2e520c5405 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -2,6 +2,7 @@ use color::{ColorSpace, Oklab}; use core_types::color::Color; use core_types::context::Ctx; use core_types::list::{Item, List}; +use graphic_types::evaluate_raster_effects; use raster_types::{CPU, Raster}; use std::collections::BinaryHeap; use std::ops::{AddAssign, SubAssign}; @@ -14,7 +15,7 @@ const TABLE_SIDE: usize = CELLS_PER_AXIS + 1; const OKLAB_GAMUT_RANGES: [(f32, f32); 3] = [(0., 1.), (-0.24, 0.28), (-0.32, 0.2)]; /// Finds the colors that best represent the distinct color ranges in an image, ignoring transparent areas. -#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), footprint_invariant)] +#[node_macro::node(category("Color"), icon("NodeImageColorPalette"), memoize, footprint_invariant)] fn image_color_palette( _: impl Ctx, /// The image to find colors in. @@ -25,6 +26,7 @@ fn image_color_palette( count: Item, ) -> List { let count = *count.element() as usize; + let image = evaluate_raster_effects(image); // 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)]; @@ -233,7 +235,6 @@ mod test { width: data.len() as u32, height: 1, data, - base64_string: None, }); let palette = image_color_palette((), Item::new_from_element(image), Item::new_from_element(count)); diff --git a/node-graph/nodes/raster/src/lib.rs b/node-graph/nodes/raster/src/lib.rs index 5d541d37705..18d19485f4d 100644 --- a/node-graph/nodes/raster/src/lib.rs +++ b/node-graph/nodes/raster/src/lib.rs @@ -1,21 +1,7 @@ -#![cfg_attr(not(feature = "std"), no_std)] - -pub mod adjust; pub mod adjustments; pub mod blending_nodes; -pub mod fullscreen_vertex; - -/// required by shader macro -#[cfg(feature = "shader-nodes")] -pub use raster_nodes_shaders::WGSL_SHADER; - -#[cfg(feature = "std")] -pub mod color_lookup_table; -#[cfg(feature = "std")] +pub use graphic_types::color_lookup_table; pub mod dehaze; -#[cfg(feature = "std")] pub mod filter; -#[cfg(feature = "std")] pub mod image_color_palette; -#[cfg(feature = "std")] pub mod std_nodes; diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 8fd6d3df2d6..b92e91376aa 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -1,18 +1,18 @@ use crate::adjustments::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, NoiseType}; 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::color::{Alpha, AlphaMut, LinearChannel, Luminance, RGBMut}; +use core_types::context::Ctx; use core_types::list::Item; use core_types::math::bbox::Bbox; use core_types::transform::Transform; use dyn_any::DynAny; -use fastnoise_lite; -use glam::{DAffine2, DVec2, Vec2}; +use glam::{DAffine2, DVec2}; use graphene_resource::Resource; -use rand::prelude::*; -use rand_chacha::ChaCha8Rng; +use graphic_types::evaluate_raster_effects; +use graphic_types::generator::{Generator, NoiseSettings}; use raster_types::Image; +use raster_types::parallel_rows::fill_rows; use raster_types::{Bitmap, BitmapMut}; use raster_types::{CPU, Raster}; use std::fmt::Debug; @@ -30,7 +30,7 @@ impl From for Error { } } -#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"))] +#[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"), memoize)] fn channels_to_image( _: impl Ctx, _primary: (), @@ -41,51 +41,68 @@ fn channels_to_image( ) -> Item> { // 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)); - - // Take this item's transform and blending attributes from the first present channel - let Some(attributes) = [&red, &green, &blue, &alpha].iter().find_map(|channel| channel.as_ref()).map(|channel| channel.attributes().clone()) else { - return Item::default(); - }; + let channels = [red, green, blue, alpha].map(present); // All present channels must share the same dimensions; a mismatch yields the default zero-sized raster - let channel_dimensions = [&red, &green, &blue, &alpha].map(|channel| channel.as_ref().map(|channel| (channel.element().width, channel.element().height))); - let Some(&(width, height)) = channel_dimensions.iter().flatten().next() else { + let Some((width, height, transform)) = channels + .iter() + .flatten() + .next() + .map(|channel| (channel.element().width, channel.element().height, channel.attribute_cloned_or_default::(ATTR_TRANSFORM))) + else { return Item::default(); }; - if channel_dimensions.iter().flatten().any(|&(other_width, other_height)| other_width != width || other_height != height) { + if channels.iter().flatten().any(|channel| channel.element().width != width || channel.element().height != height) { return Item::default(); } + // A channel's effects grow it by the margin they cast past its edges, so every channel is placed within the widest margin + let channels = channels.map(|channel| channel.map(evaluate_raster_effects)); + let margin = |channel: &Item>| ((channel.element().width - width) / 2, (channel.element().height - height) / 2); + let (margin_x, margin_y) = channels + .iter() + .flatten() + .map(margin) + .fold((0, 0), |(x, y), (channel_x, channel_y)| (x.max(channel_x), y.max(channel_y))); + + // Take this item's blending attributes from the first present channel, whose effects are carried out by now + let Some(mut attributes) = channels.iter().flatten().next().map(|channel| channel.attributes().clone()) else { + return Item::default(); + }; + let size = DVec2::new(width as f64, height as f64); + let unit_margin = DVec2::new(margin_x as f64, margin_y as f64) / size; + attributes.insert( + ATTR_TRANSFORM, + transform * DAffine2::from_translation(-unit_margin) * DAffine2::from_scale(DVec2::ONE + 2. * unit_margin), + ); + + // A channel's value at a pixel of the output, with nothing past its own edges, or `None` if the channel is absent + let value = |channel: &Option>>, x: u32, y: u32| { + channel.as_ref().map(|channel| { + let (channel_margin_x, channel_margin_y) = margin(channel); + let (x, y) = ((x + channel_margin_x).checked_sub(margin_x), (y + channel_margin_y).checked_sub(margin_y)); + x.zip(y).and_then(|(x, y)| channel.element().get_pixel(x, y)).map_or(0., |pixel| pixel.l().cast_linear_channel()) + }) + }; + // Set each output pixel's channels from the present inputs, defaulting absent color channels to 0 and absent alpha to 1 - let mut image = Image::new(width, height, Color::TRANSPARENT); + let [red, green, blue, alpha] = &channels; + let mut image = Image::new(width + 2 * margin_x, height + 2 * margin_y, Color::TRANSPARENT); for y in 0..image.height() { for x in 0..image.width() { let image_pixel = image.get_pixel_mut(x, y).unwrap(); - match red.as_ref().and_then(|r| r.element().get_pixel(x, y)) { - Some(r) => image_pixel.set_red(r.l().cast_linear_channel()), - None => image_pixel.set_red(Channel::from_linear(0.)), - } - match green.as_ref().and_then(|g| g.element().get_pixel(x, y)) { - Some(g) => image_pixel.set_green(g.l().cast_linear_channel()), - None => image_pixel.set_green(Channel::from_linear(0.)), - } - match blue.as_ref().and_then(|b| b.element().get_pixel(x, y)) { - Some(b) => image_pixel.set_blue(b.l().cast_linear_channel()), - None => image_pixel.set_blue(Channel::from_linear(0.)), - } - match alpha.as_ref().and_then(|a| a.element().get_pixel(x, y)) { - Some(a) => image_pixel.set_alpha(a.l().cast_linear_channel()), - None => image_pixel.set_alpha(Channel::from_linear(1.)), - } + image_pixel.set_red(value(red, x, y).unwrap_or(0.)); + image_pixel.set_green(value(green, x, y).unwrap_or(0.)); + image_pixel.set_blue(value(blue, x, y).unwrap_or(0.)); + image_pixel.set_alpha(value(alpha, x, y).unwrap_or(1.)); } } Item::from_parts(Raster::new_cpu(image), attributes) } -#[node_macro::node(category("Raster"), icon("NodeMask"))] +#[node_macro::node(category("Raster"), icon("NodeMask"), memoize)] fn mask( _: impl Ctx, /// The image to be masked. @@ -94,6 +111,9 @@ fn mask( #[expose] stencil: Item>, ) -> Item> { + let image = evaluate_raster_effects(image); + let stencil = evaluate_raster_effects(stencil); + // An absent stencil arrives as the default empty raster, leaving the image unmasked if stencil.element().width == 0 || stencil.element().height == 0 { return image; @@ -114,25 +134,27 @@ fn mask( let bg_to_fg = transform_attribute * DAffine2::from_scale(1. / image_size); let stencil_transform_inverse = stencil_transform.inverse(); - for y in 0..row.element().height { - for x in 0..row.element().width { + let width = row.element().width as usize; + let stencil_image = stencil.element(); + fill_rows(&mut row.element_mut().data_mut().data, width, |y, pixels| { + for (x, image_pixel) in pixels.iter_mut().enumerate() { let image_point = DVec2::new(x as f64, y as f64); let mask_point = bg_to_fg.transform_point2(image_point); let local_mask_point = stencil_transform_inverse.transform_point2(mask_point); let mask_point = stencil_transform.transform_point2(local_mask_point.clamp(DVec2::ZERO, DVec2::ONE)); let mask_point = (DAffine2::from_scale(stencil_size) * stencil_transform.inverse()).transform_point2(mask_point); - let image_pixel = row.element_mut().data_mut().get_pixel_mut(x, y).unwrap(); - let mask_pixel = stencil.element().sample(mask_point); + let mask_pixel = stencil_image.sample(mask_point); *image_pixel = image_pixel.multiplied_alpha(mask_pixel.l().cast_linear_channel()); } - } + }); row } #[node_macro::node(category(""))] fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Item> { + let image = evaluate_raster_effects(image); let bounds = *bounds.element(); let image_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); @@ -188,7 +210,7 @@ fn empty_image(transform: DAffine2, color: Color) -> Item> { /// 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. -#[node_macro::node(category("Raster"), icon("NodeImage"))] +#[node_macro::node(category("Raster"), icon("NodeImage"), memoize)] fn image( _: impl Ctx, _primary: (), @@ -206,9 +228,8 @@ fn image( #[node_macro::node(category("Raster"), icon("NodeNoisePattern"))] #[allow(clippy::too_many_arguments)] fn noise_pattern( - ctx: impl ExtractFootprint + Ctx, + _: impl Ctx, _primary: (), - #[default(true)] clip: Item, #[hard(0..)] seed: Item, #[widget(ParsedWidgetOverride::Custom = "noise_inspector_scale")] #[default(10.)] @@ -238,194 +259,66 @@ fn noise_pattern( #[widget(ParsedWidgetOverride::Custom = "noise_inspector_cellular_jitter")] #[default(1.)] cellular_jitter: Item, -) -> Item> { - let (clip, seed, scale, domain_warp_amplitude) = (*clip.element(), *seed.element(), *scale.element(), *domain_warp_amplitude.element()); - let (fractal_octaves, fractal_lacunarity, fractal_gain) = (*fractal_octaves.element(), *fractal_lacunarity.element(), *fractal_gain.element()); - let (fractal_weighted_strength, fractal_ping_pong_strength, cellular_jitter) = (*fractal_weighted_strength.element(), *fractal_ping_pong_strength.element(), *cellular_jitter.element()); - let (noise_type, domain_warp_type, fractal_type) = (noise_type.into_element(), domain_warp_type.into_element(), fractal_type.into_element()); - let (cellular_distance_function, cellular_return_type) = (cellular_distance_function.into_element(), cellular_return_type.into_element()); - - let footprint = ctx.footprint(); - let viewport_bounds = footprint.viewport_bounds_in_local_space(); - - let mut size = viewport_bounds.size(); - let mut offset = viewport_bounds.start; - if clip { - // TODO: Remove "clip" entirely (and its arbitrary 100x100 clipping square) once we have proper resolution-aware layer clipping - const CLIPPING_SQUARE_SIZE: f64 = 100.; - let image_bounds = Bbox::from_transform(DAffine2::from_scale(DVec2::splat(CLIPPING_SQUARE_SIZE))).to_axis_aligned_bbox(); - let intersection = viewport_bounds.intersect(&image_bounds); - - offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); - size = intersection.size(); - } - - // If the image would not be visible, return an empty image - if size.x <= 0. || size.y <= 0. { - return Item::default(); - } - - let transform = DAffine2::from_translation(offset) * DAffine2::from_scale(size); - - let footprint_scale = footprint.scale(); - let width = (size.x * footprint_scale.x) as u32; - let height = (size.y * footprint_scale.y) as u32; - - // All - let mut image = Image::new(width, height, Color::from_luminance(0.5)); - let mut noise = fastnoise_lite::FastNoiseLite::with_seed(seed as i32); - noise.set_frequency(Some(1. / (scale as f32).max(f32::EPSILON))); - - // Domain Warp - let domain_warp_type = match domain_warp_type { - DomainWarpType::None => None, - DomainWarpType::OpenSimplex2 => Some(fastnoise_lite::DomainWarpType::OpenSimplex2), - DomainWarpType::OpenSimplex2Reduced => Some(fastnoise_lite::DomainWarpType::OpenSimplex2Reduced), - DomainWarpType::BasicGrid => Some(fastnoise_lite::DomainWarpType::BasicGrid), - }; - let domain_warp_active = domain_warp_type.is_some(); - noise.set_domain_warp_type(domain_warp_type); - noise.set_domain_warp_amp(Some(domain_warp_amplitude as f32)); - - // Fractal - let noise_type = match noise_type { - NoiseType::Perlin => fastnoise_lite::NoiseType::Perlin, - NoiseType::OpenSimplex2 => fastnoise_lite::NoiseType::OpenSimplex2, - NoiseType::OpenSimplex2S => fastnoise_lite::NoiseType::OpenSimplex2S, - NoiseType::Cellular => fastnoise_lite::NoiseType::Cellular, - NoiseType::ValueCubic => fastnoise_lite::NoiseType::ValueCubic, - NoiseType::Value => fastnoise_lite::NoiseType::Value, - NoiseType::WhiteNoise => { - // TODO: Generate in layer space, not viewport space - - let mut rng = ChaCha8Rng::seed_from_u64(seed as u64); - - for y in 0..height { - for x in 0..width { - let pixel = image.get_pixel_mut(x, y).unwrap(); - let luminance = rng.random_range(0.0..1.) as f32; - *pixel = Color::from_luminance(luminance); - } - } - - return Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform); - } +) -> Item { + let settings = NoiseSettings { + seed: seed.into_element(), + scale: scale.into_element(), + noise_type: noise_type.into_element(), + domain_warp_type: domain_warp_type.into_element(), + domain_warp_amplitude: domain_warp_amplitude.into_element(), + fractal_type: fractal_type.into_element(), + fractal_octaves: fractal_octaves.into_element(), + fractal_lacunarity: fractal_lacunarity.into_element(), + fractal_gain: fractal_gain.into_element(), + fractal_weighted_strength: fractal_weighted_strength.into_element(), + fractal_ping_pong_strength: fractal_ping_pong_strength.into_element(), + cellular_distance_function: cellular_distance_function.into_element(), + cellular_return_type: cellular_return_type.into_element(), + cellular_jitter: cellular_jitter.into_element(), }; - noise.set_noise_type(Some(noise_type)); - let fractal_type = match fractal_type { - FractalType::None => fastnoise_lite::FractalType::None, - FractalType::FBm => fastnoise_lite::FractalType::FBm, - FractalType::Ridged => fastnoise_lite::FractalType::Ridged, - FractalType::PingPong => fastnoise_lite::FractalType::PingPong, - FractalType::DomainWarpProgressive => fastnoise_lite::FractalType::DomainWarpProgressive, - FractalType::DomainWarpIndependent => fastnoise_lite::FractalType::DomainWarpIndependent, - }; - noise.set_fractal_type(Some(fractal_type)); - noise.set_fractal_octaves(Some(fractal_octaves as i32)); - noise.set_fractal_lacunarity(Some(fractal_lacunarity as f32)); - noise.set_fractal_gain(Some(fractal_gain as f32)); - noise.set_fractal_weighted_strength(Some(fractal_weighted_strength as f32)); - noise.set_fractal_ping_pong_strength(Some(fractal_ping_pong_strength as f32)); - - // Cellular - let cellular_distance_function = match cellular_distance_function { - CellularDistanceFunction::Euclidean => fastnoise_lite::CellularDistanceFunction::Euclidean, - CellularDistanceFunction::EuclideanSq => fastnoise_lite::CellularDistanceFunction::EuclideanSq, - CellularDistanceFunction::Manhattan => fastnoise_lite::CellularDistanceFunction::Manhattan, - CellularDistanceFunction::Hybrid => fastnoise_lite::CellularDistanceFunction::Hybrid, - }; - let cellular_return_type = match cellular_return_type { - CellularReturnType::CellValue => fastnoise_lite::CellularReturnType::CellValue, - CellularReturnType::Nearest => fastnoise_lite::CellularReturnType::Distance, - CellularReturnType::NextNearest => fastnoise_lite::CellularReturnType::Distance2, - CellularReturnType::Average => fastnoise_lite::CellularReturnType::Distance2Add, - CellularReturnType::Difference => fastnoise_lite::CellularReturnType::Distance2Sub, - CellularReturnType::Product => fastnoise_lite::CellularReturnType::Distance2Mul, - CellularReturnType::Division => fastnoise_lite::CellularReturnType::Distance2Div, - }; - noise.set_cellular_distance_function(Some(cellular_distance_function)); - noise.set_cellular_return_type(Some(cellular_return_type)); - noise.set_cellular_jitter(Some(cellular_jitter as f32)); - - let coordinate_offset = offset.as_vec2(); - let scale = size.as_vec2() / Vec2::new(width as f32, height as f32); - // Calculate the noise for every pixel - for y in 0..height { - for x in 0..width { - let pixel = image.get_pixel_mut(x, y).unwrap(); - let pos = Vec2::new(x as f32, y as f32); - let vec = pos * scale + coordinate_offset; - - let (mut x, mut y) = (vec.x, vec.y); - if domain_warp_active && domain_warp_amplitude > 0. { - (x, y) = noise.domain_warp_2d(x, y); - } - - let luminance = (noise.get_noise_2d(x, y) + 1.) * 0.5; - *pixel = Color::from_luminance(luminance); - } - } - Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform) + let mut generator = Item::new_from_element(Generator::Noise); + settings.stamp(&mut generator); + generator } #[node_macro::node(category("Raster"), icon("NodeMandelbrot"))] -fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Item> { - let footprint = ctx.footprint(); - let viewport_bounds = footprint.viewport_bounds_in_local_space(); - - let image_bounds = Bbox::from_transform(DAffine2::IDENTITY).to_axis_aligned_bbox(); - let intersection = viewport_bounds.intersect(&image_bounds); - let size = intersection.size(); - - let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); - - // If the image would not be visible, return an empty image - if size.x <= 0. || size.y <= 0. { - return Item::default(); - } - - let scale = footprint.scale(); - let width = (size.x * scale.x) as u32; - let height = (size.y * scale.y) as u32; - - let mut data = Vec::with_capacity(width as usize * height as usize); - let max_iter = 255; - - let scale = 3. * size.as_vec2() / Vec2::new(width as f32, height as f32); - let coordinate_offset = offset.as_vec2() * 3. - Vec2::new(2., 1.5); - for y in 0..height { - for x in 0..width { - let pos = Vec2::new(x as f32, y as f32); - let c = pos * scale + coordinate_offset; - - let iter = mandelbrot_impl(c, max_iter); - data.push(map_color(iter, max_iter)); - } - } - - Item::new_from_element(Raster::new_cpu(Image { - width, - height, - data, - ..Default::default() - })) - .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(offset) * DAffine2::from_scale(size)) +fn mandelbrot(_: impl Ctx, _primary: ()) -> Item { + Item::new_from_element(Generator::Mandelbrot) } -#[inline(always)] -fn mandelbrot_impl(c: Vec2, max_iter: usize) -> usize { - let mut z = Vec2::new(0., 0.); - for i in 0..max_iter { - z = Vec2::new(z.x * z.x - z.y * z.y, 2. * z.x * z.y) + c; - if z.length_squared() > 4. { - return i; - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channels_stay_aligned_when_effects_grow_only_one() { + use graphic_types::{Effect, EffectOperation, stamp_effect}; + + let gray = |value: f32| { + let image = Image::new(8, 8, Color::from_rgbaf32_unchecked(value, value, value, 1.)); + Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(8.))) + }; + + // Only the red channel is blurred, which grows it by the blur's margin + let mut red = gray(1.); + stamp_effect( + &mut red, + Effect::new(&EffectOperation::Blur { + radius: 1., + box_blur: false, + gamma: false, + }), + ); + let image = channels_to_image((), (), red, gray(0.5), gray(0.25), gray(1.)); + + // The other channels are placed within the same margin, and the whole image keeps its place in the document + let raster = image.element(); + assert!(raster.width > 8 && raster.height > 8, "the image grows to hold the blur, rather than coming out empty"); + let transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); + let to_pixels = DAffine2::from_scale(DVec2::new(raster.width as f64, raster.height as f64)) * transform.inverse(); + let center = to_pixels.transform_point2(DVec2::splat(4.)).as_uvec2(); + let pixel = raster.get_pixel(center.x, center.y).unwrap(); + assert!((pixel.g() - 0.5).abs() < 1e-6 && (pixel.b() - 0.25).abs() < 1e-6, "the unblurred channels line up: {pixel:?}"); } - max_iter -} - -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.) } diff --git a/node-graph/nodes/repeat/src/repeat_nodes.rs b/node-graph/nodes/repeat/src/repeat_nodes.rs index 28cbf822f1c..57ff6295d11 100644 --- a/node-graph/nodes/repeat/src/repeat_nodes.rs +++ b/node-graph/nodes/repeat/src/repeat_nodes.rs @@ -3,6 +3,7 @@ use core::f64::consts::TAU; use core_types::list::{Item, List}; use core_types::{ATTR_TRANSFORM, CloneVarArgs, Color, Ctx, ExtractAll, InjectVarArgs, Interrupt, OwnedContextImpl}; use glam::{DAffine2, DVec2}; +use graphic_types::Generator; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; use vector_types::Gradient; @@ -24,6 +25,7 @@ fn repeat( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] content: impl Node, Output = List>, @@ -69,6 +71,7 @@ fn repeat_array( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] content: impl Node, Output = List>, @@ -130,6 +133,7 @@ fn repeat_radial( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] content: impl Node, Output = List>, @@ -188,6 +192,7 @@ fn repeat_on_points( Context -> List>, Context -> List, Context -> List, + Context -> List, Context -> List, )] content: impl Node, Output = List>, diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index 6c1a5aa8347..b4d9e2bfebe 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -5,7 +5,7 @@ use core_types::transform::{ApplyTransform, ScaleType, Transform}; use core_types::{ATTR_TRANSFORM, CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, Interrupt, ModifyFootprint, OwnedContextImpl}; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::raster_types::{CPU, GPU, Raster}; -use graphic_types::{Artboard, Graphic, Vector}; +use graphic_types::{Artboard, Generator, Graphic, Vector}; use vector_types::Gradient; /// Applies the specified transform to the input content. @@ -22,6 +22,7 @@ fn transform( Context -> Item>, Context -> Item, Context -> Item, + Context -> Item, )] content: impl Node, Output = Item>, #[widget(ParsedWidgetOverride::Custom = "transform_translation")] translation: Item, @@ -156,7 +157,7 @@ fn replace_transform( /// Obtains the transform of the input content. #[node_macro::node(category("Transform"), icon("NodeExtractTransform"), path(core_types::vector))] -fn extract_transform(_: impl Ctx, #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, String, Artboard)] content: Item) -> Item { +fn extract_transform(_: impl Ctx, #[implementations(Graphic, Vector, Raster, Raster, Color, Gradient, Generator, String, Artboard)] content: Item) -> Item { Item::new_from_element(content.attribute_cloned_or_default(ATTR_TRANSFORM)) } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 7f4cff14f6c..dd6e2c1e9ca 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -15,7 +15,7 @@ use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Vector; use graphic_types::graphic::{bake_paint_transforms, is_paint_present}; use graphic_types::raster_types::{CPU, GPU, Raster}; -use graphic_types::{Appearance, Cover, CoverPlacement, Coverage, Graphic, IntoGraphicList, stamp_coverage}; +use graphic_types::{Appearance, Cover, CoverPlacement, Coverage, Generator, Graphic, IntoGraphicList, stamp_coverage}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; use rand::{Rng, SeedableRng}; @@ -299,6 +299,7 @@ where { let (fill, stroke, reverse, randomize) = (fill.into_element(), stroke.into_element(), reverse.into_element(), randomize.into_element()); let (seed, repeat_every) = (seed.into_element(), repeat_every.into_element()); + let gradient = graphic_types::evaluate_gradient_effects(gradient); let mut content = content; let length = content.vector_count(); @@ -529,7 +530,7 @@ fn copy_to_points( points: List, /// Artwork to be copied and placed at each point. #[expose] - #[implementations(List, List, List, List>, List, List)] + #[implementations(List, List, List, List>, List, List, List)] content: List, /// Minimum range of randomized sizes given to each placed copy. #[default(1)]