diff --git a/Cargo.lock b/Cargo.lock index fc6ef1afe1..70f37160e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2305,6 +2305,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "wgpu", "wgpu-executor", ] diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs index 736fe7c10a..23a11547ec 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs @@ -4,4 +4,5 @@ use crate::messages::prelude::*; #[derive(Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum PreferencesDialogMessage { Confirm, + Update, } diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 1955986fc6..a198b2f91f 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -20,6 +20,7 @@ impl MessageHandler {} + PreferencesDialogMessage::Update => {} } self.send_dialog_to_frontend(responses, preferences); @@ -231,30 +232,36 @@ impl PreferencesDialogMessageHandler { graph_wire_style, ]; - let checkbox_id = CheckboxId::new(); - let vello_description = "Use the experimental Vello renderer instead of SVG-based rendering.".to_string(); - #[cfg(target_family = "wasm")] - let mut vello_description = vello_description; - #[cfg(target_family = "wasm")] - vello_description.push_str("\n\n(Your browser must support WebGPU.)"); - - let use_vello = vec![ + let vello_description = "Auto uses Vello renderer when GPU is available."; + let vello_renderer_label = vec![ Separator::new(SeparatorStyle::Unrelated).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), - CheckboxInput::new(preferences.use_vello && preferences.supports_wgpu()) - .tooltip_label("Vello Renderer") - .tooltip_description(vello_description.clone()) - .disabled(!preferences.supports_wgpu()) - .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::UseVello { use_vello: checkbox_input.checked }.into()) - .for_label(checkbox_id) - .widget_instance(), TextLabel::new("Vello Renderer") .tooltip_label("Vello Renderer") .tooltip_description(vello_description) - .disabled(!preferences.supports_wgpu()) - .for_checkbox(checkbox_id) .widget_instance(), ]; + let vello_preference = RadioInput::new(vec![ + RadioEntryData::new("Auto").label("Auto").on_update(move |_| { + PreferencesMessage::VelloPreference { + preference: graph_craft::wasm_application_io::VelloPreference::Auto, + } + .into() + }), + RadioEntryData::new("Disabled").label("Disabled").on_update(move |_| { + PreferencesMessage::VelloPreference { + preference: graph_craft::wasm_application_io::VelloPreference::Disabled, + } + .into() + }), + ]) + .selected_index(Some(preferences.vello_preference as u32)) + .widget_instance(); + let vello_preference = vec![ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + vello_preference, + ]; let checkbox_id = CheckboxId::new(); let brush_tool_description = " @@ -281,7 +288,47 @@ impl PreferencesDialogMessageHandler { .widget_instance(), ]; - rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, use_vello, brush_tool]); + let max_region_size_description = + "Maximum render region size in total pixels (width × height). Larger values require fewer render passes. If you see rendering artifacts or nothing at all, configure a smaller value."; + let max_region_size_label = vec![ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new("Max Render Region Size") + .tooltip_label("Max Render Region Size") + .tooltip_description(max_region_size_description) + .disabled(!preferences.use_vello()) + .widget_instance(), + ]; + let max_region_size = vec![ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(Some(preferences.max_render_region_size as f64)) + .tooltip_label("Max Render Region Size") + .tooltip_description(max_region_size_description) + .mode_range() + .int() + .min(65536.) + .max(16777216.) + .increment_step(262144.) + .unit(" pixels") + .disabled(!preferences.use_vello()) + .on_update(|number_input: &NumberInput| { + let size = number_input.value.unwrap_or(2073600.) as u32; + PreferencesMessage::MaxRenderRegionSize { size }.into() + }) + .widget_instance(), + ]; + + rows.extend_from_slice(&[ + header, + node_graph_wires_label, + graph_wire_style, + vello_renderer_label, + vello_preference, + brush_tool, + max_region_size_label, + max_region_size, + ]); } Layout(rows.into_iter().map(|r| LayoutGroup::Row { widgets: r }).collect()) diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 646b6015c6..6afd7da115 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1364,10 +1364,11 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::UpdateVelloPreference => { - let active = if cfg!(target_family = "wasm") { false } else { preferences.use_vello }; + // TODO: Resend this message once the GPU context is initialized to avoid having the hole punch be stuck in an invalid state + let active = if cfg!(target_family = "wasm") { false } else { preferences.use_vello() }; responses.add(FrontendMessage::UpdateViewportHolePunch { active }); responses.add(NodeGraphMessage::RunDocumentGraph); - self.persistent_data.use_vello = preferences.use_vello; + self.persistent_data.use_vello = preferences.use_vello(); } } } diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs index 6408cd5d2f..2246b1ba2d 100644 --- a/editor/src/messages/preferences/preferences_message.rs +++ b/editor/src/messages/preferences/preferences_message.rs @@ -10,11 +10,12 @@ pub enum PreferencesMessage { ResetToDefaults, // Per-preference messages - UseVello { use_vello: bool }, + VelloPreference { preference: graph_craft::wasm_application_io::VelloPreference }, SelectionMode { selection_mode: SelectionMode }, BrushTool { enabled: bool }, ModifyLayout { zoom_with_scroll: bool }, GraphWireStyle { style: GraphWireStyle }, ViewportZoomWheelRate { rate: f64 }, UIScale { scale: f64 }, + MaxRenderRegionSize { size: u32 }, } diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 6321119c5a..17075d1ac4 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -5,6 +5,7 @@ use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; use crate::messages::tool::utility_types::ToolType; use graph_craft::wasm_application_io::EditorPreferences; +use graphene_std::application_io::GetEditorPreferences; #[derive(ExtractField)] pub struct PreferencesMessageContext<'a> { @@ -16,11 +17,12 @@ pub struct PreferencesMessageContext<'a> { pub struct PreferencesMessageHandler { pub selection_mode: SelectionMode, pub zoom_with_scroll: bool, - pub use_vello: bool, + pub vello_preference: graph_craft::wasm_application_io::VelloPreference, pub brush_tool: bool, pub graph_wire_style: GraphWireStyle, pub viewport_zoom_wheel_rate: f64, pub ui_scale: f64, + pub max_render_region_size: u32, } impl PreferencesMessageHandler { @@ -30,13 +32,18 @@ impl PreferencesMessageHandler { pub fn editor_preferences(&self) -> EditorPreferences { EditorPreferences { - use_vello: self.use_vello && self.supports_wgpu(), + vello_preference: self.vello_preference, + max_render_region_size: self.max_render_region_size, } } pub fn supports_wgpu(&self) -> bool { graph_craft::wasm_application_io::wgpu_available().unwrap_or_default() } + + pub fn use_vello(&self) -> bool { + self.editor_preferences().use_vello() + } } impl Default for PreferencesMessageHandler { @@ -44,11 +51,12 @@ impl Default for PreferencesMessageHandler { Self { selection_mode: SelectionMode::Touched, zoom_with_scroll: matches!(MappingVariant::default(), MappingVariant::ZoomWithScroll), - use_vello: EditorPreferences::default().use_vello, + vello_preference: EditorPreferences::default().vello_preference, brush_tool: false, graph_wire_style: GraphWireStyle::default(), viewport_zoom_wheel_rate: VIEWPORT_ZOOM_WHEEL_RATE, ui_scale: UI_SCALE_DEFAULT, + max_render_region_size: 1920 * 1080, } } } @@ -80,10 +88,11 @@ impl MessageHandler> for Prefe } // Per-preference messages - PreferencesMessage::UseVello { use_vello } => { - self.use_vello = use_vello; + PreferencesMessage::VelloPreference { preference } => { + self.vello_preference = preference; responses.add(PortfolioMessage::UpdateVelloPreference); responses.add(PortfolioMessage::EditorPreferences); + responses.add(PreferencesDialogMessage::Update); } PreferencesMessage::BrushTool { enabled } => { self.brush_tool = enabled; @@ -115,6 +124,11 @@ impl MessageHandler> for Prefe self.ui_scale = scale; responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale }); } + PreferencesMessage::MaxRenderRegionSize { size } => { + self.max_render_region_size = size; + responses.add(PortfolioMessage::UpdateVelloPreference); + responses.add(PortfolioMessage::EditorPreferences); + } } responses.add(FrontendMessage::TriggerSavePreferences { preferences: self.clone() }); diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 39265a087a..3ca1e4d7fc 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -328,6 +328,7 @@ impl NodeRuntime { executor.context.queue.submit([encoder.finish()]); surface_texture.present(); + // image_texture.texture.destroy(); let frame = graphene_std::application_io::SurfaceFrame { surface_id: surface.window_id, diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 6877874007..5cb98334b8 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -206,20 +206,20 @@ impl EditorHandle { if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { handle(|handle| { // Process all messages that have been queued up - let messages = MESSAGE_BUFFER.take(); - - for message in messages { - handle.dispatch(message); - } - - handle.dispatch(InputPreprocessorMessage::CurrentTime { - timestamp: js_sys::Date::now() as u64, - }); - handle.dispatch(AnimationMessage::IncrementFrameCounter); + let mut messages = MESSAGE_BUFFER.take(); + messages.push( + InputPreprocessorMessage::CurrentTime { + timestamp: js_sys::Date::now() as u64, + } + .into(), + ); + messages.push(AnimationMessage::IncrementFrameCounter.into()); // Used by auto-panning, but this could possibly be refactored in the future, see: // - handle.dispatch(BroadcastMessage::TriggerEvent(EventMessage::AnimationFrame)); + messages.push(BroadcastMessage::TriggerEvent(EventMessage::AnimationFrame).into()); + + handle.dispatch(Message::Batched { messages: messages.into() }); }); } diff --git a/node-graph/graph-craft/src/wasm_application_io.rs b/node-graph/graph-craft/src/wasm_application_io.rs index 9a956d52b6..81e3a2b4b8 100644 --- a/node-graph/graph-craft/src/wasm_application_io.rs +++ b/node-graph/graph-craft/src/wasm_application_io.rs @@ -332,24 +332,37 @@ pub type WasmSurfaceHandle = SurfaceHandle; #[cfg(feature = "wgpu")] pub type WasmSurfaceHandleFrame = graphene_application_io::SurfaceHandleFrame; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, specta::Type, serde::Serialize, serde::Deserialize)] +pub enum VelloPreference { + Auto, + Disabled, +} + #[derive(Clone, Debug, PartialEq, Hash, specta::Type, serde::Serialize, serde::Deserialize)] pub struct EditorPreferences { - pub use_vello: bool, + pub vello_preference: VelloPreference, + /// Maximum render region size in pixels (area = width * height). Default: 2,073,600 (1080p area) + pub max_render_region_size: u32, } impl graphene_application_io::GetEditorPreferences for EditorPreferences { fn use_vello(&self) -> bool { - self.use_vello + match self.vello_preference { + VelloPreference::Auto => wgpu_available().unwrap_or(false), + VelloPreference::Disabled => false, + } + } + + fn max_render_region_size(&self) -> u32 { + self.max_render_region_size } } impl Default for EditorPreferences { fn default() -> Self { Self { - #[cfg(target_family = "wasm")] - use_vello: false, - #[cfg(not(target_family = "wasm"))] - use_vello: true, + vello_preference: VelloPreference::Auto, + max_render_region_size: 1920 * 1080, } } } diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 41f4e15515..8c1783c546 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -125,7 +125,10 @@ async fn main() -> Result<(), Box> { let wgpu_executor_ref = application_io_arc.gpu_executor().unwrap(); let device = wgpu_executor_ref.context.device.clone(); - let preferences = EditorPreferences { use_vello: true }; + let preferences = EditorPreferences { + vello_preference: graph_craft::wasm_application_io::VelloPreference::Auto, + max_render_region_size: 1920 * 1080, + }; let editor_api = Arc::new(WasmEditorApi { font_cache: FontCache::default(), application_io: Some(application_io_for_api), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index d98adf2066..4ce5ac565d 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -140,6 +140,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Arc, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate, Context => graphene_std::ContextFeatures]), + async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderOutput, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => WgpuSurface, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Option, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => WindowHandle, Context => graphene_std::ContextFeatures]), diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 562e70456a..8ecf5bf18e 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 NodeGraphUpdateSender for std::sync::Mutex { pub trait GetEditorPreferences { fn use_vello(&self) -> bool; + fn max_render_region_size(&self) -> u32; } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] @@ -261,6 +262,10 @@ impl GetEditorPreferences for DummyPreferences { fn use_vello(&self) -> bool { false } + + fn max_render_region_size(&self) -> u32 { + 1920 * 1080 // 2,073,600 pixels (1080p area) + } } pub struct EditorApi { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 56646b878a..8936690b5e 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -257,6 +257,16 @@ impl RenderMetadata { value.transform = transform * value.transform; } } + + /// Merge another RenderMetadata into this one. + /// Values from `other` take precedence for duplicate keys. + pub fn merge(&mut self, other: &RenderMetadata) { + self.upstream_footprints.extend(other.upstream_footprints.iter().map(|(k, v)| (*k, *v))); + self.local_transforms.extend(other.local_transforms.iter().map(|(k, v)| (*k, *v))); + self.first_element_source_id.extend(other.first_element_source_id.iter().map(|(k, v)| (*k, *v))); + self.click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); + self.clip_targets.extend(other.clip_targets.iter().copied()); + } } // TODO: Rename to "Graphical" diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 39e8eebb93..6555d89224 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -50,6 +50,7 @@ node-macro = { workspace = true } reqwest = { workspace = true } image = { workspace = true } base64 = { workspace = true } +wgpu = { workspace = true } # Optional workspace dependencies wasm-bindgen = { workspace = true, optional = true } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index aab8f31dc3..f524ad2768 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -1,4 +1,5 @@ pub mod any; +pub mod render_cache; pub mod render_node; pub mod text; #[cfg(feature = "wasm")] diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs new file mode 100644 index 0000000000..9c5d2d7902 --- /dev/null +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -0,0 +1,582 @@ +//! Tile-based render caching for efficient viewport panning. + +use core_types::math::bbox::AxisAlignedBbox; +use core_types::transform::{Footprint, RenderQuality, Transform}; +use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; +use glam::{DVec2, IVec2, UVec2}; +use graph_craft::document::value::RenderOutput; +use graph_craft::wasm_application_io::WasmEditorApi; +use graphene_application_io::{ApplicationIo, ImageTexture}; +use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; +use std::collections::HashSet; +use std::hash::Hash; +use std::sync::{Arc, Mutex}; + +use crate::render_node::RenderOutputType; + +pub const TILE_SIZE: u32 = 256; +pub const MAX_CACHE_MEMORY_BYTES: usize = 512 * 1024 * 1024; +const BYTES_PER_PIXEL: usize = 4; + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub struct TileCoord { + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone)] +pub struct CachedRegion { + pub texture: wgpu::Texture, + pub texture_size: UVec2, + pub world_bounds: AxisAlignedBbox, + pub tiles: Vec, + pub metadata: rendering::RenderMetadata, + last_access: u64, + memory_size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CacheKey { + pub render_mode_hash: u64, + pub hide_artboards: bool, + pub for_export: bool, + pub for_mask: bool, + pub thumbnail: bool, + pub aligned_strokes: bool, + pub override_paint_order: bool, + pub animation_time_ms: i64, + pub real_time_ms: i64, + pub pointer: [u8; 16], +} + +impl CacheKey { + pub fn new( + render_mode_hash: u64, + hide_artboards: bool, + for_export: bool, + for_mask: bool, + thumbnail: bool, + aligned_strokes: bool, + override_paint_order: bool, + animation_time: f64, + real_time: f64, + pointer: Option, + ) -> Self { + let pointer_bytes = pointer + .map(|p| { + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&p.x.to_le_bytes()); + bytes[8..].copy_from_slice(&p.y.to_le_bytes()); + bytes + }) + .unwrap_or([0u8; 16]); + Self { + render_mode_hash, + hide_artboards, + for_export, + for_mask, + thumbnail, + aligned_strokes, + override_paint_order, + animation_time_ms: (animation_time * 1000.0).round() as i64, + real_time_ms: (real_time * 1000.0).round() as i64, + pointer: pointer_bytes, + } + } +} + +impl Default for CacheKey { + fn default() -> Self { + Self { + render_mode_hash: 0, + hide_artboards: false, + for_export: false, + for_mask: false, + thumbnail: false, + aligned_strokes: false, + override_paint_order: false, + animation_time_ms: 0, + real_time_ms: 0, + pointer: [0u8; 16], + } + } +} + +#[derive(Debug)] +struct TileCacheImpl { + regions: Vec, + timestamp: u64, + total_memory: usize, + cache_key: CacheKey, + current_scale: f64, +} + +impl Default for TileCacheImpl { + fn default() -> Self { + Self { + regions: Vec::new(), + timestamp: 0, + total_memory: 0, + cache_key: CacheKey::default(), + current_scale: 0.0, + } + } +} + +#[derive(Clone, Default, dyn_any::DynAny, Debug)] +pub struct TileCache(Arc>); + +#[derive(Debug, Clone)] +pub struct RenderRegion { + pub world_bounds: AxisAlignedBbox, + pub tiles: Vec, + pub scale: f64, +} + +#[derive(Debug)] +pub struct CacheQuery { + pub cached_regions: Vec, + pub missing_regions: Vec, +} + +pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, scale: f64) -> Vec { + let pixel_start = bounds.start * scale; + let pixel_end = bounds.end * scale; + let tile_start_x = (pixel_start.x / TILE_SIZE as f64).floor() as i32; + let tile_start_y = (pixel_start.y / TILE_SIZE as f64).floor() as i32; + let tile_end_x = (pixel_end.x / TILE_SIZE as f64).ceil() as i32; + let tile_end_y = (pixel_end.y / TILE_SIZE as f64).ceil() as i32; + + let mut tiles = Vec::new(); + for y in tile_start_y..tile_end_y { + for x in tile_start_x..tile_end_x { + tiles.push(TileCoord { x, y }); + } + } + tiles +} + +#[inline] +pub fn tile_world_start(tile: &TileCoord, scale: f64) -> DVec2 { + DVec2::new(tile.x as f64, tile.y as f64) * (TILE_SIZE as f64 / scale) +} + +pub fn tile_to_world_bounds(coord: &TileCoord, scale: f64) -> AxisAlignedBbox { + let tile_world_size = TILE_SIZE as f64 / scale; + let start = tile_world_start(coord, scale); + AxisAlignedBbox { + start, + end: start + DVec2::splat(tile_world_size), + } +} + +pub fn tiles_to_world_bounds(tiles: &[TileCoord], scale: f64) -> AxisAlignedBbox { + if tiles.is_empty() { + return AxisAlignedBbox::ZERO; + } + let mut result = tile_to_world_bounds(&tiles[0], scale); + for tile in &tiles[1..] { + result = result.union(&tile_to_world_bounds(tile, scale)); + } + result +} + +impl TileCacheImpl { + fn query(&mut self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey, max_region_size: u32) -> CacheQuery { + if &self.cache_key != cache_key || (self.current_scale - scale).abs() > 0.001 { + self.invalidate_all(); + self.cache_key = cache_key.clone(); + self.current_scale = scale; + } + + let required_tiles = world_bounds_to_tiles(viewport_bounds, scale); + let required_tile_set: HashSet<_> = required_tiles.iter().cloned().collect(); + let mut cached_regions = Vec::new(); + let mut covered_tiles = HashSet::new(); + + for region in &mut self.regions { + let region_tiles: HashSet<_> = region.tiles.iter().cloned().collect(); + if region_tiles.iter().any(|t| required_tile_set.contains(t)) { + region.last_access = self.timestamp; + self.timestamp += 1; + cached_regions.push(region.clone()); + covered_tiles.extend(region_tiles); + } + } + + let missing_tiles: Vec<_> = required_tiles.into_iter().filter(|t| !covered_tiles.contains(t)).collect(); + let missing_regions = group_into_regions(&missing_tiles, scale, max_region_size); + CacheQuery { cached_regions, missing_regions } + } + + fn store_regions(&mut self, new_regions: Vec) { + 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; + } + } + } + + fn invalidate_all(&mut self) { + for region in &self.regions { + region.texture.destroy(); + } + self.regions.clear(); + self.total_memory = 0; + } +} + +impl TileCache { + pub fn query(&self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey, max_region_size: u32) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, scale, cache_key, max_region_size) + } + + pub fn store_regions(&self, regions: Vec) { + self.0.lock().unwrap().store_regions(regions); + } +} + +fn group_into_regions(tiles: &[TileCoord], scale: f64, max_region_size: u32) -> Vec { + if tiles.is_empty() { + return Vec::new(); + } + + let tile_set: HashSet<_> = tiles.iter().cloned().collect(); + let mut visited = HashSet::new(); + let mut regions = Vec::new(); + + for &tile in tiles { + if visited.contains(&tile) { + continue; + } + let region_tiles = flood_fill(&tile, &tile_set, &mut visited); + let world_bounds = tiles_to_world_bounds(®ion_tiles, scale); + let region = RenderRegion { + world_bounds, + tiles: region_tiles, + scale, + }; + regions.extend(split_oversized_region(region, scale, max_region_size)); + } + regions +} + +/// Recursively subdivides a region until all sub-regions have area <= max_region_size. +/// Uses axis-aligned splits on the longest dimension. +fn split_oversized_region(region: RenderRegion, scale: f64, max_region_size: u32) -> Vec { + let pixel_size = region.world_bounds.size() * scale; + let area = (pixel_size.x * pixel_size.y) as u32; + + // Base case: region is small enough + if area <= max_region_size { + return vec![region]; + } + + // Determine split axis: choose the longer dimension + let split_horizontally = pixel_size.x > pixel_size.y; + + // Split tiles into two groups based on midpoint + let mut group1 = Vec::new(); + let mut group2 = Vec::new(); + + if split_horizontally { + // Find midpoint X in tile coordinates + let min_x = region.tiles.iter().map(|t| t.x).min().unwrap(); + let max_x = region.tiles.iter().map(|t| t.x).max().unwrap(); + let mid_x = (min_x + max_x) / 2; + + for &tile in ®ion.tiles { + if tile.x <= mid_x { + group1.push(tile); + } else { + group2.push(tile); + } + } + } else { + // Split vertically - find midpoint Y + let min_y = region.tiles.iter().map(|t| t.y).min().unwrap(); + let max_y = region.tiles.iter().map(|t| t.y).max().unwrap(); + let mid_y = (min_y + max_y) / 2; + + for &tile in ®ion.tiles { + if tile.y <= mid_y { + group1.push(tile); + } else { + group2.push(tile); + } + } + } + + // Edge case: if split produces empty group, return as-is (can't split further) + if group1.is_empty() || group2.is_empty() { + return vec![region]; + } + + // Create sub-regions and recursively subdivide + let mut result = Vec::new(); + for tiles in [group1, group2] { + if !tiles.is_empty() { + let sub_region = RenderRegion { + world_bounds: tiles_to_world_bounds(&tiles, scale), + tiles, + scale, + }; + result.extend(split_oversized_region(sub_region, scale, max_region_size)); + } + } + + result +} + +fn flood_fill(start: &TileCoord, tile_set: &HashSet, visited: &mut HashSet) -> Vec { + let mut result = Vec::new(); + let mut stack = vec![*start]; + + while let Some(current) = stack.pop() { + if visited.contains(¤t) || !tile_set.contains(¤t) { + continue; + } + visited.insert(current); + result.push(current); + + for neighbor in [ + TileCoord { x: current.x - 1, y: current.y }, + TileCoord { x: current.x + 1, y: current.y }, + TileCoord { x: current.x, y: current.y - 1 }, + TileCoord { x: current.x, y: current.y + 1 }, + ] { + if tile_set.contains(&neighbor) && !visited.contains(&neighbor) { + stack.push(neighbor); + } + } + } + result +} + +#[node_macro::node(category(""))] +pub async fn render_output_cache<'a: 'n>( + ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + ExtractPointerPosition + Sync, + editor_api: &'a WasmEditorApi, + data: impl Node, Output = RenderOutput> + Send + Sync, + #[data] tile_cache: TileCache, +) -> RenderOutput { + let footprint = ctx.footprint(); + let Some(render_params) = ctx.vararg(0).ok().and_then(|v| v.downcast_ref::()) else { + log::warn!("render_output_cache: missing or invalid render params, falling back to direct render"); + let context = OwnedContextImpl::empty().with_footprint(*footprint); + return data.eval(context.into_context()).await; + }; + + // Fall back to direct render for non-Vello or zero-size viewports + let physical_resolution = footprint.resolution; + if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || physical_resolution.x == 0 || physical_resolution.y == 0 { + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + return data.eval(context.into_context()).await; + } + + let logical_scale = footprint.decompose_scale().x; + let device_scale = render_params.scale; + let physical_scale = logical_scale * device_scale; + let viewport_bounds = footprint.viewport_bounds_in_local_space(); + + let cache_key = CacheKey::new( + render_params.render_mode as u64, + render_params.hide_artboards, + render_params.for_export, + render_params.for_mask, + render_params.thumbnail, + render_params.aligned_strokes, + render_params.override_paint_order, + ctx.try_animation_time().unwrap_or(0.0), + ctx.try_real_time().unwrap_or(0.0), + ctx.try_pointer_position(), + ); + + let max_region_size = editor_api.editor_preferences.max_render_region_size(); + let cache_query = tile_cache.query(&viewport_bounds, logical_scale, &cache_key, max_region_size); + + let mut new_regions = Vec::new(); + for missing_region in &cache_query.missing_regions { + if missing_region.tiles.is_empty() { + continue; + } + let region = render_missing_region(missing_region, |ctx| data.eval(ctx), ctx.clone(), render_params, logical_scale, device_scale).await; + new_regions.push(region); + } + + tile_cache.store_regions(new_regions.clone()); + + let all_regions: Vec<_> = cache_query.cached_regions.into_iter().chain(new_regions.into_iter()).collect(); + + // If no regions, fall back to direct render + if all_regions.is_empty() { + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + return data.eval(context.into_context()).await; + } + + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + let (output_texture, combined_metadata) = composite_cached_regions(&all_regions, &viewport_bounds, physical_resolution, logical_scale, physical_scale, exec); + + RenderOutput { + data: RenderOutputType::Texture(ImageTexture { texture: output_texture }), + metadata: combined_metadata, + } +} + +async fn render_missing_region( + region: &RenderRegion, + render_fn: F, + ctx: impl Ctx + ExtractAll + CloneVarArgs, + render_params: &RenderParams, + logical_scale: f64, + device_scale: f64, +) -> CachedRegion +where + F: Fn(Context<'static>) -> Fut, + Fut: std::future::Future, +{ + let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + let max_tile = region.tiles.iter().fold(IVec2::new(i32::MIN, i32::MIN), |acc, t| acc.max(IVec2::new(t.x, t.y))); + + let tile_world_size = TILE_SIZE as f64 / logical_scale; + let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); + + // Calculate pixel size from tile boundaries to avoid rounding gaps + // Use round() on boundaries to ensure adjacent tiles share the same edge + let pixel_start = (min_tile.as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2(); + let pixel_end = ((max_tile + IVec2::ONE).as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2(); + let region_pixel_size = (pixel_end - pixel_start).max(IVec2::ONE).as_uvec2(); + + let region_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-region_world_start); + let region_footprint = Footprint { + transform: region_transform, + resolution: region_pixel_size, + quality: RenderQuality::Full, + }; + + let region_params = render_params.clone(); + 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 { + unreachable!("render_missing_region: expected texture output from Vello render"); + }; + + // Transform metadata from region pixel space to document space + let pixel_to_document = glam::DAffine2::from_translation(region_world_start) * glam::DAffine2::from_scale(DVec2::splat(1.0 / logical_scale)); + result.metadata.apply_transform(pixel_to_document); + + let memory_size = (region_pixel_size.x * region_pixel_size.y) as usize * BYTES_PER_PIXEL; + + CachedRegion { + texture: rendered_texture.texture, + texture_size: region_pixel_size, + world_bounds: region.world_bounds.clone(), + tiles: region.tiles.clone(), + metadata: result.metadata, + last_access: 0, + memory_size, + } +} + +fn composite_cached_regions( + regions: &[CachedRegion], + viewport_bounds: &AxisAlignedBbox, + output_resolution: UVec2, + logical_scale: f64, + physical_scale: f64, + exec: &wgpu_executor::WgpuExecutor, +) -> (wgpu::Texture, rendering::RenderMetadata) { + let device = &exec.context.device; + let queue = &exec.context.queue; + + // TODO: Use texture pool to reuse existing unused textures instead of allocating fresh ones every time + let output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("viewport_output"), + size: wgpu::Extent3d { + width: output_resolution.x, + height: output_resolution.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: &[], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("composite") }); + let mut combined_metadata = rendering::RenderMetadata::default(); + + // Calculate viewport pixel offset using round() to match region boundary calculations + let device_scale = physical_scale / logical_scale; + let viewport_pixel_start = (viewport_bounds.start * physical_scale).round().as_ivec2(); + + for region in regions { + let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + + // Use round() on tile boundaries to match render_missing_region calculation + let region_pixel_start = (min_tile.as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2(); + let offset_pixels = region_pixel_start - viewport_pixel_start; + + let (src_x, dst_x, width) = if offset_pixels.x >= 0 { + (0, offset_pixels.x as u32, region.texture_size.x.min(output_resolution.x.saturating_sub(offset_pixels.x as u32))) + } else { + let skip = (-offset_pixels.x) as u32; + (skip, 0, region.texture_size.x.saturating_sub(skip).min(output_resolution.x)) + }; + + let (src_y, dst_y, height) = if offset_pixels.y >= 0 { + (0, offset_pixels.y as u32, region.texture_size.y.min(output_resolution.y.saturating_sub(offset_pixels.y as u32))) + } else { + let skip = (-offset_pixels.y) as u32; + (skip, 0, region.texture_size.y.saturating_sub(skip).min(output_resolution.y)) + }; + + if width > 0 && height > 0 { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: ®ion.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &output_texture, + mip_level: 0, + origin: wgpu::Origin3d { x: dst_x, y: dst_y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + } + + // Transform metadata from document space to viewport logical pixels + let mut region_metadata = region.metadata.clone(); + let document_to_viewport = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-viewport_bounds.start); + region_metadata.apply_transform(document_to_viewport); + combined_metadata.merge(®ion_metadata); + } + + queue.submit([encoder.finish()]); + (output_texture, combined_metadata) +} diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 01972d040c..735cb4af4a 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -18,6 +18,9 @@ use std::sync::Arc; use vector_types::GradientStops; 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>; @@ -28,9 +31,9 @@ pub enum RenderIntermediateType { } #[derive(Clone, dyn_any::DynAny)] pub struct RenderIntermediate { - ty: RenderIntermediateType, - metadata: RenderMetadata, - contains_artboard: bool, + pub(crate) ty: RenderIntermediateType, + pub(crate) metadata: RenderMetadata, + pub(crate) contains_artboard: bool, } #[node_macro::node(category(""))]