From dd10b269eea919e6ccb1e639c0cc8ecff16195c1 Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 15 Apr 2026 13:32:19 +0000 Subject: [PATCH 01/16] Revert --- desktop/src/render/state.rs | 2 +- .../export_dialog/export_dialog_message.rs | 1 + .../export_dialog_message_handler.rs | 34 ++++--- .../new_document_dialog_message_handler.rs | 40 +++----- .../document/document_message_handler.rs | 3 +- .../graph_operation_message.rs | 6 -- .../graph_operation_message_handler.rs | 7 -- .../document/graph_operation/utility_types.rs | 1 + .../node_graph/document_node_definitions.rs | 2 +- .../messages/portfolio/portfolio_message.rs | 1 + .../portfolio/portfolio_message_handler.rs | 2 + .../tool/tool_messages/artboard_tool.rs | 2 +- editor/src/node_graph_executor.rs | 19 ++-- editor/src/node_graph_executor/runtime.rs | 1 + .../libraries/application-io/src/lib.rs | 1 + .../libraries/graphic-types/src/artboard.rs | 2 +- .../libraries/rendering/src/renderer.rs | 99 ++----------------- node-graph/libraries/wgpu-executor/src/lib.rs | 11 ++- node-graph/nodes/graphic/src/artboard.rs | 1 - node-graph/nodes/gstd/src/render_node.rs | 89 +++++------------ website/content/learn/interface/menu-bar.md | 2 +- 21 files changed, 97 insertions(+), 229 deletions(-) diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index e9a826f93b..0c8c80c068 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -236,7 +236,7 @@ impl RenderState { return; }; let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height()); - let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), &mut self.overlays_texture)); + let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), None, &mut self.overlays_texture)); if let Err(e) = result { tracing::error!("Error rendering overlays: {:?}", e); return; diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs index 6c6a9364f5..c33d94f368 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs @@ -6,6 +6,7 @@ use crate::messages::prelude::*; pub enum ExportDialogMessage { FileType { file_type: FileType }, ScaleFactor { factor: f64 }, + TransparentBackground { transparent: bool }, ExportBounds { bounds: ExportBounds }, Submit, diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 73b80e23d1..71866cb1fd 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -14,6 +14,7 @@ pub struct ExportDialogMessageHandler { pub file_type: FileType, pub scale_factor: f64, pub bounds: ExportBounds, + pub transparent_background: bool, pub artboards: HashMap, pub has_selection: bool, } @@ -24,6 +25,7 @@ impl Default for ExportDialogMessageHandler { file_type: Default::default(), scale_factor: 1., bounds: Default::default(), + transparent_background: false, artboards: Default::default(), has_selection: false, } @@ -38,17 +40,11 @@ impl MessageHandler> for Exp match message { ExportDialogMessage::FileType { file_type } => self.file_type = file_type, ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor, + ExportDialogMessage::TransparentBackground { transparent } => self.transparent_background = transparent, ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds, ExportDialogMessage::Submit => { - // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected - let bounds = if !self.has_selection && self.bounds == ExportBounds::Selection { - ExportBounds::AllArtwork - } else { - self.bounds - }; - - let artboard_name = match bounds { + let artboard_name = match self.bounds { ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(), _ => None, }; @@ -56,7 +52,8 @@ impl MessageHandler> for Exp name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(), file_type: self.file_type, scale_factor: self.scale_factor, - bounds, + bounds: self.bounds, + transparent_background: self.file_type != FileType::Jpg && self.transparent_background, artboard_name, artboard_count: self.artboards.len(), }) @@ -130,7 +127,6 @@ impl LayoutHolder for ExportDialogMessageHandler { let artboards = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect(); let choices = [standard_bounds, artboards]; - // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected let current_bounds = if !self.has_selection && self.bounds == ExportBounds::Selection { ExportBounds::AllArtwork } else { @@ -163,6 +159,22 @@ impl LayoutHolder for ExportDialogMessageHandler { DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(), ]; - Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)]) + let checkbox_id = CheckboxId::new(); + let transparent_background = vec![ + TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(checkbox_id).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + CheckboxInput::new(self.transparent_background) + .disabled(self.file_type == FileType::Jpg) + .on_update(move |value: &CheckboxInput| ExportDialogMessage::TransparentBackground { transparent: value.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + ]; + + Layout(vec![ + LayoutGroup::row(export_type), + LayoutGroup::row(resolution), + LayoutGroup::row(export_area), + LayoutGroup::row(transparent_background), + ]) } } diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index 9abd4ba5b7..e16a2120f6 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -1,9 +1,7 @@ use crate::messages::layout::utility_types::widget_prelude::*; -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::*; use glam::{IVec2, UVec2}; use graph_craft::document::NodeId; -use graphene_std::Color; /// A dialog to allow users to set some initial options about a new document. #[derive(Debug, Clone, Default, ExtractField)] @@ -24,39 +22,25 @@ impl MessageHandler for NewDocumentDialogMessageHa NewDocumentDialogMessage::Submit => { responses.add(PortfolioMessage::NewDocumentWithName { name: self.name.clone() }); - if self.infinite { - // Infinite canvas: add a locked white background layer - let node_id = NodeId::new(); - responses.add(GraphOperationMessage::NewColorFillLayer { - node_id, - color: Color::WHITE, - parent: LayerNodeIdentifier::ROOT_PARENT, - insert_index: 0, - }); - responses.add(NodeGraphMessage::SetDisplayNameImpl { - node_id, - alias: "Background".to_string(), - }); - responses.add(NodeGraphMessage::SetLocked { node_id, locked: true }); - } else if self.dimensions.x > 0 && self.dimensions.y > 0 { - // Finite canvas: create an artboard with the specified dimensions + let create_artboard = !self.infinite && self.dimensions.x > 0 && self.dimensions.y > 0; + if create_artboard { responses.add(GraphOperationMessage::NewArtboard { id: NodeId::new(), artboard: graphene_std::Artboard::new(IVec2::ZERO, self.dimensions.as_ivec2()), }); responses.add(NavigationMessage::CanvasPan { delta: self.dimensions.as_dvec2() }); - } + responses.add(NodeGraphMessage::RunDocumentGraph); - responses.add(NodeGraphMessage::RunDocumentGraph); - responses.add(ViewportMessage::RepropagateUpdate); + responses.add(ViewportMessage::RepropagateUpdate); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![ - DocumentMessage::ZoomCanvasToFitAll.into(), - DocumentMessage::DeselectAllLayers.into(), - PortfolioMessage::AutoSaveActiveDocument.into(), - ], - }); + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![ + DocumentMessage::ZoomCanvasToFitAll.into(), + DocumentMessage::DeselectAllLayers.into(), + PortfolioMessage::AutoSaveActiveDocument.into(), + ], + }); + } responses.add(DocumentMessage::MarkAsSaved); } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 7c83ae6c9e..e246b97bad 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1398,7 +1398,8 @@ impl MessageHandler> for DocumentMes let node_layer_id = LayerNodeIdentifier::new_unchecked(node_id); let new_artboard_node = document_node_definitions::resolve_network_node_type("Artboard") .expect("Failed to create artboard node") - .default_node_template(); + // Enable clipping by default (input index 5) so imported content is masked to the artboard bounds + .node_template_input_override([None, None, None, None, None, Some(NodeInput::value(TaggedValue::Bool(true), false))]); responses.add(NodeGraphMessage::InsertNode { node_id, node_template: Box::new(new_artboard_node), diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 7c0d8bb8e1..9f2834dcbf 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -97,12 +97,6 @@ pub enum GraphOperationMessage { parent: LayerNodeIdentifier, insert_index: usize, }, - NewColorFillLayer { - node_id: NodeId, - color: Color, - parent: LayerNodeIdentifier, - insert_index: usize, - }, NewVectorLayer { id: NodeId, subpaths: Vec>, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index db6148934c..aa89b49737 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -269,13 +269,6 @@ impl MessageHandler> for responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index }); responses.add(NodeGraphMessage::RunDocumentGraph); } - GraphOperationMessage::NewColorFillLayer { node_id, color, parent, insert_index } => { - let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); - let layer = modify_inputs.create_layer(node_id); - modify_inputs.insert_color_value(color, layer); - network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); - responses.add(NodeGraphMessage::RunDocumentGraph); - } GraphOperationMessage::NewVectorLayer { id, subpaths, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); 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 26903bd89e..9217586576 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -7,6 +7,7 @@ use glam::{DAffine2, IVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; +use graphene_std::Artboard; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; 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 3ee48af260..419855b9d7 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 @@ -380,7 +380,7 @@ fn document_node_definitions() -> HashMap, artboard_count: usize, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 0231640404..f62172360b 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1430,6 +1430,7 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, + transparent_background, artboard_name, artboard_count, } => { @@ -1440,6 +1441,7 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, + transparent_background, artboard_name, artboard_count, ..Default::default() diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index fa06962f00..0df694050e 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -395,7 +395,7 @@ impl Fsm for ArtboardToolFsmState { location: start.min(end).round().as_ivec2(), dimensions: (start.round() - end.round()).abs().as_ivec2(), background: graphene_std::Color::WHITE, - clip: true, + clip: false, }, }) } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 619839ff95..7f3b2ad9ca 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -153,6 +153,7 @@ impl NodeGraphExecutor { pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode: document.render_mode, + hide_artboards: false, for_export: false, for_eyedropper: false, }; @@ -217,6 +218,7 @@ impl NodeGraphExecutor { pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode, + hide_artboards: false, for_export: false, for_eyedropper: true, }; @@ -239,10 +241,10 @@ impl NodeGraphExecutor { graphene_std::application_io::ExportFormat::Raster }; - // Calculate the bounding box of the region to be exported (artboard bounds always contribute) + // Calculate the bounding box of the region to be exported let bounds = match export_config.bounds { - ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(true), - ExportBounds::Selection => document.network_interface.selected_bounds_document_space(true, &[]), + ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(!export_config.transparent_background), + ExportBounds::Selection => document.network_interface.selected_bounds_document_space(!export_config.transparent_background, &[]), ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id), } .ok_or_else(|| "No bounding box".to_string())?; @@ -264,6 +266,7 @@ impl NodeGraphExecutor { pointer: DVec2::ZERO, export_format, render_mode: document.render_mode, + hide_artboards: export_config.transparent_background, for_export: true, for_eyedropper: false, }; @@ -478,7 +481,7 @@ impl NodeGraphExecutor { use image::buffer::ConvertBuffer; use image::{ImageFormat, RgbImage, RgbaImage}; - let Some(mut image) = RgbaImage::from_raw(width, height, data) else { + let Some(image) = RgbaImage::from_raw(width, height, data) else { return Err("Failed to create image buffer for export".to_string()); }; @@ -493,14 +496,6 @@ impl NodeGraphExecutor { } } FileType::Jpg => { - // Composite onto a white background since JPG doesn't support transparency - for pixel in image.pixels_mut() { - let [r, g, b, a] = pixel.0; - let alpha = a as f32 / 255.; - let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8; - *pixel = image::Rgba([blend(r), blend(g), blend(b), 255]); - } - let image: RgbImage = image.convert(); let result = image.write_to(&mut cursor, ImageFormat::Jpeg); if let Err(err) = result { diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 70ceaefec0..dfd79e8e42 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -86,6 +86,7 @@ pub struct ExportConfig { pub file_type: FileType, pub scale_factor: f64, pub bounds: ExportBounds, + pub transparent_background: bool, pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index c28a1baa7b..fd5c555b38 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -112,6 +112,7 @@ pub struct RenderConfig { #[serde(alias = "view_mode")] pub render_mode: RenderMode, pub export_format: ExportFormat, + pub hide_artboards: bool, pub for_export: bool, pub for_eyedropper: bool, } diff --git a/node-graph/libraries/graphic-types/src/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index 7595f2cd52..f3c057d82a 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -36,7 +36,7 @@ impl Artboard { location: location.min(location + dimensions), dimensions: dimensions.abs(), background: Color::WHITE, - clip: true, + clip: false, } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 6f1690ad37..ad42a77449 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,8 +1,9 @@ use crate::render_ext::RenderExt; use crate::to_peniko::BlendModeExt; use core_types::blending::BlendMode; -use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::color::{Alpha, Color}; +use core_types::bounds::BoundingBox; +use core_types::bounds::RenderBoundingBox; +use core_types::color::Color; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; use core_types::table::{Table, TableRow}; @@ -27,46 +28,6 @@ use std::sync::{Arc, LazyLock}; use vector_types::gradient::GradientSpreadMethod; use vello::*; -/// Cached 16x16 transparency checkerboard image data (two 8x8 cells of #ffffff and #cccccc). -static CHECKERBOARD_IMAGE_DATA: LazyLock>> = LazyLock::new(|| { - const SIZE: u32 = 16; - const HALF: u32 = 8; - - let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; - for y in 0..SIZE { - for x in 0..SIZE { - let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); - let value = if is_light { 0xff } else { 0xcc }; - let index = ((y * SIZE + x) * 4) as usize; - data[index] = value; - data[index + 1] = value; - data[index + 2] = value; - data[index + 3] = 0xff; - } - } - - Arc::new(data) -}); - -/// Creates a 16x16 tiling transparency checkerboard brush for Vello. -pub fn checkerboard_brush() -> peniko::Brush { - peniko::Brush::Image(peniko::ImageBrush { - image: peniko::ImageData { - data: peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()), - format: peniko::ImageFormat::Rgba8, - width: 16, - height: 16, - alpha_type: peniko::ImageAlphaType::Alpha, - }, - sampler: peniko::ImageSampler { - x_extend: peniko::Extend::Repeat, - y_extend: peniko::Extend::Repeat, - quality: peniko::ImageQuality::Low, // Nearest-neighbor sampling for crisp edges - alpha: 1., - }, - }) -} - #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] enum MaskType { Clip, @@ -511,45 +472,18 @@ impl Render for Graphic { impl Render for Artboard { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - let x = self.location.x.min(self.location.x + self.dimensions.x); - let y = self.location.y.min(self.location.y + self.dimensions.y); - let width = self.dimensions.x.abs(); - let height = self.dimensions.y.abs(); - // Rectangle for the artboard if !render_params.hide_artboards { - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = self.background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - let checker_id = format!("checkered-artboard-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; - - // Anchor pattern at this artboard's top-left corner (x, y), not the document origin - let _ = write!( - &mut render.svg_defs, - r##""## - ); - - render.leaf_tag("rect", |attributes| { - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); - } - // Background render.leaf_tag("rect", |attributes| { attributes.push("fill", format!("#{}", self.background.to_rgb_hex_srgb_from_gamma())); if self.background.a() < 1. { attributes.push("fill-opacity", ((self.background.a() * 1000.).round() / 1000.).to_string()); } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); + attributes.push("x", self.location.x.min(self.location.x + self.dimensions.x).to_string()); + attributes.push("y", self.location.y.min(self.location.y + self.dimensions.y).to_string()); + attributes.push("width", self.dimensions.x.abs().to_string()); + attributes.push("height", self.dimensions.y.abs().to_string()); }); } @@ -570,7 +504,7 @@ impl Render for Artboard { write!( &mut attributes.0.svg_defs, - r##""##, + r##""##, self.dimensions.x, self.dimensions.y, ) .unwrap(); @@ -594,22 +528,9 @@ impl Render for Artboard { // Render background if !render_params.hide_artboards { - let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = self.background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Anchor pattern at THIS artboard's top-left corner - // brush_transform is an image placement transform: it maps brush pixel coords → shape coords - // scale(1/zoom) sets each brush pixel to 1/zoom document units (constant CSS size after viewport transform) - // then_translate places the brush origin at the artboard corner - let brush_transform = kurbo::Affine::scale(1. / render_params.viewport_zoom).then_translate(kurbo::Vec2::new(rect.x0, rect.y0)); - scene.fill(peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); - } - let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect); + scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect); scene.pop_layer(); } diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7cd413c1cb..7c5c355524 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -6,6 +6,7 @@ pub mod texture_conversion; use crate::resample::Resampler; use crate::shader_runtime::ShaderRuntime; use anyhow::Result; +use core_types::Color; use futures::lock::Mutex; use glam::UVec2; use graphene_application_io::{ApplicationIo, EditorApi}; @@ -93,13 +94,12 @@ impl TargetTexture { const VELLO_SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; impl WgpuExecutor { - pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext) -> Result { + pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option) -> Result { let mut output = None; - self.render_vello_scene_to_target_texture(scene, size, context, &mut output).await?; + self.render_vello_scene_to_target_texture(scene, size, context, background, &mut output).await?; Ok(output.unwrap().texture) } - - pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, output: &mut Option) -> Result<()> { + pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option, output: &mut Option) -> Result<()> { // Initialize (lazily) if this is the first call if output.is_none() { *output = Some(TargetTexture::new(&self.context.device, size)); @@ -108,8 +108,9 @@ impl WgpuExecutor { if let Some(target_texture) = output.as_mut() { target_texture.ensure_size(&self.context.device, size); + let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); let render_params = RenderParams { - base_color: vello::peniko::Color::from_rgba8(0, 0, 0, 0), + base_color: vello::peniko::Color::from_rgba8(r, g, b, a), width: size.x, height: size.y, antialiasing_method: AaConfig::Msaa16, diff --git a/node-graph/nodes/graphic/src/artboard.rs b/node-graph/nodes/graphic/src/artboard.rs index 2886c342bd..fed9694265 100644 --- a/node-graph/nodes/graphic/src/artboard.rs +++ b/node-graph/nodes/graphic/src/artboard.rs @@ -31,7 +31,6 @@ pub async fn create_artboard( /// Color of the artboard background. Only positive integers are valid. background: Table, /// Whether to cut off the contained content that extends outside the artboard, or keep it visible. - #[default(true)] clip: bool, ) -> Table { let location = location.as_ivec2(); diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 734420732c..5fa07663fc 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -1,6 +1,5 @@ use core_types::table::Table; use core_types::transform::{Footprint, Transform}; -use core_types::uuid::generate_uuid; use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs}; use core_types::{Color, Context, Ctx, ExtractFootprint, OwnedContextImpl, WasmNotSend}; pub use graph_craft::application_io::*; @@ -10,7 +9,7 @@ use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; -use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, checkerboard_brush}; +use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, format_transform_matrix}; use rendering::{RenderMetadata, SvgSegment}; use std::collections::HashMap; use std::sync::Arc; @@ -104,7 +103,7 @@ async fn create_context<'a: 'n>( let render_params = RenderParams { render_mode: render_config.render_mode, - hide_artboards: false, + hide_artboards: render_config.hide_artboards, for_export: render_config.for_export, render_output_type, footprint: Footprint::default(), @@ -146,47 +145,22 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito let data = match (render_params.render_output_type, &ty) { (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => { let mut rendering = SvgRender::new(); - - // Infinite canvas background (no artboards) if !contains_artboard && !render_params.hide_artboards { - let show_checkerboard = render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Checkerboard pattern anchored at the document origin, tiling at 8x8 viewport pixels - let checker_id = format!("checkered-canvas-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; - - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary when the view is rotated so the rect fully covers the visible area - let inverse_transform = footprint.transform.inverse(); - let corners = [ - inverse_transform.transform_point2(glam::DVec2::ZERO), - inverse_transform.transform_point2(glam::DVec2::new(logical_resolution.x, 0.)), - inverse_transform.transform_point2(glam::DVec2::new(0., logical_resolution.y)), - inverse_transform.transform_point2(logical_resolution), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - - rendering.leaf_tag("rect", |attributes| { - attributes.push("x", bb_min.x.to_string()); - attributes.push("y", bb_min.y.to_string()); - attributes.push("width", (bb_max.x - bb_min.x).to_string()); - attributes.push("height", (bb_max.y - bb_min.y).to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); - - // Pattern defs will be appended after the intermediate defs are copied below - rendering.svg_defs = format!( - r##""##, - ); - } + rendering.leaf_tag("rect", |attributes| { + attributes.push("x", "0"); + attributes.push("y", "0"); + attributes.push("width", logical_resolution.x.to_string()); + attributes.push("height", logical_resolution.y.to_string()); + let matrix = format_transform_matrix(footprint.transform.inverse()); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + attributes.push("fill", "white"); + }); } - - let existing_defs = rendering.svg_defs.clone(); rendering.svg.push(SvgSegment::from(svg_data.0.clone())); rendering.image_data = svg_data.1.clone(); - rendering.svg_defs = format!("{existing_defs}{}", svg_data.2); + rendering.svg_defs = svg_data.2.clone(); rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); RenderOutputType::Svg { @@ -205,29 +179,6 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); let mut scene = vello::Scene::new(); - - // Infinite canvas checkerboard (when no artboards are present) - let show_checkerboard = !render_params.for_export && !contains_artboard && !render_params.hide_artboards; - if show_checkerboard && scale > 0. && render_params.viewport_zoom > 0. { - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary so the rect fully covers the visible area when the canvas is tilted - let inverse_footprint = footprint_transform.inverse(); - let corners = [ - inverse_footprint.transform_point2(glam::DVec2::ZERO), - inverse_footprint.transform_point2(glam::DVec2::new(physical_resolution.x as f64, 0.)), - inverse_footprint.transform_point2(glam::DVec2::new(0., physical_resolution.y as f64)), - inverse_footprint.transform_point2(physical_resolution.as_dvec2()), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - let doc_rect = vello::kurbo::Rect::new(bb_min.x, bb_min.y, bb_max.x, bb_max.y); - - // Draw in document space, transformed to screen by footprint_transform (includes rotation) - // Brush maps each pixel to 1/viewport_zoom document units, giving constant 8px cells - let brush_transform = vello::kurbo::Affine::scale(1. / render_params.viewport_zoom); - scene.fill(vello::peniko::Fill::NonZero, footprint_transform_vello, &checkerboard_brush(), Some(brush_transform), &doc_rect); - } - scene.append(child, Some(footprint_transform_vello)); // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport @@ -239,7 +190,17 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito } } - let texture = Arc::new(exec.render_vello_scene_to_texture(&scene, physical_resolution, context).await.expect("Failed to render Vello scene")); + let background = if !render_params.for_export && !contains_artboard && !render_params.hide_artboards { + Some(Color::WHITE) + } else { + None + }; + + let texture = Arc::new( + exec.render_vello_scene_to_texture(&scene, physical_resolution, context, background) + .await + .expect("Failed to render Vello scene"), + ); RenderOutputType::Texture(texture.into()) } diff --git a/website/content/learn/interface/menu-bar.md b/website/content/learn/interface/menu-bar.md index d9b2c140d8..0d2c5368f7 100644 --- a/website/content/learn/interface/menu-bar.md +++ b/website/content/learn/interface/menu-bar.md @@ -30,7 +30,7 @@ The **File menu** lists actions related to file handling: | **Close All** |

Closes all open documents. To avoid accidentally losing unsaved work, you will be asked to confirm that you want to proceed which will discard the unsaved changes in all open documents.

| | **Save** |

Saves the active document by writing the `.graphite` file to disk. An operating system file download dialog may appear asking where to place it. That dialog will provide an opportunity to save over a previous version of the file, if you wish, by picking the identical name instead of saving another instance with a number after it.

| | **Import…** |

Opens the operating system file picker dialog for selecting an image file from disk to be placed as a new bitmap image layer or SVG content into the active document.

| -| **Export…** |

Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard.

The 'Export' dialog

| +| **Export…** |

Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard. *Transparency* exports PNG or SVG files with transparency instead of the artboard background color.

The 'Export' dialog

| | **Preferences…** |

Opens the **Editor Preferences** dialog for configuring Graphite's settings.

The 'Editor Preferences' dialog

| ### Edit From 6b50d3eaf470dc8ec4743c1cd61dac40d31011bf Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 15 Apr 2026 17:54:15 +0000 Subject: [PATCH 02/16] WIP --- node-graph/interpreted-executor/src/util.rs | 37 ++- .../libraries/rendering/src/background.rs | 230 ++++++++++++++++++ node-graph/libraries/rendering/src/lib.rs | 2 + .../libraries/wgpu-executor/src/blend.rs | 165 +++++++++++++ .../wgpu-executor/src/blend_shader.wgsl | 36 +++ node-graph/libraries/wgpu-executor/src/lib.rs | 9 + node-graph/nodes/gstd/src/render_node.rs | 207 +++++++++++----- 7 files changed, 626 insertions(+), 60 deletions(-) create mode 100644 node-graph/libraries/rendering/src/background.rs create mode 100644 node-graph/libraries/wgpu-executor/src/blend.rs create mode 100644 node-graph/libraries/wgpu-executor/src/blend_shader.wgsl diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index ab3b8b4a58..b6e50f9604 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc= 1. || render_params.viewport_zoom <= 0. { + return; + } + + let x = self.location.x.min(self.location.x + self.dimensions.x); + let y = self.location.y.min(self.location.y + self.dimensions.y); + let width = self.dimensions.x.abs(); + let height = self.dimensions.y.abs(); + let checker_id = format!("checkered-artboard-{}", generate_uuid()); + if !write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::new(x as f64, y as f64), render_params.viewport_zoom) { + return; + } + + render.leaf_tag("rect", |attributes| { + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); + } + + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { + return; + } + + let [a, b] = [self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()]; + let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); + let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); + let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::new(rect.x0, rect.y0)) else { + return; + }; + + scene.fill(vello::peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); + } +} + +impl RenderBackground for Table { + fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for artboard in self.iter() { + artboard.element.render_background_svg(render, render_params); + } + } + + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + for row in self.iter() { + row.element.render_background_to_vello(scene, transform * *row.transform, context, render_params); + } + } +} + +impl RenderBackground for Graphic {} +impl RenderBackground for Table {} +impl RenderBackground for Table {} +impl RenderBackground for Table> {} +impl RenderBackground for Table> {} +impl RenderBackground for Table {} +impl RenderBackground for Table {} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Background; + +impl BoundingBox for Background { + fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + RenderBoundingBox::Infinite + } +} + +impl RenderComplexity for Background {} + +impl Render for Background { + fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {} + + fn render_to_vello(&self, _scene: &mut vello::Scene, _transform: DAffine2, _context: &mut RenderContext, _render_params: &RenderParams) {} +} + +impl RenderBackground for Background { + fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + if !render_params.to_canvas() { + return; + } + + let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { + return; + }; + + let checker_id = format!("checkered-viewport-{}", generate_uuid()); + if write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::ZERO, render_params.viewport_zoom) { + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); + } + } + + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + if !render_params.to_canvas() { + return; + } + + let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { + return; + }; + let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::ZERO) else { + return; + }; + + scene.fill( + vello::peniko::Fill::NonZero, + kurbo::Affine::new(transform.to_cols_array()), + &checkerboard_brush(), + Some(brush_transform), + &rect, + ); + } +} + +/// Cached 16x16 transparency checkerboard image data (four 8x8 cells of #ffffff and #cccccc). +static CHECKERBOARD_IMAGE_DATA: LazyLock>> = LazyLock::new(|| { + const SIZE: u32 = 16; + const HALF: u32 = 8; + + let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); + let value = if is_light { 0xff } else { 0xcc }; + let index = ((y * SIZE + x) * 4) as usize; + data[index] = value; + data[index + 1] = value; + data[index + 2] = value; + data[index + 3] = 0xff; + } + } + + Arc::new(data) +}); + +fn checkerboard_brush() -> vello::peniko::Brush { + vello::peniko::Brush::Image(vello::peniko::ImageBrush { + image: vello::peniko::ImageData { + data: vello::peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()), + format: vello::peniko::ImageFormat::Rgba8, + width: 16, + height: 16, + alpha_type: vello::peniko::ImageAlphaType::Alpha, + }, + sampler: vello::peniko::ImageSampler { + x_extend: vello::peniko::Extend::Repeat, + y_extend: vello::peniko::Extend::Repeat, + quality: vello::peniko::ImageQuality::Low, + alpha: 1., + }, + }) +} + +fn checkerboard_brush_transform(viewport_zoom: f64, pattern_origin: DVec2) -> Option { + if viewport_zoom <= 0. { + return None; + } + + Some(kurbo::Affine::scale(1. / viewport_zoom).then_translate(kurbo::Vec2::new(pattern_origin.x, pattern_origin.y))) +} + +fn write_checkerboard_pattern(svg_defs: &mut String, pattern_id: &str, pattern_origin: DVec2, viewport_zoom: f64) -> bool { + if viewport_zoom <= 0. { + return false; + } + + let cell_size = 8. / viewport_zoom; + let pattern_size = cell_size * 2.; + + write!( + svg_defs, + r##""##, + pattern_origin.x, + pattern_origin.y, + ) + .unwrap(); + + true +} + +fn viewport_checkerboard_rect(footprint: Footprint, scale: f64) -> Option { + if scale <= 0. { + return None; + } + + let logical_resolution = footprint.resolution.as_dvec2() / scale; + let logical_footprint = Footprint { + resolution: logical_resolution.round().as_uvec2().max(glam::UVec2::ONE), + ..footprint + }; + let bounds = logical_footprint.viewport_bounds_in_local_space(); + let min = bounds.start.floor(); + let max = bounds.end.ceil(); + + if !(min.is_finite() && max.is_finite()) { + return None; + } + + Some(kurbo::Rect::new(min.x, min.y, max.x, max.y)) +} diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 418f4e9c0c..d0652f50f4 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,6 +1,8 @@ +pub mod background; pub mod convert_usvg_path; pub mod render_ext; mod renderer; pub mod to_peniko; +pub use background::RenderBackground; pub use renderer::*; diff --git a/node-graph/libraries/wgpu-executor/src/blend.rs b/node-graph/libraries/wgpu-executor/src/blend.rs new file mode 100644 index 0000000000..b1453a1b69 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/blend.rs @@ -0,0 +1,165 @@ +use crate::WgpuContext; +use glam::UVec2; + +pub struct Blender { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl Blender { + pub fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("blend_shader.wgsl")); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("blend_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("blend_pipeline_layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("blend_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Self { pipeline, bind_group_layout, sampler } + } + + pub fn blend(&self, context: &WgpuContext, foreground: &wgpu::Texture, background: &wgpu::Texture) -> wgpu::Texture { + let device = &context.device; + let queue = &context.queue; + let size = UVec2::new(foreground.width(), foreground.height()).max(UVec2::ONE); + + let output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("blend_output"), + 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: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default()); + let background_view = background.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("blend_bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&foreground_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&background_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("blend_encoder") }); + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("blend_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + render_pass.set_pipeline(&self.pipeline); + render_pass.set_bind_group(0, &bind_group, &[]); + render_pass.draw(0..3, 0..1); + } + + queue.submit([encoder.finish()]); + + output_texture + } +} diff --git a/node-graph/libraries/wgpu-executor/src/blend_shader.wgsl b/node-graph/libraries/wgpu-executor/src/blend_shader.wgsl new file mode 100644 index 0000000000..60f1fed27b --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/blend_shader.wgsl @@ -0,0 +1,36 @@ +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) tex_coords: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let xy = pos[vertex_index]; + out.clip_position = vec4f(xy, 0.0, 1.0); + let coords = xy / 2. + 0.5; + out.tex_coords = vec2f(coords.x, 1. - coords.y); + return out; +} + +@group(0) @binding(0) +var t_foreground: texture_2d; +@group(0) @binding(1) +var t_background: texture_2d; +@group(0) @binding(2) +var s_linear: sampler; + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let foreground = textureSample(t_foreground, s_linear, in.tex_coords); + let background = textureSample(t_background, s_linear, in.tex_coords); + + let a = foreground.a + background.a * (1.0 - foreground.a); + let rgb = foreground.rgb * foreground.a + background.rgb * background.a * (1.0 - foreground.a); + return vec4(rgb, a); +} diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7c5c355524..0926cdf55a 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -1,8 +1,10 @@ +mod blend; mod context; mod resample; pub mod shader_runtime; pub mod texture_conversion; +use crate::blend::Blender; use crate::resample::Resampler; use crate::shader_runtime::ShaderRuntime; use anyhow::Result; @@ -24,6 +26,7 @@ pub struct WgpuExecutor { pub context: WgpuContext, vello_renderer: Mutex, resampler: Resampler, + blender: Blender, pub shader_runtime: ShaderRuntime, } @@ -139,6 +142,10 @@ impl WgpuExecutor { pub fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> wgpu::Texture { self.resampler.resample(&self.context, source, target_size, transform) } + + pub fn blend_textures(&self, foreground: &wgpu::Texture, background: &wgpu::Texture) -> wgpu::Texture { + self.blender.blend(&self.context, foreground, background) + } } impl WgpuExecutor { @@ -160,11 +167,13 @@ impl WgpuExecutor { .ok()?; let resampler = Resampler::new(&context.device); + let blender = Blender::new(&context.device); Some(Self { shader_runtime: ShaderRuntime::new(&context), context, resampler, + blender, vello_renderer: vello_renderer.into(), }) } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 5fa07663fc..9e3a41ddf1 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -9,8 +9,8 @@ use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; -use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, format_transform_matrix}; -use rendering::{RenderMetadata, SvgSegment}; +use rendering::background::Background; +use rendering::{Render, RenderBackground, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment}; use std::collections::HashMap; use std::sync::Arc; use vector_types::GradientStops; @@ -31,7 +31,6 @@ pub enum RenderIntermediateType { pub struct RenderIntermediate { pub(crate) ty: RenderIntermediateType, pub(crate) metadata: RenderMetadata, - pub(crate) contains_artboard: bool, } #[node_macro::node(category(""))] @@ -59,8 +58,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + let footprint = Footprint::default(); let mut metadata = RenderMetadata::default(); data.collect_metadata(&mut metadata, footprint, None); - let contains_artboard = data.contains_artboard(); - match &render_params.render_output_type { RenderOutputTypeRequest::Vello => { let mut scene = vello::Scene::new(); @@ -71,7 +68,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + RenderIntermediate { ty: RenderIntermediateType::Vello(Arc::new((scene, context))), metadata, - contains_artboard, } } RenderOutputTypeRequest::Svg => { @@ -82,45 +78,69 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + RenderIntermediate { ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), metadata, - contains_artboard, } } } } #[node_macro::node(category(""))] -async fn create_context<'a: 'n>( - // Context injections are defined in the wrap_network_in_scope function - render_config: RenderConfig, - data: impl Node, Output = RenderOutput>, -) -> RenderOutput { - let footprint = render_config.viewport; +async fn render_background_intermediate<'a: 'n, T: 'static + RenderBackground + WasmNotSend + Send + Sync>( + ctx: impl Ctx + ExtractFootprint + ExtractVarArgs + ExtractAll + CloneVarArgs, + #[implementations( + Context -> Table, + Context -> Table, + Context -> Table, + Context -> Table>, + Context -> Table, + Context -> Table, + )] + data: impl Node, Output = T>, +) -> RenderIntermediate { + let footprint = ctx.footprint(); + let render_params = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type"); + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; + let render_params = &render_params; - let render_output_type = match render_config.export_format { - ExportFormat::Svg => RenderOutputTypeRequest::Svg, - ExportFormat::Raster => RenderOutputTypeRequest::Vello, - }; + let ctx = OwnedContextImpl::from(ctx.clone()).into_context(); + let data = data.eval(ctx).await; + let has_artboard = data.contains_artboard(); - let render_params = RenderParams { - render_mode: render_config.render_mode, - hide_artboards: render_config.hide_artboards, - for_export: render_config.for_export, - render_output_type, - footprint: Footprint::default(), - scale: render_config.scale, - viewport_zoom: footprint.scale_magnitudes().x, - ..Default::default() - }; + match &render_params.render_output_type { + RenderOutputTypeRequest::Vello => { + let mut scene = vello::Scene::new(); - let ctx = OwnedContextImpl::default() - .with_footprint(footprint) - .with_real_time(render_config.time.time) - .with_animation_time(render_config.time.animation_time.as_secs_f64()) - .with_pointer_position(render_config.pointer) - .with_vararg(Box::new(render_params)) - .into_context(); + let mut context = wgpu_executor::RenderContext::default(); + if has_artboard { + data.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); + } else { + Background.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); + } - data.eval(ctx).await + RenderIntermediate { + ty: RenderIntermediateType::Vello(Arc::new((scene, context))), + metadata: RenderMetadata::default(), + } + } + RenderOutputTypeRequest::Svg => { + let mut render = SvgRender::new(); + + if has_artboard { + data.render_background_svg(&mut render, render_params); + } else { + Background.render_background_svg(&mut render, render_params); + } + + RenderIntermediate { + ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), + metadata: RenderMetadata::default(), + } + } + } } #[node_macro::node(category(""))] @@ -139,25 +159,12 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito let physical_resolution = render_params.footprint.resolution; let logical_resolution = render_params.footprint.resolution.as_dvec2() / scale; - let RenderIntermediate { ty, mut metadata, contains_artboard } = data; + let RenderIntermediate { ty, mut metadata } = data; metadata.apply_transform(footprint.transform); let data = match (render_params.render_output_type, &ty) { (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => { let mut rendering = SvgRender::new(); - if !contains_artboard && !render_params.hide_artboards { - rendering.leaf_tag("rect", |attributes| { - attributes.push("x", "0"); - attributes.push("y", "0"); - attributes.push("width", logical_resolution.x.to_string()); - attributes.push("height", logical_resolution.y.to_string()); - let matrix = format_transform_matrix(footprint.transform.inverse()); - if !matrix.is_empty() { - attributes.push("transform", matrix); - } - attributes.push("fill", "white"); - }); - } rendering.svg.push(SvgSegment::from(svg_data.0.clone())); rendering.image_data = svg_data.1.clone(); rendering.svg_defs = svg_data.2.clone(); @@ -190,14 +197,8 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito } } - let background = if !render_params.for_export && !contains_artboard && !render_params.hide_artboards { - Some(Color::WHITE) - } else { - None - }; - let texture = Arc::new( - exec.render_vello_scene_to_texture(&scene, physical_resolution, context, background) + exec.render_vello_scene_to_texture(&scene, physical_resolution, context, None) .await .expect("Failed to render Vello scene"), ); @@ -208,3 +209,97 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito }; RenderOutput { data, metadata } } + +#[node_macro::node(category(""))] +async fn compose<'a: 'n>( + ctx: impl Ctx + ExtractVarArgs + ExtractAll + CloneVarArgs, + editor_api: &'a PlatformEditorApi, + data: impl Node, Output = RenderOutput>, + background: impl Node, Output = RenderOutput>, +) -> RenderOutput { + let render_params = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type"); + + let eval_ctx = OwnedContextImpl::from(ctx.clone()).into_context(); + let artwork = data.eval(eval_ctx.clone()).await; + + if render_params.for_export { + return artwork; + } + + let background = background.eval(eval_ctx).await; + let RenderOutput { data: foreground_data, metadata } = artwork; + + let data = match (foreground_data, background.data) { + (RenderOutputType::Texture(foreground_texture), RenderOutputType::Texture(background_texture)) => { + let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else { + return RenderOutput { + data: RenderOutputType::Texture(foreground_texture), + metadata, + }; + }; + + let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()); + RenderOutputType::Texture(blended.into()) + } + ( + RenderOutputType::Svg { + svg: foreground_svg, + image_data: foreground_images, + }, + RenderOutputType::Svg { + svg: background_svg, + image_data: background_images, + }, + ) => { + let mut image_data = background_images; + image_data.extend(foreground_images); + + RenderOutputType::Svg { + svg: format!("{background_svg}{foreground_svg}"), + image_data, + } + } + (foreground_data, _) => foreground_data, + }; + + RenderOutput { data, metadata } +} + +#[node_macro::node(category(""))] +async fn create_context<'a: 'n>( + // Context injections are defined in the wrap_network_in_scope function + render_config: RenderConfig, + data: impl Node, Output = RenderOutput>, +) -> RenderOutput { + let footprint = render_config.viewport; + + let render_output_type = match render_config.export_format { + ExportFormat::Svg => RenderOutputTypeRequest::Svg, + ExportFormat::Raster => RenderOutputTypeRequest::Vello, + }; + + let render_params = RenderParams { + render_mode: render_config.render_mode, + hide_artboards: render_config.hide_artboards, + for_export: render_config.for_export, + render_output_type, + footprint: Footprint::BOUNDLESS, + scale: render_config.scale, + viewport_zoom: footprint.scale_magnitudes().x, + ..Default::default() + }; + + let ctx = OwnedContextImpl::default() + .with_footprint(footprint) + .with_real_time(render_config.time.time) + .with_animation_time(render_config.time.animation_time.as_secs_f64()) + .with_pointer_position(render_config.pointer) + .with_vararg(Box::new(render_params)) + .into_context(); + + data.eval(ctx).await +} From d8a5e807e0150e4085dfc506d6c1407ad5b925a7 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 20 Apr 2026 11:11:17 +0000 Subject: [PATCH 03/16] Remove Background Unit struct --- .../libraries/rendering/src/background.rs | 169 +++++++++--------- node-graph/nodes/gstd/src/render_node.rs | 14 +- 2 files changed, 89 insertions(+), 94 deletions(-) diff --git a/node-graph/libraries/rendering/src/background.rs b/node-graph/libraries/rendering/src/background.rs index 405d9e1474..b63629c824 100644 --- a/node-graph/libraries/rendering/src/background.rs +++ b/node-graph/libraries/rendering/src/background.rs @@ -1,7 +1,5 @@ use crate::renderer::{Render, RenderContext, RenderParams, SvgRender}; -use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::color::Color; -use core_types::render_complexity::RenderComplexity; use core_types::table::Table; use core_types::transform::Footprint; use core_types::uuid::generate_uuid; @@ -14,35 +12,56 @@ use std::fmt::Write; use std::sync::{Arc, LazyLock}; pub trait RenderBackground: Render { - fn render_background_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {} + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + if self.contains_artboard() { + return; + } + render_viewport_checkerboard_vello(scene, transform, render_params) + } - fn render_background_to_vello(&self, _scene: &mut vello::Scene, _transform: DAffine2, _context: &mut RenderContext, _render_params: &RenderParams) {} + fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + if self.contains_artboard() { + return; + } + render_viewport_checkerboard_svg(render, render_params); + } } -impl RenderBackground for Artboard { - fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { +impl RenderBackground for Table +where + T: RenderBackground, + Table: Render, +{ + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + if !self.contains_artboard() { + render_viewport_checkerboard_vello(scene, transform, render_params); return; } - let x = self.location.x.min(self.location.x + self.dimensions.x); - let y = self.location.y.min(self.location.y + self.dimensions.y); - let width = self.dimensions.x.abs(); - let height = self.dimensions.y.abs(); - let checker_id = format!("checkered-artboard-{}", generate_uuid()); - if !write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::new(x as f64, y as f64), render_params.viewport_zoom) { + for row in self.iter() { + if !row.element.contains_artboard() { + continue; + } + row.element.render_background_to_vello(scene, transform, context, render_params); + } + } + + fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + if !self.contains_artboard() { + render_viewport_checkerboard_svg(render, render_params); return; } - render.leaf_tag("rect", |attributes| { - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + for row in self.iter() { + if !row.element.contains_artboard() { + continue; + } + row.element.render_background_svg(render, render_params); + } } +} +impl RenderBackground for Artboard { fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { return; @@ -57,88 +76,74 @@ impl RenderBackground for Artboard { scene.fill(vello::peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); } -} -impl RenderBackground for Table { fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for artboard in self.iter() { - artboard.element.render_background_svg(render, render_params); + if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { + return; } - } - fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - for row in self.iter() { - row.element.render_background_to_vello(scene, transform * *row.transform, context, render_params); + let x = self.location.x.min(self.location.x + self.dimensions.x); + let y = self.location.y.min(self.location.y + self.dimensions.y); + let width = self.dimensions.x.abs(); + let height = self.dimensions.y.abs(); + let checker_id = format!("checkered-artboard-{}", generate_uuid()); + if !write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::new(x as f64, y as f64), render_params.viewport_zoom) { + return; } + + render.leaf_tag("rect", |attributes| { + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); } } impl RenderBackground for Graphic {} -impl RenderBackground for Table {} impl RenderBackground for Table {} impl RenderBackground for Table> {} impl RenderBackground for Table> {} impl RenderBackground for Table {} impl RenderBackground for Table {} -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Background; - -impl BoundingBox for Background { - fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { - RenderBoundingBox::Infinite +fn render_viewport_checkerboard_vello(scene: &mut vello::Scene, transform: DAffine2, render_params: &RenderParams) { + if !render_params.to_canvas() { + return; } + let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { + return; + }; + let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::ZERO) else { + return; + }; + scene.fill( + vello::peniko::Fill::NonZero, + kurbo::Affine::new(transform.to_cols_array()), + &checkerboard_brush(), + Some(brush_transform), + &rect, + ); } -impl RenderComplexity for Background {} - -impl Render for Background { - fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {} - - fn render_to_vello(&self, _scene: &mut vello::Scene, _transform: DAffine2, _context: &mut RenderContext, _render_params: &RenderParams) {} -} - -impl RenderBackground for Background { - fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - if !render_params.to_canvas() { - return; - } - - let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { - return; - }; - - let checker_id = format!("checkered-viewport-{}", generate_uuid()); - if write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::ZERO, render_params.viewport_zoom) { - render.leaf_tag("rect", |attributes| { - attributes.push("x", rect.x0.to_string()); - attributes.push("y", rect.y0.to_string()); - attributes.push("width", rect.width().to_string()); - attributes.push("height", rect.height().to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); - } +fn render_viewport_checkerboard_svg(render: &mut SvgRender, render_params: &RenderParams) { + if !render_params.to_canvas() { + return; } + let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { + return; + }; + let checker_id = format!("checkered-viewport-{}", generate_uuid()); - fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - if !render_params.to_canvas() { - return; - } - - let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { - return; - }; - let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::ZERO) else { - return; - }; - - scene.fill( - vello::peniko::Fill::NonZero, - kurbo::Affine::new(transform.to_cols_array()), - &checkerboard_brush(), - Some(brush_transform), - &rect, - ); + if write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::ZERO, render_params.viewport_zoom) { + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); } } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 9e3a41ddf1..ba10af75d6 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -9,7 +9,6 @@ use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; -use rendering::background::Background; use rendering::{Render, RenderBackground, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment}; use std::collections::HashMap; use std::sync::Arc; @@ -108,18 +107,13 @@ async fn render_background_intermediate<'a: 'n, T: 'static + RenderBackground + let ctx = OwnedContextImpl::from(ctx.clone()).into_context(); let data = data.eval(ctx).await; - let has_artboard = data.contains_artboard(); match &render_params.render_output_type { RenderOutputTypeRequest::Vello => { let mut scene = vello::Scene::new(); let mut context = wgpu_executor::RenderContext::default(); - if has_artboard { - data.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); - } else { - Background.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); - } + data.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); RenderIntermediate { ty: RenderIntermediateType::Vello(Arc::new((scene, context))), @@ -129,11 +123,7 @@ async fn render_background_intermediate<'a: 'n, T: 'static + RenderBackground + RenderOutputTypeRequest::Svg => { let mut render = SvgRender::new(); - if has_artboard { - data.render_background_svg(&mut render, render_params); - } else { - Background.render_background_svg(&mut render, render_params); - } + data.render_background_svg(&mut render, render_params); RenderIntermediate { ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), From 109c39bd13af1390015527b417558455fb0b784b Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 20 Apr 2026 11:31:29 +0000 Subject: [PATCH 04/16] Refactor --- .../libraries/rendering/src/background.rs | 133 +++++++----------- 1 file changed, 54 insertions(+), 79 deletions(-) diff --git a/node-graph/libraries/rendering/src/background.rs b/node-graph/libraries/rendering/src/background.rs index b63629c824..5000e5511e 100644 --- a/node-graph/libraries/rendering/src/background.rs +++ b/node-graph/libraries/rendering/src/background.rs @@ -67,14 +67,8 @@ impl RenderBackground for Artboard { return; } - let [a, b] = [self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()]; - let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); - let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::new(rect.x0, rect.y0)) else { - return; - }; - - scene.fill(vello::peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); + let rect = artboard_rect(self); + checkerboard_fill_vello(scene, transform, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom); } fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { @@ -82,22 +76,8 @@ impl RenderBackground for Artboard { return; } - let x = self.location.x.min(self.location.x + self.dimensions.x); - let y = self.location.y.min(self.location.y + self.dimensions.y); - let width = self.dimensions.x.abs(); - let height = self.dimensions.y.abs(); - let checker_id = format!("checkered-artboard-{}", generate_uuid()); - if !write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::new(x as f64, y as f64), render_params.viewport_zoom) { - return; - } - - render.leaf_tag("rect", |attributes| { - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + let rect = artboard_rect(self); + checkerboard_fill_svg(render, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom, "checkered-artboard"); } } @@ -112,64 +92,28 @@ fn render_viewport_checkerboard_vello(scene: &mut vello::Scene, transform: DAffi if !render_params.to_canvas() { return; } - let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { - return; - }; - let Some(brush_transform) = checkerboard_brush_transform(render_params.viewport_zoom, DVec2::ZERO) else { + let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { return; }; - scene.fill( - vello::peniko::Fill::NonZero, - kurbo::Affine::new(transform.to_cols_array()), - &checkerboard_brush(), - Some(brush_transform), - &rect, - ); + checkerboard_fill_vello(scene, transform, rect, DVec2::ZERO, render_params.viewport_zoom); } fn render_viewport_checkerboard_svg(render: &mut SvgRender, render_params: &RenderParams) { if !render_params.to_canvas() { return; } - let Some(rect) = viewport_checkerboard_rect(render_params.footprint, render_params.scale) else { + let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { return; }; - let checker_id = format!("checkered-viewport-{}", generate_uuid()); - - if write_checkerboard_pattern(&mut render.svg_defs, &checker_id, DVec2::ZERO, render_params.viewport_zoom) { - render.leaf_tag("rect", |attributes| { - attributes.push("x", rect.x0.to_string()); - attributes.push("y", rect.y0.to_string()); - attributes.push("width", rect.width().to_string()); - attributes.push("height", rect.height().to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); - } + checkerboard_fill_svg(render, rect, DVec2::ZERO, render_params.viewport_zoom, "checkered-viewport"); } -/// Cached 16x16 transparency checkerboard image data (four 8x8 cells of #ffffff and #cccccc). -static CHECKERBOARD_IMAGE_DATA: LazyLock>> = LazyLock::new(|| { - const SIZE: u32 = 16; - const HALF: u32 = 8; - - let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; - for y in 0..SIZE { - for x in 0..SIZE { - let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); - let value = if is_light { 0xff } else { 0xcc }; - let index = ((y * SIZE + x) * 4) as usize; - data[index] = value; - data[index + 1] = value; - data[index + 2] = value; - data[index + 3] = 0xff; - } +fn checkerboard_fill_vello(scene: &mut vello::Scene, transform: DAffine2, rect: kurbo::Rect, pattern_origin: DVec2, viewport_zoom: f64) { + if viewport_zoom <= 0. { + return; } - Arc::new(data) -}); - -fn checkerboard_brush() -> vello::peniko::Brush { - vello::peniko::Brush::Image(vello::peniko::ImageBrush { + let brush = vello::peniko::Brush::Image(vello::peniko::ImageBrush { image: vello::peniko::ImageData { data: vello::peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()), format: vello::peniko::ImageFormat::Rgba8, @@ -183,21 +127,20 @@ fn checkerboard_brush() -> vello::peniko::Brush { quality: vello::peniko::ImageQuality::Low, alpha: 1., }, - }) + }); + let brush_transform = kurbo::Affine::scale(1. / viewport_zoom).then_translate(kurbo::Vec2::new(pattern_origin.x, pattern_origin.y)); + scene.fill(vello::peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &brush, Some(brush_transform), &rect); } -fn checkerboard_brush_transform(viewport_zoom: f64, pattern_origin: DVec2) -> Option { +fn checkerboard_fill_svg(render: &mut SvgRender, rect: kurbo::Rect, pattern_origin: DVec2, viewport_zoom: f64, checker_id_prefix: &str) { if viewport_zoom <= 0. { - return None; + return; } - Some(kurbo::Affine::scale(1. / viewport_zoom).then_translate(kurbo::Vec2::new(pattern_origin.x, pattern_origin.y))) -} + let checker_id = format!("{checker_id_prefix}-{}", generate_uuid()); -fn write_checkerboard_pattern(svg_defs: &mut String, pattern_id: &str, pattern_origin: DVec2, viewport_zoom: f64) -> bool { - if viewport_zoom <= 0. { - return false; - } + let svg_defs: &mut String = &mut render.svg_defs; + let pattern_id: &str = &checker_id; let cell_size = 8. / viewport_zoom; let pattern_size = cell_size * 2.; @@ -210,10 +153,21 @@ fn write_checkerboard_pattern(svg_defs: &mut String, pattern_id: &str, pattern_o ) .unwrap(); - true + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); +} + +fn artboard_rect(artboard: &Artboard) -> kurbo::Rect { + let [a, b] = [artboard.location.as_dvec2(), artboard.location.as_dvec2() + artboard.dimensions.as_dvec2()]; + kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)) } -fn viewport_checkerboard_rect(footprint: Footprint, scale: f64) -> Option { +fn viewport_rect(footprint: Footprint, scale: f64) -> Option { if scale <= 0. { return None; } @@ -233,3 +187,24 @@ fn viewport_checkerboard_rect(footprint: Footprint, scale: f64) -> Option>> = LazyLock::new(|| { + const SIZE: u32 = 16; + const HALF: u32 = 8; + + let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); + let value = if is_light { 0xff } else { 0xcc }; + let index = ((y * SIZE + x) * 4) as usize; + data[index] = value; + data[index + 1] = value; + data[index + 2] = value; + data[index + 3] = 0xff; + } + } + + Arc::new(data) +}); From cfe6429dac1e804d732a903474aebca78f5b74d9 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 20 Apr 2026 17:14:45 +0000 Subject: [PATCH 05/16] Unrevert --- .../export_dialog/export_dialog_message.rs | 1 - .../export_dialog_message_handler.rs | 34 +++++----------- .../new_document_dialog_message_handler.rs | 40 +++++++++++++------ .../document/document_message_handler.rs | 3 +- .../graph_operation_message.rs | 8 +++- .../graph_operation_message_handler.rs | 7 ++++ .../document/graph_operation/utility_types.rs | 1 - .../node_graph/document_node_definitions.rs | 2 +- .../messages/portfolio/portfolio_message.rs | 1 - .../portfolio/portfolio_message_handler.rs | 2 - .../tool/tool_messages/artboard_tool.rs | 2 +- editor/src/node_graph_executor.rs | 19 +++++---- editor/src/node_graph_executor/runtime.rs | 1 - .../libraries/application-io/src/lib.rs | 1 - .../libraries/graphic-types/src/artboard.rs | 2 +- .../libraries/rendering/src/background.rs | 4 +- .../libraries/rendering/src/renderer.rs | 36 +++++++---------- node-graph/nodes/graphic/src/artboard.rs | 1 + node-graph/nodes/gstd/src/render_cache.rs | 4 -- node-graph/nodes/gstd/src/render_node.rs | 1 - website/content/learn/interface/menu-bar.md | 2 +- 21 files changed, 87 insertions(+), 85 deletions(-) diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs index c33d94f368..6c6a9364f5 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs @@ -6,7 +6,6 @@ use crate::messages::prelude::*; pub enum ExportDialogMessage { FileType { file_type: FileType }, ScaleFactor { factor: f64 }, - TransparentBackground { transparent: bool }, ExportBounds { bounds: ExportBounds }, Submit, diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 71866cb1fd..73b80e23d1 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -14,7 +14,6 @@ pub struct ExportDialogMessageHandler { pub file_type: FileType, pub scale_factor: f64, pub bounds: ExportBounds, - pub transparent_background: bool, pub artboards: HashMap, pub has_selection: bool, } @@ -25,7 +24,6 @@ impl Default for ExportDialogMessageHandler { file_type: Default::default(), scale_factor: 1., bounds: Default::default(), - transparent_background: false, artboards: Default::default(), has_selection: false, } @@ -40,11 +38,17 @@ impl MessageHandler> for Exp match message { ExportDialogMessage::FileType { file_type } => self.file_type = file_type, ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor, - ExportDialogMessage::TransparentBackground { transparent } => self.transparent_background = transparent, ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds, ExportDialogMessage::Submit => { - let artboard_name = match self.bounds { + // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected + let bounds = if !self.has_selection && self.bounds == ExportBounds::Selection { + ExportBounds::AllArtwork + } else { + self.bounds + }; + + let artboard_name = match bounds { ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(), _ => None, }; @@ -52,8 +56,7 @@ impl MessageHandler> for Exp name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(), file_type: self.file_type, scale_factor: self.scale_factor, - bounds: self.bounds, - transparent_background: self.file_type != FileType::Jpg && self.transparent_background, + bounds, artboard_name, artboard_count: self.artboards.len(), }) @@ -127,6 +130,7 @@ impl LayoutHolder for ExportDialogMessageHandler { let artboards = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect(); let choices = [standard_bounds, artboards]; + // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected let current_bounds = if !self.has_selection && self.bounds == ExportBounds::Selection { ExportBounds::AllArtwork } else { @@ -159,22 +163,6 @@ impl LayoutHolder for ExportDialogMessageHandler { DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(), ]; - let checkbox_id = CheckboxId::new(); - let transparent_background = vec![ - TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(checkbox_id).widget_instance(), - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - CheckboxInput::new(self.transparent_background) - .disabled(self.file_type == FileType::Jpg) - .on_update(move |value: &CheckboxInput| ExportDialogMessage::TransparentBackground { transparent: value.checked }.into()) - .for_label(checkbox_id) - .widget_instance(), - ]; - - Layout(vec![ - LayoutGroup::row(export_type), - LayoutGroup::row(resolution), - LayoutGroup::row(export_area), - LayoutGroup::row(transparent_background), - ]) + Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)]) } } diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index e16a2120f6..9abd4ba5b7 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -1,7 +1,9 @@ use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::*; use glam::{IVec2, UVec2}; use graph_craft::document::NodeId; +use graphene_std::Color; /// A dialog to allow users to set some initial options about a new document. #[derive(Debug, Clone, Default, ExtractField)] @@ -22,25 +24,39 @@ impl MessageHandler for NewDocumentDialogMessageHa NewDocumentDialogMessage::Submit => { responses.add(PortfolioMessage::NewDocumentWithName { name: self.name.clone() }); - let create_artboard = !self.infinite && self.dimensions.x > 0 && self.dimensions.y > 0; - if create_artboard { + if self.infinite { + // Infinite canvas: add a locked white background layer + let node_id = NodeId::new(); + responses.add(GraphOperationMessage::NewColorFillLayer { + node_id, + color: Color::WHITE, + parent: LayerNodeIdentifier::ROOT_PARENT, + insert_index: 0, + }); + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id, + alias: "Background".to_string(), + }); + responses.add(NodeGraphMessage::SetLocked { node_id, locked: true }); + } else if self.dimensions.x > 0 && self.dimensions.y > 0 { + // Finite canvas: create an artboard with the specified dimensions responses.add(GraphOperationMessage::NewArtboard { id: NodeId::new(), artboard: graphene_std::Artboard::new(IVec2::ZERO, self.dimensions.as_ivec2()), }); responses.add(NavigationMessage::CanvasPan { delta: self.dimensions.as_dvec2() }); - responses.add(NodeGraphMessage::RunDocumentGraph); + } - responses.add(ViewportMessage::RepropagateUpdate); + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(ViewportMessage::RepropagateUpdate); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![ - DocumentMessage::ZoomCanvasToFitAll.into(), - DocumentMessage::DeselectAllLayers.into(), - PortfolioMessage::AutoSaveActiveDocument.into(), - ], - }); - } + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![ + DocumentMessage::ZoomCanvasToFitAll.into(), + DocumentMessage::DeselectAllLayers.into(), + PortfolioMessage::AutoSaveActiveDocument.into(), + ], + }); responses.add(DocumentMessage::MarkAsSaved); } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index e246b97bad..7c83ae6c9e 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1398,8 +1398,7 @@ impl MessageHandler> for DocumentMes let node_layer_id = LayerNodeIdentifier::new_unchecked(node_id); let new_artboard_node = document_node_definitions::resolve_network_node_type("Artboard") .expect("Failed to create artboard node") - // Enable clipping by default (input index 5) so imported content is masked to the artboard bounds - .node_template_input_override([None, None, None, None, None, Some(NodeInput::value(TaggedValue::Bool(true), false))]); + .default_node_template(); responses.add(NodeGraphMessage::InsertNode { node_id, node_template: Box::new(new_artboard_node), diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 9f2834dcbf..bcdfe390e7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -4,7 +4,6 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; -use graphene_std::Artboard; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::color::Color; use graphene_std::raster::BlendMode; @@ -14,6 +13,7 @@ use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::PointId; use graphene_std::vector::VectorModificationType; use graphene_std::vector::style::{Fill, Stroke}; +use graphene_std::{Artboard, Color}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -97,6 +97,12 @@ pub enum GraphOperationMessage { parent: LayerNodeIdentifier, insert_index: usize, }, + NewColorFillLayer { + node_id: NodeId, + color: Color, + parent: LayerNodeIdentifier, + insert_index: usize, + }, NewVectorLayer { id: NodeId, subpaths: Vec>, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index aa89b49737..db6148934c 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -269,6 +269,13 @@ impl MessageHandler> for responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index }); responses.add(NodeGraphMessage::RunDocumentGraph); } + GraphOperationMessage::NewColorFillLayer { node_id, color, parent, insert_index } => { + let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); + let layer = modify_inputs.create_layer(node_id); + modify_inputs.insert_color_value(color, layer); + network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); + responses.add(NodeGraphMessage::RunDocumentGraph); + } GraphOperationMessage::NewVectorLayer { id, subpaths, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); 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 9217586576..26903bd89e 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -7,7 +7,6 @@ use glam::{DAffine2, IVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; -use graphene_std::Artboard; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; 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 419855b9d7..3ee48af260 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 @@ -380,7 +380,7 @@ fn document_node_definitions() -> HashMap, artboard_count: usize, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index f62172360b..0231640404 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1430,7 +1430,6 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, - transparent_background, artboard_name, artboard_count, } => { @@ -1441,7 +1440,6 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, - transparent_background, artboard_name, artboard_count, ..Default::default() diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index 0df694050e..fa06962f00 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -395,7 +395,7 @@ impl Fsm for ArtboardToolFsmState { location: start.min(end).round().as_ivec2(), dimensions: (start.round() - end.round()).abs().as_ivec2(), background: graphene_std::Color::WHITE, - clip: false, + clip: true, }, }) } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 7f3b2ad9ca..619839ff95 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -153,7 +153,6 @@ impl NodeGraphExecutor { pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode: document.render_mode, - hide_artboards: false, for_export: false, for_eyedropper: false, }; @@ -218,7 +217,6 @@ impl NodeGraphExecutor { pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode, - hide_artboards: false, for_export: false, for_eyedropper: true, }; @@ -241,10 +239,10 @@ impl NodeGraphExecutor { graphene_std::application_io::ExportFormat::Raster }; - // Calculate the bounding box of the region to be exported + // Calculate the bounding box of the region to be exported (artboard bounds always contribute) let bounds = match export_config.bounds { - ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(!export_config.transparent_background), - ExportBounds::Selection => document.network_interface.selected_bounds_document_space(!export_config.transparent_background, &[]), + ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(true), + ExportBounds::Selection => document.network_interface.selected_bounds_document_space(true, &[]), ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id), } .ok_or_else(|| "No bounding box".to_string())?; @@ -266,7 +264,6 @@ impl NodeGraphExecutor { pointer: DVec2::ZERO, export_format, render_mode: document.render_mode, - hide_artboards: export_config.transparent_background, for_export: true, for_eyedropper: false, }; @@ -481,7 +478,7 @@ impl NodeGraphExecutor { use image::buffer::ConvertBuffer; use image::{ImageFormat, RgbImage, RgbaImage}; - let Some(image) = RgbaImage::from_raw(width, height, data) else { + let Some(mut image) = RgbaImage::from_raw(width, height, data) else { return Err("Failed to create image buffer for export".to_string()); }; @@ -496,6 +493,14 @@ impl NodeGraphExecutor { } } FileType::Jpg => { + // Composite onto a white background since JPG doesn't support transparency + for pixel in image.pixels_mut() { + let [r, g, b, a] = pixel.0; + let alpha = a as f32 / 255.; + let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8; + *pixel = image::Rgba([blend(r), blend(g), blend(b), 255]); + } + let image: RgbImage = image.convert(); let result = image.write_to(&mut cursor, ImageFormat::Jpeg); if let Err(err) = result { diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index dfd79e8e42..70ceaefec0 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -86,7 +86,6 @@ pub struct ExportConfig { pub file_type: FileType, pub scale_factor: f64, pub bounds: ExportBounds, - pub transparent_background: bool, pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index fd5c555b38..c28a1baa7b 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -112,7 +112,6 @@ pub struct RenderConfig { #[serde(alias = "view_mode")] pub render_mode: RenderMode, pub export_format: ExportFormat, - pub hide_artboards: bool, pub for_export: bool, pub for_eyedropper: bool, } diff --git a/node-graph/libraries/graphic-types/src/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index f3c057d82a..7595f2cd52 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -36,7 +36,7 @@ impl Artboard { location: location.min(location + dimensions), dimensions: dimensions.abs(), background: Color::WHITE, - clip: false, + clip: true, } } } diff --git a/node-graph/libraries/rendering/src/background.rs b/node-graph/libraries/rendering/src/background.rs index 5000e5511e..3409345039 100644 --- a/node-graph/libraries/rendering/src/background.rs +++ b/node-graph/libraries/rendering/src/background.rs @@ -63,7 +63,7 @@ where impl RenderBackground for Artboard { fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { + if !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { return; } @@ -72,7 +72,7 @@ impl RenderBackground for Artboard { } fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - if render_params.hide_artboards || !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { + if !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { return; } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index ad42a77449..badae6ffff 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -169,8 +169,6 @@ pub struct RenderParams { pub scale: f64, pub render_output_type: RenderOutputType, pub thumbnail: bool, - /// Don't render the rectangle for an artboard to allow exporting with a transparent background. - pub hide_artboards: bool, /// Are we exporting pub for_export: bool, /// Are we generating a mask in this render pass? Used to see if fill should be multiplied with alpha. @@ -190,7 +188,6 @@ impl Hash for RenderParams { self.footprint.hash(state); self.render_output_type.hash(state); self.thumbnail.hash(state); - self.hide_artboards.hash(state); self.for_export.hash(state); self.for_mask.hash(state); if let Some(x) = self.alignment_parent_transform { @@ -473,19 +470,16 @@ impl Render for Graphic { impl Render for Artboard { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { // Rectangle for the artboard - if !render_params.hide_artboards { - // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", self.background.to_rgb_hex_srgb_from_gamma())); - if self.background.a() < 1. { - attributes.push("fill-opacity", ((self.background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", self.location.x.min(self.location.x + self.dimensions.x).to_string()); - attributes.push("y", self.location.y.min(self.location.y + self.dimensions.y).to_string()); - attributes.push("width", self.dimensions.x.abs().to_string()); - attributes.push("height", self.dimensions.y.abs().to_string()); - }); - } + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", self.background.to_rgb_hex_srgb_from_gamma())); + if self.background.a() < 1. { + attributes.push("fill-opacity", ((self.background.a() * 1000.).round() / 1000.).to_string()); + } + attributes.push("x", self.location.x.min(self.location.x + self.dimensions.x).to_string()); + attributes.push("y", self.location.y.min(self.location.y + self.dimensions.y).to_string()); + attributes.push("width", self.dimensions.x.abs().to_string()); + attributes.push("height", self.dimensions.y.abs().to_string()); + }); // Artwork render.parent_tag( @@ -527,12 +521,10 @@ impl Render for Artboard { let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); // Render background - if !render_params.hide_artboards { - let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect); - scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect); - scene.pop_layer(); - } + let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect); + scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect); + scene.pop_layer(); if self.clip { scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); diff --git a/node-graph/nodes/graphic/src/artboard.rs b/node-graph/nodes/graphic/src/artboard.rs index fed9694265..2886c342bd 100644 --- a/node-graph/nodes/graphic/src/artboard.rs +++ b/node-graph/nodes/graphic/src/artboard.rs @@ -31,6 +31,7 @@ pub async fn create_artboard( /// Color of the artboard background. Only positive integers are valid. background: Table, /// Whether to cut off the contained content that extends outside the artboard, or keep it visible. + #[default(true)] clip: bool, ) -> Table { let location = location.as_ivec2(); diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 434c2c8ebb..392f822b4e 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -41,7 +41,6 @@ pub struct CacheKey { pub device_scale: u64, pub zoom: u64, pub rotation: u64, - pub hide_artboards: bool, pub for_export: bool, pub for_mask: bool, pub thumbnail: bool, @@ -60,7 +59,6 @@ impl CacheKey { device_scale: f64, zoom: f64, rotation: f64, - hide_artboards: bool, for_export: bool, for_mask: bool, thumbnail: bool, @@ -87,7 +85,6 @@ impl CacheKey { device_scale: device_scale.to_bits(), zoom: zoom.to_bits(), rotation: quantized_rotation.to_bits(), - hide_artboards, for_export, for_mask, thumbnail, @@ -411,7 +408,6 @@ pub async fn render_output_cache<'a: 'n>( device_scale, zoom, rotation, - render_params.hide_artboards, render_params.for_export, render_params.for_mask, render_params.thumbnail, diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index ba10af75d6..93d4fcaa1f 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -274,7 +274,6 @@ async fn create_context<'a: 'n>( let render_params = RenderParams { render_mode: render_config.render_mode, - hide_artboards: render_config.hide_artboards, for_export: render_config.for_export, render_output_type, footprint: Footprint::BOUNDLESS, diff --git a/website/content/learn/interface/menu-bar.md b/website/content/learn/interface/menu-bar.md index 0d2c5368f7..d9b2c140d8 100644 --- a/website/content/learn/interface/menu-bar.md +++ b/website/content/learn/interface/menu-bar.md @@ -30,7 +30,7 @@ The **File menu** lists actions related to file handling: | **Close All** |

Closes all open documents. To avoid accidentally losing unsaved work, you will be asked to confirm that you want to proceed which will discard the unsaved changes in all open documents.

| | **Save** |

Saves the active document by writing the `.graphite` file to disk. An operating system file download dialog may appear asking where to place it. That dialog will provide an opportunity to save over a previous version of the file, if you wish, by picking the identical name instead of saving another instance with a number after it.

| | **Import…** |

Opens the operating system file picker dialog for selecting an image file from disk to be placed as a new bitmap image layer or SVG content into the active document.

| -| **Export…** |

Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard. *Transparency* exports PNG or SVG files with transparency instead of the artboard background color.

The 'Export' dialog

| +| **Export…** |

Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard.

The 'Export' dialog

| | **Preferences…** |

Opens the **Editor Preferences** dialog for configuring Graphite's settings.

The 'Editor Preferences' dialog

| ### Edit From aeb20444c81053254d10b4d5cc2a220013970251 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 21 Apr 2026 18:55:30 +0000 Subject: [PATCH 06/16] Add Texture Cache --- desktop/src/render/state.rs | 27 ++-- desktop/wrapper/src/lib.rs | 1 - .../libraries/wgpu-executor/src/blend.rs | 32 +--- node-graph/libraries/wgpu-executor/src/lib.rs | 144 ++++++------------ .../libraries/wgpu-executor/src/resample.rs | 36 +---- .../wgpu-executor/src/texture_cache.rs | 81 ++++++++++ node-graph/nodes/gstd/src/pixel_preview.rs | 2 +- node-graph/nodes/gstd/src/render_cache.rs | 61 +------- node-graph/nodes/gstd/src/render_node.rs | 8 +- 9 files changed, 168 insertions(+), 224 deletions(-) create mode 100644 node-graph/libraries/wgpu-executor/src/texture_cache.rs diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index 0c8c80c068..04f372ab1d 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -1,8 +1,7 @@ -use std::borrow::Cow; use wgpu::PresentMode; use crate::window::Window; -use crate::wrapper::{TargetTexture, WgpuContext, WgpuExecutor}; +use crate::wrapper::{WgpuContext, WgpuExecutor}; #[derive(derivative::Derivative)] #[derivative(Debug)] @@ -19,7 +18,7 @@ pub(crate) struct RenderState { viewport_scale: [f32; 2], viewport_offset: [f32; 2], viewport_texture: Option>, - overlays_texture: Option, + overlays_texture: Option>, ui_texture: Option, bind_group: Option, #[derivative(Debug = "ignore")] @@ -236,11 +235,17 @@ impl RenderState { return; }; let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height()); - let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), None, &mut self.overlays_texture)); - if let Err(e) = result { - tracing::error!("Error rendering overlays: {:?}", e); - return; + let result = futures::executor::block_on(self.executor.render_vello_scene(&scene, size, &Default::default(), None)); + match result { + Ok(texture) => { + self.overlays_texture = Some(texture); + } + Err(e) => { + self.overlays_texture = None; + tracing::error!("Error rendering overlays: {:?}", e); + } } + self.update_bindgroup(); } @@ -317,11 +322,7 @@ impl RenderState { fn update_bindgroup(&mut self) { self.surface_outdated = true; let viewport_texture_view = self.viewport_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); - let overlays_texture_view = self - .overlays_texture - .as_ref() - .map(|target| Cow::Borrowed(target.view())) - .unwrap_or_else(|| Cow::Owned(self.transparent_texture.create_view(&wgpu::TextureViewDescriptor::default()))); + let overlays_texture_view = self.overlays_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let ui_texture_view = self.ui_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.context.device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -333,7 +334,7 @@ impl RenderState { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(overlays_texture_view.as_ref()), + resource: wgpu::BindingResource::TextureView(&overlays_texture_view), }, wgpu::BindGroupEntry { binding: 2, diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 2f8c4c49f4..ad83ca4154 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -5,7 +5,6 @@ use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION}; -pub use wgpu_executor::TargetTexture; pub use wgpu_executor::WgpuContext; pub use wgpu_executor::WgpuContextBuilder; pub use wgpu_executor::WgpuExecutor; diff --git a/node-graph/libraries/wgpu-executor/src/blend.rs b/node-graph/libraries/wgpu-executor/src/blend.rs index b1453a1b69..9b4390582e 100644 --- a/node-graph/libraries/wgpu-executor/src/blend.rs +++ b/node-graph/libraries/wgpu-executor/src/blend.rs @@ -1,5 +1,4 @@ use crate::WgpuContext; -use glam::UVec2; pub struct Blender { pipeline: wgpu::RenderPipeline, @@ -91,31 +90,12 @@ impl Blender { Self { pipeline, bind_group_layout, sampler } } - pub fn blend(&self, context: &WgpuContext, foreground: &wgpu::Texture, background: &wgpu::Texture) -> wgpu::Texture { - let device = &context.device; - let queue = &context.queue; - let size = UVec2::new(foreground.width(), foreground.height()).max(UVec2::ONE); - - let output_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("blend_output"), - 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: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - + pub fn blend(&self, context: &WgpuContext, foreground: &wgpu::Texture, background: &wgpu::Texture, output: &wgpu::Texture) { let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default()); let background_view = background.create_view(&wgpu::TextureViewDescriptor::default()); - let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = context.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("blend_bind_group"), layout: &self.bind_group_layout, entries: &[ @@ -134,7 +114,7 @@ impl Blender { ], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("blend_encoder") }); + let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("blend_encoder") }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -158,8 +138,6 @@ impl Blender { render_pass.draw(0..3, 0..1); } - queue.submit([encoder.finish()]); - - output_texture + context.queue.submit([encoder.finish()]); } } diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 0926cdf55a..f3480e8d2e 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -2,11 +2,15 @@ mod blend; mod context; mod resample; pub mod shader_runtime; +mod texture_cache; pub mod texture_conversion; +use std::sync::Arc; + use crate::blend::Blender; use crate::resample::Resampler; use crate::shader_runtime::ShaderRuntime; +use crate::texture_cache::TextureCache; use anyhow::Result; use core_types::Color; use futures::lock::Mutex; @@ -21,9 +25,12 @@ pub use rendering::RenderContext; pub use wgpu::Backends as WgpuBackends; pub use wgpu::Features as WgpuFeatures; +const TEXTURE_CACHE_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB + #[derive(dyn_any::DynAny)] pub struct WgpuExecutor { pub context: WgpuContext, + texture_cache: Mutex, vello_renderer: Mutex, resampler: Resampler, blender: Blender, @@ -42,109 +49,55 @@ impl<'a, T: ApplicationIo> From<&'a EditorApi> for & } } -#[derive(Clone, Debug)] -pub struct TargetTexture { - texture: wgpu::Texture, - view: wgpu::TextureView, - size: UVec2, -} - -impl TargetTexture { - /// Creates a new TargetTexture with the specified size. - pub fn new(device: &wgpu::Device, size: UVec2) -> Self { - let size = size.max(UVec2::ONE); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: None, - 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, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, - format: VELLO_SURFACE_FORMAT, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - Self { texture, view, size } - } - - /// Ensures the texture has the specified size, creating a new one if needed. - /// This allows reusing the same texture across frames when the size hasn't changed. - pub fn ensure_size(&mut self, device: &wgpu::Device, size: UVec2) { - let size = size.max(UVec2::ONE); - if self.size == size { - return; +impl WgpuExecutor { + pub async fn render_vello_scene(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option) -> Result> { + let texture = self.request_texture(size).await; + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); + let render_params = RenderParams { + base_color: vello::peniko::Color::from_rgba8(r, g, b, a), + width: size.x, + height: size.y, + antialiasing_method: AaConfig::Msaa16, + }; + + { + let mut renderer = self.vello_renderer.lock().await; + for (image_brush, texture) in context.resource_overrides.iter() { + let texture_view = wgpu::TexelCopyTextureInfoBase { + texture: texture.clone(), + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }; + renderer.override_image(&image_brush.image, Some(texture_view)); + } + renderer.render_to_texture(&self.context.device, &self.context.queue, scene, &texture_view, &render_params)?; + for (image_brush, _) in context.resource_overrides.iter() { + renderer.override_image(&image_brush.image, None); + } } - *self = Self::new(device, size); - } - - /// Returns a reference to the texture view for rendering. - pub fn view(&self) -> &wgpu::TextureView { - &self.view - } - - /// Returns a reference to the underlying texture. - pub fn texture(&self) -> &wgpu::Texture { - &self.texture + Ok(texture) } -} - -const VELLO_SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; -impl WgpuExecutor { - pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option) -> Result { - let mut output = None; - self.render_vello_scene_to_target_texture(scene, size, context, background, &mut output).await?; - Ok(output.unwrap().texture) - } - pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option, output: &mut Option) -> Result<()> { - // Initialize (lazily) if this is the first call - if output.is_none() { - *output = Some(TargetTexture::new(&self.context.device, size)); - } - - if let Some(target_texture) = output.as_mut() { - target_texture.ensure_size(&self.context.device, size); - - let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); - let render_params = RenderParams { - base_color: vello::peniko::Color::from_rgba8(r, g, b, a), - width: size.x, - height: size.y, - antialiasing_method: AaConfig::Msaa16, - }; - - { - let mut renderer = self.vello_renderer.lock().await; - for (image_brush, texture) in context.resource_overrides.iter() { - let texture_view = wgpu::TexelCopyTextureInfoBase { - texture: texture.clone(), - mip_level: 0, - origin: Origin3d::ZERO, - aspect: TextureAspect::All, - }; - renderer.override_image(&image_brush.image, Some(texture_view)); - } - renderer.render_to_texture(&self.context.device, &self.context.queue, scene, target_texture.view(), &render_params)?; - for (image_brush, _) in context.resource_overrides.iter() { - renderer.override_image(&image_brush.image, None); - } - } - } - Ok(()) + pub async fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> Arc { + let out = self.request_texture(target_size).await; + self.resampler.resample(&self.context, source, transform, &out); + out } - pub fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> wgpu::Texture { - self.resampler.resample(&self.context, source, target_size, transform) + pub async fn blend_textures(&self, foreground: &wgpu::Texture, background: &wgpu::Texture) -> Arc { + let size = UVec2::new(foreground.width(), foreground.height()).max(UVec2::ONE); + let out = self.request_texture(size).await; + self.blender.blend(&self.context, foreground, background, &out); + out } - pub fn blend_textures(&self, foreground: &wgpu::Texture, background: &wgpu::Texture) -> wgpu::Texture { - self.blender.blend(&self.context, foreground, background) + pub async fn request_texture(&self, size: UVec2) -> Arc { + self.texture_cache.lock().await.request_texture(&self.context.device, size) } } @@ -171,6 +124,7 @@ impl WgpuExecutor { Some(Self { shader_runtime: ShaderRuntime::new(&context), + texture_cache: Mutex::new(TextureCache::new(TEXTURE_CACHE_SIZE)), context, resampler, blender, diff --git a/node-graph/libraries/wgpu-executor/src/resample.rs b/node-graph/libraries/wgpu-executor/src/resample.rs index 3a788fa3c9..98b03a46d7 100644 --- a/node-graph/libraries/wgpu-executor/src/resample.rs +++ b/node-graph/libraries/wgpu-executor/src/resample.rs @@ -1,5 +1,5 @@ use crate::WgpuContext; -use glam::{DAffine2, UVec2, Vec2}; +use glam::{DAffine2, Vec2}; pub struct Resampler { pipeline: wgpu::RenderPipeline, @@ -74,29 +74,11 @@ impl Resampler { Resampler { pipeline, bind_group_layout } } - pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, target_size: UVec2, transform: &DAffine2) -> wgpu::Texture { - let device = &context.device; - let queue = &context.queue; - - let output_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("resample_output"), - size: wgpu::Extent3d { - width: target_size.x.max(1), - height: target_size.y.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - + pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, transform: &DAffine2, output: &wgpu::Texture) { let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); - let params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + let params_buffer = context.device.create_buffer(&wgpu::BufferDescriptor { label: Some("resample_params"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, @@ -104,9 +86,9 @@ impl Resampler { }); let params_data = [transform.matrix2.x_axis.as_vec2(), transform.matrix2.y_axis.as_vec2(), transform.translation.as_vec2(), Vec2::ZERO]; - queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); + context.queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = context.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("resample_bind_group"), layout: &self.bind_group_layout, entries: &[ @@ -121,7 +103,7 @@ impl Resampler { ], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); + let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -145,8 +127,6 @@ impl Resampler { render_pass.draw(0..3, 0..1); } - queue.submit([encoder.finish()]); - - output_texture + context.queue.submit([encoder.finish()]); } } diff --git a/node-graph/libraries/wgpu-executor/src/texture_cache.rs b/node-graph/libraries/wgpu-executor/src/texture_cache.rs new file mode 100644 index 0000000000..3528cd189c --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/texture_cache.rs @@ -0,0 +1,81 @@ +use glam::UVec2; +use std::collections::VecDeque; +use std::sync::Arc; + +struct CachedTexture { + texture: Arc, + size: UVec2, + bytes: u64, +} + +pub(crate) struct TextureCache { + /// Always sorted oldest-first by insertion/last-use order. + textures: VecDeque, + max_free_bytes: u64, +} + +impl TextureCache { + pub fn new(max_free_bytes: u64) -> Self { + Self { + textures: VecDeque::new(), + max_free_bytes, + } + } + + pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2) -> Arc { + if let Some(pos) = self.textures.iter().position(|e| e.size == size && Arc::strong_count(&e.texture) == 1) { + let entry = self.textures.remove(pos).unwrap(); + let texture = entry.texture.clone(); + self.textures.push_back(entry); + return texture; + } + + let incoming_bytes = size.x as u64 * size.y as u64 * 4; + self.evict_until_fits(incoming_bytes); + + let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("cached_texture_{}x{}", size.x, size.y)), + 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: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + })); + + self.textures.push_back(CachedTexture { + texture: texture.clone(), + size, + bytes: incoming_bytes, + }); + + println!( + "total size {} MB, total free size {} MB, Textures in cache: {}", + self.textures.iter().map(|e| e.bytes).sum::() / (1024 * 1024), + self.total_free_bytes() / (1024 * 1024), + self.textures.len() + ); + + texture + } + + fn total_free_bytes(&self) -> u64 { + self.textures.iter().filter(|e| Arc::strong_count(&e.texture) == 1).map(|e| e.bytes).sum() + } + + fn evict_until_fits(&mut self, incoming_bytes: u64) { + while self.total_free_bytes() + incoming_bytes > self.max_free_bytes { + match self.textures.iter().position(|e| Arc::strong_count(&e.texture) == 1) { + Some(pos) => { + self.textures.remove(pos); + } + None => break, + } + } + } +} diff --git a/node-graph/nodes/gstd/src/pixel_preview.rs b/node-graph/nodes/gstd/src/pixel_preview.rs index 266ff7de93..d27b0cb8a7 100644 --- a/node-graph/nodes/gstd/src/pixel_preview.rs +++ b/node-graph/nodes/gstd/src/pixel_preview.rs @@ -59,7 +59,7 @@ pub async fn pixel_preview<'a: 'n>( let transform = DAffine2::from_translation(-upstream_min) * footprint.transform.inverse() * DAffine2::from_scale(logical_resolution); let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform); + let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform).await; result.data = RenderOutputType::Texture(resampled.into()); diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 392f822b4e..97b59e8fb5 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -6,7 +6,7 @@ use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, E use glam::{DAffine2, DVec2, IVec2, UVec2}; use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::RenderOutput; -use graphene_application_io::ApplicationIo; +use graphene_application_io::{ApplicationIo, ImageTexture}; use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; use std::collections::HashSet; use std::hash::Hash; @@ -26,7 +26,7 @@ pub struct TileCoord { #[derive(Debug, Clone)] pub struct CachedRegion { - pub texture: wgpu::Texture, + pub texture: ImageTexture, pub texture_size: UVec2, pub tiles: Vec, pub metadata: rendering::RenderMetadata, @@ -103,12 +103,6 @@ struct TileCacheImpl { timestamp: u64, total_memory: usize, cache_key: CacheKey, - texture_cache_resolution: UVec2, - /// Pool of textures of the same size: `texture_cache_resolution`. - /// Reusing textures reduces the wgpu allocation pressure, - /// which is a problem on web since we have to wait for - /// the browser to garbage collect unused textures, eating up memory. - texture_cache: Vec>, } #[derive(Clone, Default, dyn_any::DynAny, Debug)] @@ -189,20 +183,19 @@ impl TileCacheImpl { } fn store_regions(&mut self, new_regions: Vec) { + self.evict_until_under_budget(); for mut region in new_regions { region.last_access = self.timestamp; self.timestamp += 1; self.total_memory += region.memory_size; self.regions.push(region); } - self.evict_until_under_budget(); } fn evict_until_under_budget(&mut self) { while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() { if let Some((oldest_idx, _)) = self.regions.iter().enumerate().min_by_key(|(_, r)| r.last_access) { let removed = self.regions.remove(oldest_idx); - removed.texture.destroy(); self.total_memory = self.total_memory.saturating_sub(removed.memory_size); } else { break; @@ -211,42 +204,9 @@ impl TileCacheImpl { } fn invalidate_all(&mut self) { - for region in &self.regions { - region.texture.destroy(); - } self.regions.clear(); self.total_memory = 0; } - - pub fn request_texture(&mut self, size: UVec2, device: &wgpu::Device) -> Arc { - if self.texture_cache_resolution != size { - self.texture_cache_resolution = size; - self.texture_cache.clear(); - } - self.texture_cache.truncate(5); - for texture in &self.texture_cache { - if Arc::strong_count(texture) == 1 { - return Arc::clone(texture); - } - } - let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { - label: Some("viewport_output"), - 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: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - })); - self.texture_cache.push(texture.clone()); - - texture - } } impl TileCache { @@ -257,10 +217,6 @@ impl TileCache { pub fn store_regions(&self, regions: Vec) { self.0.lock().unwrap().store_regions(regions); } - - pub fn request_texture(&self, size: UVec2, device: &wgpu::Device) -> Arc { - self.0.lock().unwrap().request_texture(size, device) - } } fn group_into_regions(tiles: &[TileCoord], max_region_area: u32) -> Vec { @@ -450,10 +406,9 @@ pub async fn render_output_cache<'a: 'n>( let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let output_texture = tile_cache.request_texture(physical_resolution, device); + let output_texture = exec.request_texture(physical_resolution).await; - let combined_metadata = composite_cached_regions(&all_regions, output_texture.as_ref(), &device_origin_offset, &footprint.transform, exec); + let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, exec); RenderOutput { data: RenderOutputType::Texture(output_texture.into()), @@ -492,7 +447,7 @@ where let region_ctx = OwnedContextImpl::from(ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); let mut result = render_fn(region_ctx).await; - let RenderOutputType::Texture(rendered_texture) = result.data else { + let RenderOutputType::Texture(texture) = result.data else { unreachable!("render_missing_region: expected texture output from Vello render"); }; @@ -502,7 +457,7 @@ where let memory_size = (region_pixel_size.x * region_pixel_size.y) as usize * BYTES_PER_PIXEL; CachedRegion { - texture: rendered_texture.as_ref().clone(), + texture, texture_size: region_pixel_size, tiles: region.tiles.clone(), metadata: result.metadata, @@ -548,7 +503,7 @@ fn composite_cached_regions( if width > 0 && height > 0 { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: ®ion.texture, + texture: region.texture.as_ref(), mip_level: 0, origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 }, aspect: wgpu::TextureAspect::All, diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 93d4fcaa1f..4ed62a4739 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -187,11 +187,7 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito } } - let texture = Arc::new( - exec.render_vello_scene_to_texture(&scene, physical_resolution, context, None) - .await - .expect("Failed to render Vello scene"), - ); + let texture = exec.render_vello_scene(&scene, physical_resolution, context, None).await.expect("Failed to render Vello scene"); RenderOutputType::Texture(texture.into()) } @@ -232,7 +228,7 @@ async fn compose<'a: 'n>( }; }; - let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()); + let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()).await; RenderOutputType::Texture(blended.into()) } ( From 3005ff350cc07da414535ade62daaa28a3be78a8 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 21 Apr 2026 19:56:27 +0000 Subject: [PATCH 07/16] Review --- .../wgpu-executor/src/texture_cache.rs | 33 +++++++++++-------- node-graph/nodes/gstd/src/render_cache.rs | 2 +- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/node-graph/libraries/wgpu-executor/src/texture_cache.rs b/node-graph/libraries/wgpu-executor/src/texture_cache.rs index 3528cd189c..4e89abe937 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_cache.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_cache.rs @@ -23,6 +23,8 @@ impl TextureCache { } pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2) -> Arc { + let size = size.max(UVec2::ONE); + if let Some(pos) = self.textures.iter().position(|e| e.size == size && Arc::strong_count(&e.texture) == 1) { let entry = self.textures.remove(pos).unwrap(); let texture = entry.texture.clone(); @@ -54,13 +56,6 @@ impl TextureCache { bytes: incoming_bytes, }); - println!( - "total size {} MB, total free size {} MB, Textures in cache: {}", - self.textures.iter().map(|e| e.bytes).sum::() / (1024 * 1024), - self.total_free_bytes() / (1024 * 1024), - self.textures.len() - ); - texture } @@ -69,13 +64,23 @@ impl TextureCache { } fn evict_until_fits(&mut self, incoming_bytes: u64) { - while self.total_free_bytes() + incoming_bytes > self.max_free_bytes { - match self.textures.iter().position(|e| Arc::strong_count(&e.texture) == 1) { - Some(pos) => { - self.textures.remove(pos); - } - None => break, - } + let mut free_bytes = self.total_free_bytes(); + let max_free_bytes = self.max_free_bytes; + + if free_bytes + incoming_bytes <= max_free_bytes { + return; } + + self.textures.retain(|entry| { + if free_bytes + incoming_bytes <= max_free_bytes { + return true; + } + if Arc::strong_count(&entry.texture) == 1 { + free_bytes -= entry.bytes; + false + } else { + true + } + }); } } diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 97b59e8fb5..39e30beeff 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -183,13 +183,13 @@ impl TileCacheImpl { } fn store_regions(&mut self, new_regions: Vec) { - self.evict_until_under_budget(); for mut region in new_regions { region.last_access = self.timestamp; self.timestamp += 1; self.total_memory += region.memory_size; self.regions.push(region); } + self.evict_until_under_budget(); } fn evict_until_under_budget(&mut self) { From 58e3313cbe9b6d814347597488b6aa7af22badcf Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 21 Apr 2026 20:38:03 +0000 Subject: [PATCH 08/16] Destroy textures --- node-graph/libraries/wgpu-executor/src/texture_cache.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/node-graph/libraries/wgpu-executor/src/texture_cache.rs b/node-graph/libraries/wgpu-executor/src/texture_cache.rs index 4e89abe937..dadb9cab30 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_cache.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_cache.rs @@ -77,6 +77,7 @@ impl TextureCache { } if Arc::strong_count(&entry.texture) == 1 { free_bytes -= entry.bytes; + entry.texture.destroy(); false } else { true From fd255713f1e15052343bb1d70ed29fed2232559a Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 22 Apr 2026 08:16:55 +0000 Subject: [PATCH 09/16] Add memo node --- node-graph/interpreted-executor/src/util.rs | 24 +++++++++++++-------- node-graph/nodes/gstd/src/render_node.rs | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index b6e50f9604..1a3ff1eb4d 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,11 +28,17 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc( render_mode: render_config.render_mode, for_export: render_config.for_export, render_output_type, - footprint: Footprint::BOUNDLESS, + footprint, scale: render_config.scale, viewport_zoom: footprint.scale_magnitudes().x, ..Default::default() From 400dedea8ac83d0b5b7b1e45994a07bdc3acf7ea Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 22 Apr 2026 17:20:59 +0000 Subject: [PATCH 10/16] Two pipelines --- editor/src/node_graph_executor.rs | 1 + node-graph/interpreted-executor/src/util.rs | 40 +++--- .../libraries/rendering/src/background.rs | 118 +++++------------- .../libraries/rendering/src/renderer.rs | 11 ++ node-graph/nodes/gstd/src/render_node.rs | 47 +++---- 5 files changed, 80 insertions(+), 137 deletions(-) diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 619839ff95..01077863b8 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -413,6 +413,7 @@ impl NodeGraphExecutor { click_targets, clip_targets, vector_data, + backgrounds: _, } = render_output.metadata; // Run these update state messages immediately diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 1a3ff1eb4d..613b9b2b41 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,17 +28,11 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc RenderBackground for Table -where - T: RenderBackground, - Table: Render, -{ - fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - if !self.contains_artboard() { - render_viewport_checkerboard_vello(scene, transform, render_params); - return; - } +pub trait RenderBackground { + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, render_params: &RenderParams); + fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams); +} - for row in self.iter() { - if !row.element.contains_artboard() { - continue; - } - row.element.render_background_to_vello(scene, transform, context, render_params); - } +impl RenderBackground for Background { + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, render_params: &RenderParams) { + let rect = background_rect(self); + checkerboard_fill_vello(scene, transform, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom); } fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - if !self.contains_artboard() { - render_viewport_checkerboard_svg(render, render_params); - return; - } - - for row in self.iter() { - if !row.element.contains_artboard() { - continue; - } - row.element.render_background_svg(render, render_params); - } + let rect = background_rect(self); + checkerboard_fill_svg(render, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom, "checkered-artboard"); } } -impl RenderBackground for Artboard { - fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - if !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { +impl RenderBackground for Vec { + fn render_background_to_vello(&self, scene: &mut vello::Scene, transform: DAffine2, render_params: &RenderParams) { + if self.is_empty() { + let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { return }; + checkerboard_fill_vello(scene, transform, rect, DVec2::ZERO, render_params.viewport_zoom); return; } - let rect = artboard_rect(self); - checkerboard_fill_vello(scene, transform, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom); + for background in self { + background.render_background_to_vello(scene, transform, render_params); + } } fn render_background_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - if !render_params.to_canvas() || self.background.a() >= 1. || render_params.viewport_zoom <= 0. { + if self.is_empty() { + let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { return }; + checkerboard_fill_svg(render, rect, DVec2::ZERO, render_params.viewport_zoom, "checkered-viewport"); return; } - let rect = artboard_rect(self); - checkerboard_fill_svg(render, rect, DVec2::new(rect.x0, rect.y0), render_params.viewport_zoom, "checkered-artboard"); - } -} - -impl RenderBackground for Graphic {} -impl RenderBackground for Table {} -impl RenderBackground for Table> {} -impl RenderBackground for Table> {} -impl RenderBackground for Table {} -impl RenderBackground for Table {} - -fn render_viewport_checkerboard_vello(scene: &mut vello::Scene, transform: DAffine2, render_params: &RenderParams) { - if !render_params.to_canvas() { - return; - } - let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { - return; - }; - checkerboard_fill_vello(scene, transform, rect, DVec2::ZERO, render_params.viewport_zoom); -} - -fn render_viewport_checkerboard_svg(render: &mut SvgRender, render_params: &RenderParams) { - if !render_params.to_canvas() { - return; + for background in self { + background.render_background_svg(render, render_params); + } } - let Some(rect) = viewport_rect(render_params.footprint, render_params.scale) else { - return; - }; - checkerboard_fill_svg(render, rect, DVec2::ZERO, render_params.viewport_zoom, "checkered-viewport"); } fn checkerboard_fill_vello(scene: &mut vello::Scene, transform: DAffine2, rect: kurbo::Rect, pattern_origin: DVec2, viewport_zoom: f64) { @@ -162,8 +110,8 @@ fn checkerboard_fill_svg(render: &mut SvgRender, rect: kurbo::Rect, pattern_orig }); } -fn artboard_rect(artboard: &Artboard) -> kurbo::Rect { - let [a, b] = [artboard.location.as_dvec2(), artboard.location.as_dvec2() + artboard.dimensions.as_dvec2()]; +fn background_rect(background: &Background) -> kurbo::Rect { + let [a, b] = [background.location.as_dvec2(), background.location.as_dvec2() + background.dimensions.as_dvec2()]; kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)) } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index badae6ffff..fbc246e6f7 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,3 +1,4 @@ +use crate::background::Background; use crate::render_ext::RenderExt; use crate::to_peniko::BlendModeExt; use core_types::blending::BlendMode; @@ -299,6 +300,7 @@ pub struct RenderMetadata { pub click_targets: HashMap>>, pub clip_targets: HashSet, pub vector_data: HashMap>, + pub backgrounds: Vec, } impl RenderMetadata { @@ -319,6 +321,7 @@ impl RenderMetadata { click_targets, clip_targets, vector_data, + backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); local_transforms.extend(other.local_transforms.iter()); @@ -326,6 +329,10 @@ impl RenderMetadata { click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); + // TODO: Don't! + if other.backgrounds.len() > backgrounds.len() { + *backgrounds = other.backgrounds.clone(); + } } } @@ -551,6 +558,10 @@ impl Render for Artboard { } } footprint.transform *= self.transform(); + metadata.backgrounds.push(Background { + location: self.location, + dimensions: self.dimensions, + }); self.content.collect_metadata(metadata, footprint, None); } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index c1fe5e4618..0845fa2245 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -83,53 +83,42 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + } #[node_macro::node(category(""))] -async fn render_background_intermediate<'a: 'n, T: 'static + RenderBackground + WasmNotSend + Send + Sync>( - ctx: impl Ctx + ExtractFootprint + ExtractVarArgs + ExtractAll + CloneVarArgs, - #[implementations( - Context -> Table, - Context -> Table, - Context -> Table, - Context -> Table>, - Context -> Table, - Context -> Table, - )] - data: impl Node, Output = T>, -) -> RenderIntermediate { +async fn render_background_intermediate(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, data: RenderOutput) -> RenderIntermediate { let footprint = ctx.footprint(); let render_params = ctx .vararg(0) .expect("Did not find var args") .downcast_ref::() .expect("Downcasting render params yielded invalid type"); - let mut render_params = render_params.clone(); - render_params.footprint = *footprint; - let render_params = &render_params; - let ctx = OwnedContextImpl::from(ctx.clone()).into_context(); - let data = data.eval(ctx).await; - - match &render_params.render_output_type { - RenderOutputTypeRequest::Vello => { - let mut scene = vello::Scene::new(); + dbg!(&data.metadata.backgrounds); - let mut context = wgpu_executor::RenderContext::default(); - data.render_background_to_vello(&mut scene, Default::default(), &mut context, render_params); + match data { + RenderOutput { + data: RenderOutputType::Texture(_), + metadata, + } => { + let mut background_scene = vello::Scene::new(); + metadata.backgrounds.render_background_to_vello(&mut background_scene, footprint.transform, render_params); RenderIntermediate { - ty: RenderIntermediateType::Vello(Arc::new((scene, context))), - metadata: RenderMetadata::default(), + ty: RenderIntermediateType::Vello(Arc::new((background_scene, RenderContext::default()))), + metadata, } } - RenderOutputTypeRequest::Svg => { + RenderOutput { + data: RenderOutputType::Svg { .. }, + metadata, + } => { let mut render = SvgRender::new(); - - data.render_background_svg(&mut render, render_params); + metadata.backgrounds.render_background_svg(&mut render, render_params); RenderIntermediate { ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), - metadata: RenderMetadata::default(), + metadata, } } + _ => unreachable!("Render background node received unsupported render output type"), } } From 4713c4e77a334128f8e8498f25d8b35bd8114daf Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 22 Apr 2026 17:21:08 +0000 Subject: [PATCH 11/16] One node --- node-graph/interpreted-executor/src/util.rs | 28 +------ node-graph/nodes/gstd/src/render_node.rs | 86 ++++++++------------- 2 files changed, 37 insertions(+), 77 deletions(-) diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 613b9b2b41..e9877085fd 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc RenderIntermediate { - let footprint = ctx.footprint(); - let render_params = ctx - .vararg(0) - .expect("Did not find var args") - .downcast_ref::() - .expect("Downcasting render params yielded invalid type"); - - dbg!(&data.metadata.backgrounds); - - match data { - RenderOutput { - data: RenderOutputType::Texture(_), - metadata, - } => { - let mut background_scene = vello::Scene::new(); - metadata.backgrounds.render_background_to_vello(&mut background_scene, footprint.transform, render_params); - - RenderIntermediate { - ty: RenderIntermediateType::Vello(Arc::new((background_scene, RenderContext::default()))), - metadata, - } - } - RenderOutput { - data: RenderOutputType::Svg { .. }, - metadata, - } => { - let mut render = SvgRender::new(); - metadata.backgrounds.render_background_svg(&mut render, render_params); - - RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), - metadata, - } - } - _ => unreachable!("Render background node received unsupported render output type"), - } -} - #[node_macro::node(category(""))] async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderIntermediate) -> RenderOutput { let footprint = ctx.footprint(); @@ -132,8 +92,10 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito .expect("Downcasting render params yielded invalid type"); let mut render_params = render_params.clone(); render_params.footprint = *footprint; - let render_params = &render_params; + render_intermediate_to_output(*footprint, &render_params, editor_api, data).await +} +async fn render_intermediate_to_output(footprint: Footprint, render_params: &RenderParams, editor_api: &PlatformEditorApi, data: RenderIntermediate) -> RenderOutput { let scale = render_params.scale; let physical_resolution = render_params.footprint.resolution; let logical_resolution = render_params.footprint.resolution.as_dvec2() / scale; @@ -186,27 +148,45 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito } #[node_macro::node(category(""))] -async fn compose<'a: 'n>( - ctx: impl Ctx + ExtractVarArgs + ExtractAll + CloneVarArgs, - editor_api: &'a PlatformEditorApi, - data: impl Node, Output = RenderOutput>, - background: impl Node, Output = RenderOutput>, -) -> RenderOutput { +async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput { + let footprint = ctx.footprint(); let render_params = ctx .vararg(0) .expect("Did not find var args") .downcast_ref::() .expect("Downcasting render params yielded invalid type"); - let eval_ctx = OwnedContextImpl::from(ctx.clone()).into_context(); - let artwork = data.eval(eval_ctx.clone()).await; - if render_params.for_export { - return artwork; + return data; } - let background = background.eval(eval_ctx).await; - let RenderOutput { data: foreground_data, metadata } = artwork; + let RenderOutput { data: foreground_data, metadata } = data; + + let intermediate = match &foreground_data { + RenderOutputType::Texture(_) => { + let mut background_scene = vello::Scene::new(); + metadata.backgrounds.render_background_to_vello(&mut background_scene, Default::default(), render_params); + + RenderIntermediate { + ty: RenderIntermediateType::Vello(Arc::new((background_scene, RenderContext::default()))), + metadata: RenderMetadata::default(), + } + } + RenderOutputType::Svg { .. } => { + let mut render = SvgRender::new(); + metadata.backgrounds.render_background_svg(&mut render, render_params); + + RenderIntermediate { + ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), + metadata: RenderMetadata::default(), + } + } + _ => unreachable!("Render background node received unsupported render output type"), + }; + + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; + let background = render_intermediate_to_output(*footprint, &render_params, editor_api, intermediate).await; let data = match (foreground_data, background.data) { (RenderOutputType::Texture(foreground_texture), RenderOutputType::Texture(background_texture)) => { From 635965b10133398aae2e2b2a940d374920b6ef1a Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 22 Apr 2026 18:12:56 +0000 Subject: [PATCH 12/16] One node improvements --- node-graph/nodes/gstd/src/render_node.rs | 146 ++++++++++------------- 1 file changed, 64 insertions(+), 82 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 85e716ffe8..818911adb8 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -92,61 +92,66 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito .expect("Downcasting render params yielded invalid type"); let mut render_params = render_params.clone(); render_params.footprint = *footprint; - render_intermediate_to_output(*footprint, &render_params, editor_api, data).await -} - -async fn render_intermediate_to_output(footprint: Footprint, render_params: &RenderParams, editor_api: &PlatformEditorApi, data: RenderIntermediate) -> RenderOutput { - let scale = render_params.scale; - let physical_resolution = render_params.footprint.resolution; - let logical_resolution = render_params.footprint.resolution.as_dvec2() / scale; let RenderIntermediate { ty, mut metadata } = data; metadata.apply_transform(footprint.transform); let data = match (render_params.render_output_type, &ty) { - (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => { - let mut rendering = SvgRender::new(); - rendering.svg.push(SvgSegment::from(svg_data.0.clone())); - rendering.image_data = svg_data.1.clone(); - rendering.svg_defs = svg_data.2.clone(); - - rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); - RenderOutputType::Svg { - svg: rendering.svg.to_svg_string(), - image_data: rendering.image_data.into_iter().map(|(image, id)| (id, image)).collect(), - } + (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(data)) => { + let (svg, image_data) = render_svg(&data.0, &data.1, &data.2, *footprint, &render_params); + RenderOutputType::Svg { svg, image_data } } - (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(vello_data)) => { + (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else { unreachable!("Attempted to render with Vello when no GPU executor is available"); }; - let (child, context) = Arc::as_ref(vello_data); - - let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); - let footprint_transform = scale_transform * footprint.transform; - let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); - - let mut scene = vello::Scene::new(); - scene.append(child, Some(footprint_transform_vello)); - - // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport - // See for more detail - let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); - for transform in scene.encoding_mut().transforms.iter_mut() { - if transform.matrix[0] == f32::INFINITY { - *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); - } - } - - let texture = exec.render_vello_scene(&scene, physical_resolution, context, None).await.expect("Failed to render Vello scene"); - + let (scene, context) = data.as_ref(); + let texture = render_vello(scene, context, *footprint, &render_params, exec).await; RenderOutputType::Texture(texture.into()) } _ => unreachable!("Render node did not receive its requested data type"), }; + RenderOutput { data, metadata } } +fn render_svg(svg: &str, image_data: &ImageData, svg_defs: &str, footprint: Footprint, render_params: &RenderParams) -> (String, Vec<(u64, Image)>) { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + + let mut rendering = SvgRender::new(); + rendering.svg.push(SvgSegment::from(svg.to_string())); + rendering.image_data = image_data.clone(); + rendering.svg_defs = svg_defs.to_string(); + + rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); + (rendering.svg.to_svg_string(), rendering.image_data.into_iter().map(|(image, id)| (id, image)).collect()) +} + +async fn render_vello(scene: &vello::Scene, context: &RenderContext, footprint: Footprint, render_params: &RenderParams, exec: &wgpu_executor::WgpuExecutor) -> Arc { + let scale = render_params.scale; + let physical_resolution = render_params.footprint.resolution; + + let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); + let footprint_transform = scale_transform * footprint.transform; + let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); + + let mut transformed_scene = vello::Scene::new(); + transformed_scene.append(scene, Some(footprint_transform_vello)); + + // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport + // See for more detail + let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); + for transform in transformed_scene.encoding_mut().transforms.iter_mut() { + if transform.matrix[0] == f32::INFINITY { + *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); + } + } + + exec.render_vello_scene(&transformed_scene, physical_resolution, context, None) + .await + .expect("Failed to render Vello scene") +} + #[node_macro::node(category(""))] async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput { let footprint = ctx.footprint(); @@ -161,55 +166,32 @@ async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVar } let RenderOutput { data: foreground_data, metadata } = data; + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; - let intermediate = match &foreground_data { - RenderOutputType::Texture(_) => { - let mut background_scene = vello::Scene::new(); - metadata.backgrounds.render_background_to_vello(&mut background_scene, Default::default(), render_params); + let data = match foreground_data { + RenderOutputType::Texture(foreground_texture) => { + if let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() { + let mut background_scene = vello::Scene::new(); + metadata.backgrounds.render_background_to_vello(&mut background_scene, Default::default(), &render_params); - RenderIntermediate { - ty: RenderIntermediateType::Vello(Arc::new((background_scene, RenderContext::default()))), - metadata: RenderMetadata::default(), - } - } - RenderOutputType::Svg { .. } => { - let mut render = SvgRender::new(); - metadata.backgrounds.render_background_svg(&mut render, render_params); + let background_texture = render_vello(&background_scene, &RenderContext::default(), *footprint, &render_params, exec).await; - RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), - metadata: RenderMetadata::default(), + let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()).await; + RenderOutputType::Texture(blended.into()) + } else { + RenderOutputType::Texture(foreground_texture) } } - _ => unreachable!("Render background node received unsupported render output type"), - }; - - let mut render_params = render_params.clone(); - render_params.footprint = *footprint; - let background = render_intermediate_to_output(*footprint, &render_params, editor_api, intermediate).await; + RenderOutputType::Svg { + svg: foreground_svg, + image_data: foreground_images, + } => { + let mut render = SvgRender::new(); + metadata.backgrounds.render_background_svg(&mut render, &render_params); - let data = match (foreground_data, background.data) { - (RenderOutputType::Texture(foreground_texture), RenderOutputType::Texture(background_texture)) => { - let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else { - return RenderOutput { - data: RenderOutputType::Texture(foreground_texture), - metadata, - }; - }; + let (background_svg, background_images) = render_svg(&render.svg.to_svg_string(), &render.image_data, &render.svg_defs, *footprint, &render_params); - let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()).await; - RenderOutputType::Texture(blended.into()) - } - ( - RenderOutputType::Svg { - svg: foreground_svg, - image_data: foreground_images, - }, - RenderOutputType::Svg { - svg: background_svg, - image_data: background_images, - }, - ) => { let mut image_data = background_images; image_data.extend(foreground_images); @@ -218,7 +200,7 @@ async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVar image_data, } } - (foreground_data, _) => foreground_data, + _ => unreachable!("Render background node received unsupported render output type"), }; RenderOutput { data, metadata } From b9ddfd0b6d25d86861069c871220b0898cb4ef15 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Apr 2026 07:59:09 +0000 Subject: [PATCH 13/16] Cleanup --- .../libraries/rendering/src/renderer.rs | 42 ++++++++- node-graph/nodes/gstd/src/render_node.rs | 93 +++++++++---------- 2 files changed, 82 insertions(+), 53 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index fbc246e6f7..31a3b1c576 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -81,15 +81,17 @@ impl SvgRender { pub fn format_svg(&mut self, bounds_min: DVec2, bounds_max: DVec2) { let (x, y) = bounds_min.into(); let (size_x, size_y) = (bounds_max - bounds_min).into(); - let defs = &self.svg_defs; - let svg_header = format!(r#"{defs}"#,); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } /// Wraps the SVG with `...`, which allows for rotation pub fn wrap_with_transform(&mut self, transform: DAffine2, size: Option) { - let defs = &self.svg_defs; let view_box = size .map(|size| format!("viewBox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"", size.x, size.y, size.x, size.y)) .unwrap_or_default(); @@ -97,7 +99,11 @@ impl SvgRender { let matrix = format_transform_matrix(transform); let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; - let svg_header = format!(r#"{defs}"#); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } @@ -142,6 +148,34 @@ impl SvgRender { } } +pub struct SvgRenderOutput { + pub svg: String, + pub svg_defs: String, + pub image_data: HashMap, u64>, +} + +impl From<&SvgRenderOutput> for SvgRender { + fn from(value: &SvgRenderOutput) -> Self { + Self { + svg: vec![value.svg.clone().into()], + svg_defs: value.svg_defs.clone(), + transform: DAffine2::IDENTITY, + image_data: value.image_data.clone(), + indent: 0, + } + } +} + +impl From for SvgRenderOutput { + fn from(val: SvgRender) -> Self { + Self { + svg: val.svg.to_svg_string(), + svg_defs: val.svg_defs, + image_data: val.image_data, + } + } +} + impl Default for SvgRender { fn default() -> Self { Self::new() diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 818911adb8..b097d575da 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -9,8 +9,7 @@ use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; -use rendering::{Render, RenderBackground, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment}; -use std::collections::HashMap; +use rendering::{Render, RenderBackground, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput}; use std::sync::Arc; use vector_types::GradientStops; use wgpu_executor::RenderContext; @@ -18,13 +17,10 @@ use wgpu_executor::RenderContext; // Re-export render_output_cache from render_cache module pub use crate::render_cache::render_output_cache; -/// List of (canvas id, image data) pairs for embedding images as canvases in the final SVG string. -type ImageData = HashMap, u64>; - #[derive(Clone, dyn_any::DynAny)] pub enum RenderIntermediateType { Vello(Arc<(vello::Scene, RenderContext)>), - Svg(Arc<(String, ImageData, String)>), + Svg(Arc), } #[derive(Clone, dyn_any::DynAny)] pub struct RenderIntermediate { @@ -75,7 +71,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + data.render_svg(&mut render, render_params); RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), + ty: RenderIntermediateType::Svg(Arc::new(render.into())), metadata, } } @@ -96,9 +92,9 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito let RenderIntermediate { ty, mut metadata } = data; metadata.apply_transform(footprint.transform); - let data = match (render_params.render_output_type, &ty) { + let data = match (render_params.render_output_type, ty) { (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(data)) => { - let (svg, image_data) = render_svg(&data.0, &data.1, &data.2, *footprint, &render_params); + let (svg, image_data) = render_svg(data.as_ref().into(), &render_params); RenderOutputType::Svg { svg, image_data } } (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { @@ -106,7 +102,7 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito unreachable!("Attempted to render with Vello when no GPU executor is available"); }; let (scene, context) = data.as_ref(); - let texture = render_vello(scene, context, *footprint, &render_params, exec).await; + let texture = render_vello(scene, context, &render_params, exec).await; RenderOutputType::Texture(texture.into()) } _ => unreachable!("Render node did not receive its requested data type"), @@ -115,43 +111,6 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito RenderOutput { data, metadata } } -fn render_svg(svg: &str, image_data: &ImageData, svg_defs: &str, footprint: Footprint, render_params: &RenderParams) -> (String, Vec<(u64, Image)>) { - let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; - - let mut rendering = SvgRender::new(); - rendering.svg.push(SvgSegment::from(svg.to_string())); - rendering.image_data = image_data.clone(); - rendering.svg_defs = svg_defs.to_string(); - - rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); - (rendering.svg.to_svg_string(), rendering.image_data.into_iter().map(|(image, id)| (id, image)).collect()) -} - -async fn render_vello(scene: &vello::Scene, context: &RenderContext, footprint: Footprint, render_params: &RenderParams, exec: &wgpu_executor::WgpuExecutor) -> Arc { - let scale = render_params.scale; - let physical_resolution = render_params.footprint.resolution; - - let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); - let footprint_transform = scale_transform * footprint.transform; - let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); - - let mut transformed_scene = vello::Scene::new(); - transformed_scene.append(scene, Some(footprint_transform_vello)); - - // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport - // See for more detail - let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); - for transform in transformed_scene.encoding_mut().transforms.iter_mut() { - if transform.matrix[0] == f32::INFINITY { - *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); - } - } - - exec.render_vello_scene(&transformed_scene, physical_resolution, context, None) - .await - .expect("Failed to render Vello scene") -} - #[node_macro::node(category(""))] async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput { let footprint = ctx.footprint(); @@ -175,7 +134,7 @@ async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVar let mut background_scene = vello::Scene::new(); metadata.backgrounds.render_background_to_vello(&mut background_scene, Default::default(), &render_params); - let background_texture = render_vello(&background_scene, &RenderContext::default(), *footprint, &render_params, exec).await; + let background_texture = render_vello(&background_scene, &RenderContext::default(), &render_params, exec).await; let blended = exec.blend_textures(foreground_texture.as_ref(), background_texture.as_ref()).await; RenderOutputType::Texture(blended.into()) @@ -190,7 +149,7 @@ async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVar let mut render = SvgRender::new(); metadata.backgrounds.render_background_svg(&mut render, &render_params); - let (background_svg, background_images) = render_svg(&render.svg.to_svg_string(), &render.image_data, &render.svg_defs, *footprint, &render_params); + let (background_svg, background_images) = render_svg(render, &render_params); let mut image_data = background_images; image_data.extend(foreground_images); @@ -206,6 +165,42 @@ async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVar RenderOutput { data, metadata } } +async fn render_vello(scene: &vello::Scene, context: &RenderContext, render_params: &RenderParams, exec: &wgpu_executor::WgpuExecutor) -> Arc { + let scale = render_params.scale; + let physical_resolution = render_params.footprint.resolution; + + let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); + let footprint_transform = scale_transform * render_params.footprint.transform; + let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); + + let mut transformed_scene = vello::Scene::new(); + transformed_scene.append(scene, Some(footprint_transform_vello)); + + // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport + // See for more detail + let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); + for transform in transformed_scene.encoding_mut().transforms.iter_mut() { + if transform.matrix[0] == f32::INFINITY { + *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); + } + } + + exec.render_vello_scene(&transformed_scene, physical_resolution, context, None) + .await + .expect("Failed to render Vello scene") +} + +fn render_svg(mut render: SvgRender, render_params: &RenderParams) -> (String, Vec<(u64, Image)>) { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + + render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution)); + + let output = SvgRenderOutput::from(render); + assert!(output.svg_defs.is_empty()); + + (output.svg, output.image_data.into_iter().map(|(image, id)| (id, image)).collect()) +} + #[node_macro::node(category(""))] async fn create_context<'a: 'n>( // Context injections are defined in the wrap_network_in_scope function From 3398e7255de8a76f8b0ed9f75ba5fe43b20083e6 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Apr 2026 08:14:07 +0000 Subject: [PATCH 14/16] Cleanup --- node-graph/libraries/core-types/src/transform.rs | 2 +- node-graph/nodes/gstd/src/render_node.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index f92965ab97..d9fc6b89a7 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -173,7 +173,7 @@ impl Default for Footprint { impl Footprint { pub const DEFAULT: Self = Self { transform: DAffine2::IDENTITY, - resolution: UVec2::new(1920, 1080), + resolution: UVec2::ZERO, quality: RenderQuality::Full, }; diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index b097d575da..5b46e427e2 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -218,7 +218,6 @@ async fn create_context<'a: 'n>( render_mode: render_config.render_mode, for_export: render_config.for_export, render_output_type, - footprint, scale: render_config.scale, viewport_zoom: footprint.scale_magnitudes().x, ..Default::default() From 64feff4947a671d714c37de0e39dbe8ad74297d4 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Apr 2026 09:08:28 +0000 Subject: [PATCH 15/16] Improve --- node-graph/libraries/rendering/src/renderer.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 31a3b1c576..296b826da3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -363,9 +363,12 @@ impl RenderMetadata { click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); - // TODO: Don't! - if other.backgrounds.len() > backgrounds.len() { - *backgrounds = other.backgrounds.clone(); + + // TODO: Find a better non O(n^2) way to merge backgrounds + for background in &other.backgrounds { + if !backgrounds.contains(background) { + backgrounds.push(background.clone()); + } } } } From 5d44cca8cbb219ae7d9807dd9894b1cd95142f7a Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Apr 2026 09:41:31 +0000 Subject: [PATCH 16/16] Fix --- .../document/graph_operation/graph_operation_message.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index bcdfe390e7..61a55b5f30 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -5,7 +5,6 @@ use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; use graphene_std::brush::brush_stroke::BrushStroke; -use graphene_std::color::Color; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath;