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/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index b6bc9b63ae..c33e42b3dd 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::Arc; use super::utility_types::misc::{GroupFolderType, SnappingState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; @@ -203,7 +204,7 @@ pub enum DocumentMessage { first_element_source_id: HashMap>, }, UpdateClickTargets { - click_targets: HashMap>, + click_targets: HashMap>>, }, UpdateClipTargets { clip_targets: HashSet, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index e38597a682..c807b60a36 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -42,6 +42,7 @@ use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; use graphene_std::vector::style::RenderMode; use kurbo::{Affine, CubicBez, Line, ParamCurve, PathSeg, QuadBez}; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; #[derive(ExtractField)] @@ -3071,7 +3072,14 @@ impl<'a> ClickXRayIter<'a> { } /// Handles the checking of the layer where the target is a rect or path - fn check_layer_area_target(&mut self, click_targets: Option<&Vec>, clip: bool, layer: LayerNodeIdentifier, path: Vec, transform: DAffine2) -> XRayResult { + fn check_layer_area_target( + &mut self, + click_targets: Option<&[Arc]>, + clip: bool, + layer: LayerNodeIdentifier, + path: Vec, + transform: DAffine2, + ) -> XRayResult { // Convert back to Kurbo types for intersections let segment = |bezier: &path_bool_lib::PathSegment| match *bezier { path_bool_lib::PathSegment::Line(start, end) => PathSeg::Line(Line::new(dvec2_to_point(start), dvec2_to_point(end))), @@ -3088,7 +3096,7 @@ impl<'a> ClickXRayIter<'a> { // In the case of a clip path where the area partially intersects, it is necessary to do a boolean operation. // We do this on this using the target area to reduce computation (as the target area is usually very simple). if clip && intersects { - let clip_path = click_targets_to_path_lib_segments(click_targets.iter().flat_map(|x| x.iter()), transform); + let clip_path = click_targets_to_path_lib_segments(click_targets.iter().flat_map(|x| x.iter()).map(|x| x.as_ref()), transform); let subtracted = boolean_intersect(path, clip_path).into_iter().flatten().collect::>(); if subtracted.is_empty() { use_children = false; diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 4188eff338..6cea963ba1 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -13,6 +13,7 @@ use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::{PointId, Vector}; use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; +use std::sync::Arc; // ================ // DocumentMetadata @@ -26,7 +27,7 @@ pub struct DocumentMetadata { pub local_transforms: HashMap, pub first_element_source_ids: HashMap>, pub structure: HashMap, - pub click_targets: HashMap>, + pub click_targets: HashMap>>, pub clip_targets: HashSet, pub vector_modify: HashMap, /// Transform from document space to viewport space. @@ -46,8 +47,8 @@ impl DocumentMetadata { self.structure.contains_key(&layer) } - pub fn click_targets(&self, layer: LayerNodeIdentifier) -> Option<&Vec> { - self.click_targets.get(&layer) + pub fn click_targets(&self, layer: LayerNodeIdentifier) -> Option<&[Arc]> { + self.click_targets.get(&layer).map(|x| x.as_slice()) } /// Access the [`NodeRelations`] of a layer. @@ -206,7 +207,7 @@ impl DocumentMetadata { } pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator> { - static EMPTY: Vec = Vec::new(); + static EMPTY: Vec> = Vec::new(); let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY); click_targets.iter().filter_map(|target| match target.target_type() { ClickTargetType::Subpath(subpath) => Some(subpath), @@ -215,7 +216,7 @@ impl DocumentMetadata { } pub fn layer_with_free_points_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator { - static EMPTY: Vec = Vec::new(); + static EMPTY: Vec> = Vec::new(); let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY); click_targets.iter().map(|target| target.target_type()) } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 23ab3c64f2..cf6e487bc4 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -31,6 +31,7 @@ use serde_json::{Value, json}; use std::collections::{HashMap, HashSet, VecDeque}; use std::hash::Hash; use std::ops::Deref; +use std::sync::Arc; /// All network modifications should be done through this API, so the fields cannot be public. However, all fields within this struct can be public since it it not possible to have a public mutable reference. #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] @@ -3078,7 +3079,7 @@ impl NodeNetworkInterface { self.document_metadata .click_targets .get(&layer) - .map(|click| click.iter().map(ClickTarget::target_type)) + .map(|click| click.iter().map(|x| x.target_type())) .map(|target_types| Vector::from_target_types(target_types, true)) } @@ -3180,7 +3181,7 @@ impl NodeNetworkInterface { } /// Update the cached click targets of the layers - pub fn update_click_targets(&mut self, new_click_targets: HashMap>) { + pub fn update_click_targets(&mut self, new_click_targets: HashMap>>) { self.document_metadata.click_targets = new_click_targets; } 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..de9b57ba9b 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, pub local_transforms: HashMap, pub first_element_source_id: HashMap>, - pub click_targets: HashMap>, + pub click_targets: HashMap>>, pub clip_targets: HashSet, } @@ -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" @@ -471,7 +481,7 @@ impl Render for Artboard { fn collect_metadata(&self, metadata: &mut RenderMetadata, mut footprint: Footprint, element_id: Option) { if let Some(element_id) = element_id { let subpath = Subpath::new_rectangle(DVec2::ZERO, self.dimensions.as_dvec2()); - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); metadata.upstream_footprints.insert(element_id, footprint); metadata.local_transforms.insert(element_id, DAffine2::from_translation(self.location.as_dvec2())); if self.clip { @@ -666,7 +676,7 @@ impl Render for Table { all_upstream_click_targets.extend(new_click_targets); } - metadata.click_targets.insert(element_id, all_upstream_click_targets); + metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); } } @@ -1173,7 +1183,7 @@ impl Render for Table { let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default(); let point = FreePoint::new(point_id, anchor); - Some(ClickTarget::new_with_free_point(point)) + Some(ClickTarget::new_with_free_point(point).into()) } else { None } @@ -1182,9 +1192,9 @@ impl Render for Table { let click_targets = vector .stroke_bezier_paths() .map(fill) - .map(|subpath| ClickTarget::new_with_subpath(subpath, stroke_width)) + .map(|subpath| ClickTarget::new_with_subpath(subpath, stroke_width).into()) .chain(single_anchors_targets.into_iter()) - .collect::>(); + .collect::>(); metadata.click_targets.entry(element_id).or_insert(click_targets); } @@ -1366,7 +1376,7 @@ impl Render for Table> { let Some(element_id) = element_id else { return }; let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than one row of the raster table if let Some(raster) = self.iter().next() { @@ -1426,7 +1436,7 @@ impl Render for Table> { let Some(element_id) = element_id else { return }; let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than one row of the raster table if let Some(raster) = self.iter().next() { 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..2d93761645 --- /dev/null +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -0,0 +1,529 @@ +//! 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::{HashMap, 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; +pub const MAX_REGION_DIMENSION: u32 = 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) -> 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); + 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) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, scale, cache_key) + } + + pub fn store_regions(&self, regions: Vec) { + self.0.lock().unwrap().store_regions(regions); + } +} + +fn group_into_regions(tiles: &[TileCoord], scale: f64) -> 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)); + } + regions +} + +fn split_oversized_region(region: RenderRegion, scale: f64) -> Vec { + let pixel_size = region.world_bounds.size() * scale; + if pixel_size.x <= MAX_REGION_DIMENSION as f64 && pixel_size.y <= MAX_REGION_DIMENSION as f64 { + return vec![region]; + } + + let max_tiles_per_dimension = (MAX_REGION_DIMENSION / TILE_SIZE) as i32; + let mut chunks: HashMap<(i32, i32), Vec> = HashMap::new(); + + for &tile in ®ion.tiles { + let chunk_x = tile.x.div_euclid(max_tiles_per_dimension); + let chunk_y = tile.y.div_euclid(max_tiles_per_dimension); + chunks.entry((chunk_x, chunk_y)).or_default().push(tile); + } + + chunks + .into_iter() + .map(|(_, tiles)| RenderRegion { + world_bounds: tiles_to_world_bounds(&tiles, scale), + tiles, + scale, + }) + .collect() +} + +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 render_params = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type"); + + if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) { + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + return data.eval(context.into_context()).await; + } + + let physical_resolution = footprint.resolution; + 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 cache_query = tile_cache.query(&viewport_bounds, logical_scale, &cache_key); + + let mut new_regions = Vec::new(); + for missing_region in &cache_query.missing_regions { + 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(); + 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).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 mut region_params = render_params.clone(); + region_params.footprint = region_footprint; + 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 { + panic!("Expected texture output from 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; + + 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(""))]