From 2d9ad96a2b8a83b72e5f9ab82215366c710b66cf Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 1 Jan 2026 17:35:52 +0100 Subject: [PATCH 1/9] WIP render caching --- Cargo.lock | 1 + node-graph/nodes/gstd/Cargo.toml | 1 + node-graph/nodes/gstd/src/lib.rs | 1 + node-graph/nodes/gstd/src/render_cache.rs | 757 ++++++++++++++++++++++ node-graph/nodes/gstd/src/render_node.rs | 9 +- 5 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 node-graph/nodes/gstd/src/render_cache.rs 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/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..62d3bf4ffa --- /dev/null +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -0,0 +1,757 @@ +use core_types::math::bbox::AxisAlignedBbox; +use core_types::transform::{Footprint, RenderQuality}; +use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractFootprint, ExtractVarArgs, Node, OwnedContextImpl}; +use glam::{DVec2, IVec2, UVec2}; +use graph_craft::document::value::RenderOutput; +use graph_craft::wasm_application_io::WasmEditorApi; +use graphene_application_io::ImageTexture; +use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; +use wgpu_executor::RenderContext; + +use crate::render_node::{RenderIntermediate, RenderIntermediateType, RenderOutputType}; + +// Constants +pub const TILE_SIZE: u32 = 256; +pub const MAX_CACHE_MEMORY_BYTES: usize = 512 * 1024 * 1024; // 512MB +pub const ZOOM_BUCKET_STOPS: f64 = 0.25; // Quantize to 0.25 zoom stops +pub const MAX_REGION_DIMENSION: u32 = 4096; // 16 tiles max per dimension +const BYTES_PER_PIXEL: usize = 4; // RGBA8Unorm + +// Tile coordinate in world-space grid at specific zoom +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub struct TileCoord { + pub x: i32, + pub y: i32, + pub zoom_bucket: i32, +} + +// Single cached tile +#[derive(Debug, Clone)] +pub struct CachedTile { + pub texture: wgpu::Texture, + pub world_bounds: AxisAlignedBbox, + pub zoom_level: f64, + last_access: u64, + memory_size: usize, +} + +// Cache key for invalidation based on RenderParams +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CacheKey { + // Fields from RenderParams that affect rendering output + 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, + // Time fields quantized to milliseconds for Eq/Hash + pub animation_time_ms: i64, + pub real_time_ms: i64, +} + +impl CacheKey { + /// Create a cache key from f64 times (quantizes to milliseconds) + pub fn from_times(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) -> Self { + 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, + } + } +} + +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, + } + } +} + +// Internal cache implementation +#[derive(Debug)] +struct TileCacheImpl { + tiles: HashMap, + access_order: VecDeque<(u64, TileCoord)>, + timestamp: u64, + total_memory: usize, + cache_key: CacheKey, +} + +impl Default for TileCacheImpl { + fn default() -> Self { + Self { + tiles: HashMap::new(), + access_order: VecDeque::new(), + timestamp: 0, + total_memory: 0, + cache_key: CacheKey::default(), + } + } +} + +// Public thread-safe wrapper +#[derive(Clone, Default, dyn_any::DynAny, Debug)] +pub struct TileCache(Arc>); + +// Contiguous region to render +#[derive(Debug, Clone)] +pub struct RenderRegion { + pub world_bounds: AxisAlignedBbox, + pub tiles: Vec, + pub zoom_level: f64, +} + +// Cache query result +#[derive(Debug)] +pub struct CacheQuery { + pub cached_tiles: Vec, + pub missing_regions: Vec, +} + +// Coordinate conversion functions + +/// Quantize zoom level to reduce cache fragmentation +pub fn quantize_zoom(zoom_level: f64) -> i32 { + (zoom_level / ZOOM_BUCKET_STOPS).round() as i32 +} + +/// Convert world-space bounds to tile coordinates at given zoom +pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, zoom_level: f64) -> Vec { + let zoom_bucket = quantize_zoom(zoom_level); + let pixels_per_world_unit = zoom_level.exp2(); // 2^zoom + + // Convert world bounds to pixel bounds + let pixel_start = bounds.start * pixels_per_world_unit; + let pixel_end = bounds.end * pixels_per_world_unit; + + // Convert to tile grid coordinates + let tile_start = IVec2::new((pixel_start.x / TILE_SIZE as f64).floor() as i32, (pixel_start.y / TILE_SIZE as f64).floor() as i32); + let tile_end = IVec2::new((pixel_end.x / TILE_SIZE as f64).ceil() as i32, (pixel_end.y / TILE_SIZE as f64).ceil() as i32); + + // Generate all tile coordinates in range + 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, zoom_bucket }); + } + } + tiles +} + +/// Convert tile coordinate back to world-space bounds +pub fn tile_to_world_bounds(coord: &TileCoord, actual_zoom: f64) -> AxisAlignedBbox { + let pixels_per_world_unit = actual_zoom.exp2(); + let world_units_per_pixel = 1.0 / pixels_per_world_unit; + + let pixel_start = DVec2::new((coord.x as f64) * (TILE_SIZE as f64), (coord.y as f64) * (TILE_SIZE as f64)); + let pixel_end = pixel_start + DVec2::splat(TILE_SIZE as f64); + + AxisAlignedBbox { + start: pixel_start * world_units_per_pixel, + end: pixel_end * world_units_per_pixel, + } +} + +/// Get bounding box of multiple tiles in world space +pub fn tiles_to_world_bounds(tiles: &[TileCoord], zoom_level: f64) -> AxisAlignedBbox { + if tiles.is_empty() { + return AxisAlignedBbox::ZERO; + } + + let mut result = tile_to_world_bounds(&tiles[0], zoom_level); + for tile in &tiles[1..] { + let bounds = tile_to_world_bounds(tile, zoom_level); + result = result.union(&bounds); + } + result +} + +// Cache implementation + +impl TileCacheImpl { + /// Query cache for viewport bounds at given zoom level + fn query(&mut self, viewport_bounds: &AxisAlignedBbox, zoom_level: f64, cache_key: &CacheKey) -> CacheQuery { + // Check if cache needs invalidation + if &self.cache_key != cache_key { + self.invalidate_all(); + self.cache_key = cache_key.clone(); + } + + let required_tiles = world_bounds_to_tiles(viewport_bounds, zoom_level); + let mut cached_tiles = Vec::new(); + let mut missing_tiles = Vec::new(); + + for tile_coord in required_tiles { + if let Some(cached) = self.tiles.get_mut(&tile_coord) { + // Update LRU + cached.last_access = self.timestamp; + self.timestamp += 1; + self.access_order.push_back((cached.last_access, tile_coord)); + cached_tiles.push(cached.clone()); + } else { + missing_tiles.push(tile_coord); + } + } + + // Group missing tiles into contiguous regions (will be implemented next) + let missing_regions = group_into_regions(&missing_tiles, zoom_level); + + CacheQuery { cached_tiles, missing_regions } + } + + /// Store newly rendered tiles + fn store_tiles(&mut self, new_tiles: Vec<(TileCoord, CachedTile)>) { + for (coord, mut tile) in new_tiles { + tile.last_access = self.timestamp; + self.timestamp += 1; + + self.total_memory += tile.memory_size; + self.access_order.push_back((tile.last_access, coord)); + self.tiles.insert(coord, tile); + } + + // Evict old tiles if over memory limit + self.evict_until_under_budget(); + } + + /// LRU eviction to stay under memory budget + fn evict_until_under_budget(&mut self) { + while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.access_order.is_empty() { + if let Some((timestamp, coord)) = self.access_order.pop_front() { + // Only remove if this is still the oldest access for this tile + if let Some(tile) = self.tiles.get(&coord) { + if tile.last_access == timestamp { + if let Some(removed) = self.tiles.remove(&coord) { + self.total_memory = self.total_memory.saturating_sub(removed.memory_size); + } + } + } + } + } + } + + /// Clear all cached tiles + fn invalidate_all(&mut self) { + self.tiles.clear(); + self.access_order.clear(); + self.total_memory = 0; + // Don't reset timestamp - it's monotonic + } +} + +// Public TileCache API +impl TileCache { + pub fn query(&self, viewport_bounds: &AxisAlignedBbox, zoom_level: f64, cache_key: &CacheKey) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, zoom_level, cache_key) + } + + pub fn store_tiles(&self, tiles: Vec<(TileCoord, CachedTile)>) { + self.0.lock().unwrap().store_tiles(tiles); + } +} + +/// Group tiles into contiguous regions using flood-fill, then split oversized regions +fn group_into_regions(tiles: &[TileCoord], zoom_level: 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; + } + + // Flood-fill to find connected region + let region_tiles = flood_fill(&tile, &tile_set, &mut visited); + let world_bounds = tiles_to_world_bounds(®ion_tiles, zoom_level); + + let mut region = RenderRegion { + world_bounds, + tiles: region_tiles, + zoom_level, + }; + + // Split if region exceeds MAX_REGION_DIMENSION + let split_regions = split_oversized_region(region, zoom_level); + regions.extend(split_regions); + } + + regions +} + +/// Split region if it exceeds MAX_REGION_DIMENSION, aligned to tile boundaries +fn split_oversized_region(region: RenderRegion, zoom_level: f64) -> Vec { + let pixels_per_world_unit = zoom_level.exp2(); + let region_size = region.world_bounds.size(); + let pixel_size = region_size * pixels_per_world_unit; + + // Check if region fits within limits + if pixel_size.x <= MAX_REGION_DIMENSION as f64 && pixel_size.y <= MAX_REGION_DIMENSION as f64 { + return vec![region]; + } + + // Calculate how many tiles fit in MAX_REGION_DIMENSION + let max_tiles_per_dimension = (MAX_REGION_DIMENSION / TILE_SIZE) as i32; // Should be 16 + + // Group tiles into grid of chunks + 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); + } + + // Convert chunks into regions + chunks + .into_iter() + .map(|(_, tiles)| { + let world_bounds = tiles_to_world_bounds(&tiles, zoom_level); + RenderRegion { + world_bounds, + tiles, + zoom_level, + } + }) + .collect() +} + +/// Flood-fill to find connected tiles (4-connected neighbors) +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); + + // Check 4-connected neighbors + let neighbors = [ + TileCoord { + x: current.x - 1, + y: current.y, + zoom_bucket: current.zoom_bucket, + }, + TileCoord { + x: current.x + 1, + y: current.y, + zoom_bucket: current.zoom_bucket, + }, + TileCoord { + x: current.x, + y: current.y - 1, + zoom_bucket: current.zoom_bucket, + }, + TileCoord { + x: current.x, + y: current.y + 1, + zoom_bucket: current.zoom_bucket, + }, + ]; + + for neighbor in neighbors { + if tile_set.contains(&neighbor) && !visited.contains(&neighbor) { + stack.push(neighbor); + } + } + } + + result +} + +// Rendering and texture operations + +/// Render a single region to texture using a render function +pub async fn render_region<'a, F, Fut>( + region: &RenderRegion, + render_fn: F, + editor_api: &'a WasmEditorApi, + base_render_params: &RenderParams, + base_ctx: &(impl Ctx + Clone), + contains_artboard: bool, +) -> wgpu::Texture +where + F: FnOnce(Context<'static>) -> Fut, + Fut: std::future::Future, +{ + let region_size = region.world_bounds.size(); + let pixels_per_world_unit = region.zoom_level.exp2(); + let physical_size = UVec2::new((region_size.x * pixels_per_world_unit).ceil() as u32, (region_size.y * pixels_per_world_unit).ceil() as u32); + + // Create footprint for this region + let scale = base_render_params.scale; + let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); + let translation = glam::DAffine2::from_translation(-region.world_bounds.start); + let region_transform = scale_transform * translation; + + let region_footprint = Footprint { + transform: region_transform, + resolution: physical_size, + quality: RenderQuality::Full, + }; + + // Create context with region footprint + let mut region_params = base_render_params.clone(); + region_params.footprint = region_footprint; + + // Build context from base context with new footprint + let region_ctx = OwnedContextImpl::from(base_ctx.clone()) + .with_footprint(region_footprint) + .with_vararg(Box::new(region_params)) + .into_context(); + + // Evaluate render function with region context + let render_intermediate = render_fn(region_ctx).await; + + // Render to texture (similar to existing render node logic) + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().expect("No GPU executor available"); + + match &render_intermediate.ty { + RenderIntermediateType::Vello(vello_data) => { + let (child, context) = Arc::as_ref(vello_data); + + let footprint_transform_vello = vello::kurbo::Affine::new(region_transform.to_cols_array()); + + let mut scene = vello::Scene::new(); + scene.append(child, Some(footprint_transform_vello)); + + // Handle infinite transforms + let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_size.x as f64, physical_size.y as f64); + let encoding = scene.encoding_mut(); + for transform in encoding.transforms.iter_mut() { + if transform.matrix[0] == f32::INFINITY { + *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); + } + } + + let background = if contains_artboard || base_render_params.hide_artboards { + Color::from_rgb8_srgb(0x22, 0x22, 0x22) + } else { + Color::WHITE + }; + + exec.render_vello_scene_to_texture(&scene, physical_size, context, background).await.expect("Failed to render region") + } + _ => panic!("Cache only supports Vello rendering"), + } +} + +/// Split rendered region texture into individual tile textures +pub async fn split_texture_into_tiles<'a>( + region_texture: wgpu::Texture, + region: &RenderRegion, + editor_api: &'a WasmEditorApi, +) -> Vec<(TileCoord, CachedTile)> { + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + let device = &exec.context.device; + let queue = &exec.context.queue; + + let mut tiles = Vec::new(); + let pixels_per_world_unit = region.zoom_level.exp2(); + + for &tile_coord in ®ion.tiles { + // Calculate tile bounds in world and pixel space + let tile_world_bounds = tile_to_world_bounds(&tile_coord, region.zoom_level); + + // Calculate offset within region texture + let offset_in_region = tile_world_bounds.start - region.world_bounds.start; + let pixel_offset = (offset_in_region * pixels_per_world_unit).as_uvec2(); + + // Create tile texture + let tile_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("cached_tile"), + size: wgpu::Extent3d { + width: TILE_SIZE, + height: TILE_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + // Copy tile region from large texture + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_copy") }); + + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: ®ion_texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: pixel_offset.x, + y: pixel_offset.y, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &tile_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: TILE_SIZE, + height: TILE_SIZE, + depth_or_array_layers: 1, + }, + ); + + queue.submit([encoder.finish()]); + + tiles.push(( + tile_coord, + CachedTile { + texture: tile_texture, + world_bounds: tile_world_bounds, + zoom_level: region.zoom_level, + last_access: 0, // Will be set by cache + memory_size: (TILE_SIZE * TILE_SIZE * BYTES_PER_PIXEL as u32) as usize, + }, + )); + } + + tiles +} + +/// Composite cached tiles into final output texture +pub async fn composite_tiles<'a>(cached_tiles: Vec, viewport_bounds: &AxisAlignedBbox, output_resolution: UVec2, editor_api: &'a WasmEditorApi) -> wgpu::Texture { + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + let device = &exec.context.device; + let queue = &exec.context.queue; + + // Create output texture + let output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("composite_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("tile_composite") }); + + let viewport_size = viewport_bounds.size(); + let pixels_per_world_unit = (output_resolution.as_dvec2() / viewport_size).x; // Assuming uniform scaling + + for tile in &cached_tiles { + // Calculate where this tile goes in output texture + let tile_offset_in_viewport = tile.world_bounds.start - viewport_bounds.start; + let pixel_offset = (tile_offset_in_viewport * pixels_per_world_unit).as_uvec2(); + + // Clamp to output bounds (handle edge tiles that might extend beyond viewport) + let copy_width = TILE_SIZE.min(output_resolution.x.saturating_sub(pixel_offset.x)); + let copy_height = TILE_SIZE.min(output_resolution.y.saturating_sub(pixel_offset.y)); + + if copy_width > 0 && copy_height > 0 { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &tile.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &output_texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: pixel_offset.x, + y: pixel_offset.y, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: copy_width, + height: copy_height, + depth_or_array_layers: 1, + }, + ); + } + } + + queue.submit([encoder.finish()]); + output_texture +} + +// Node implementation + +#[node_macro::node(category(""))] +pub async fn render_output_cache<'a: 'n>( + ctx: impl Ctx + ExtractFootprint + ExtractVarArgs + CloneVarArgs, + editor_api: &'a WasmEditorApi, + data: impl Node, Output = RenderIntermediate> + 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"); + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; + let render_params = &render_params; + + // Only cache Vello (GPU) rendering + if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) { + // Fall back to regular rendering for SVG + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + let intermediate = data.eval(context.into_context()).await; + // Convert intermediate to output (simplified SVG path) + return RenderOutput { + data: RenderOutputType::Svg { + svg: String::new(), + image_data: Vec::new(), + }, + metadata: intermediate.metadata, + }; + } + + let scale = render_params.scale; + let physical_resolution = footprint.resolution; + let zoom_level = scale.log2(); + let viewport_bounds = footprint.viewport_bounds_in_local_space(); + + // Evaluate data node once to get intermediate representation + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + let intermediate = data.eval(context.into_context()).await; + let contains_artboard = intermediate.contains_artboard; + + // Compute cache key from render params + let mut hasher = DefaultHasher::new(); + render_params.render_mode.hash(&mut hasher); + let render_mode_hash = hasher.finish(); + + // Extract animation and real time from context + let animation_time = ctx.try_animation_time().unwrap_or(0.0); + let real_time = ctx.try_real_time().unwrap_or(0.0); + + let cache_key = CacheKey::from_times( + render_mode_hash, + render_params.hide_artboards, + render_params.for_export, + render_params.for_mask, + render_params.thumbnail, + render_params.aligned_strokes, + render_params.override_paint_order, + animation_time, + real_time, + ); + + // Query cache for tiles + let query = tile_cache.query(&viewport_bounds, zoom_level, &cache_key); + + // Render missing regions + let mut new_tiles = Vec::new(); + for region in &query.missing_regions { + // Create render closure for this region + let data_clone = data.clone(); + let render_fn = |ctx: Context<'static>| async move { data_clone.eval(ctx).await }; + + let region_texture = render_region(region, render_fn, editor_api, render_params, &ctx, contains_artboard).await; + let tiles = split_texture_into_tiles(region_texture, region, editor_api).await; + new_tiles.extend(tiles); + } + + // Store new tiles in cache + if !new_tiles.is_empty() { + tile_cache.store_tiles(new_tiles.clone()); + } + + // Combine cached and new tiles + let mut all_tiles = query.cached_tiles; + all_tiles.extend(new_tiles.into_iter().map(|(_, tile)| tile)); + + // Composite tiles into final output + let output_texture = composite_tiles(all_tiles, &viewport_bounds, physical_resolution, editor_api).await; + + // Collect metadata + let mut metadata = intermediate.metadata; + metadata.apply_transform(footprint.transform); + + RenderOutput { + data: RenderOutputType::Texture(ImageTexture { texture: output_texture }), + metadata, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quantize_zoom() { + assert_eq!(quantize_zoom(0.0), 0); + assert_eq!(quantize_zoom(0.1), 0); + assert_eq!(quantize_zoom(0.125), 1); + assert_eq!(quantize_zoom(0.25), 1); + assert_eq!(quantize_zoom(0.5), 2); + assert_eq!(quantize_zoom(1.0), 4); + } + + #[test] + fn test_tile_coordinate_conversion() { + let zoom = 2.0; // scale = 4.0 + let coord = TileCoord { x: 0, y: 0, zoom_bucket: quantize_zoom(zoom) }; + let bounds = tile_to_world_bounds(&coord, zoom); + + // At zoom 2.0, scale = 4.0, so 256 pixels = 64 world units + assert_eq!(bounds.start, DVec2::ZERO); + assert_eq!(bounds.end, DVec2::splat(64.0)); + } + + #[test] + fn test_world_to_tiles() { + let zoom = 0.0; // scale = 1.0, 1 pixel = 1 world unit + let bounds = AxisAlignedBbox { + start: DVec2::ZERO, + end: DVec2::new(512.0, 256.0), + }; + let tiles = world_bounds_to_tiles(&bounds, zoom); + + // Should be 2x1 tiles (512x256 pixels) + assert_eq!(tiles.len(), 2); + assert!(tiles.contains(&TileCoord { x: 0, y: 0, zoom_bucket: 0 })); + assert!(tiles.contains(&TileCoord { x: 1, y: 0, zoom_bucket: 0 })); + } +} 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(""))] From ad119f7255313812a0d9061ca92579cd27631f59 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 4 Feb 2026 12:16:28 +0100 Subject: [PATCH 2/9] Hook up render cache to render pipeline --- node-graph/interpreted-executor/src/util.rs | 14 +- node-graph/nodes/gstd/src/render_cache.rs | 976 +++++++++++++------- 2 files changed, 636 insertions(+), 354 deletions(-) diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 562e70456a..081edd4cc4 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, + /// Metadata (click targets, etc.) stored in document space + pub metadata: rendering::RenderMetadata, + /// LRU timestamp for eviction last_access: u64, + /// Memory consumption in bytes memory_size: usize, } @@ -57,7 +167,17 @@ pub struct CacheKey { impl CacheKey { /// Create a cache key from f64 times (quantizes to milliseconds) - pub fn from_times(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) -> Self { + pub fn from_times( + 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, + ) -> Self { Self { render_mode_hash, hide_artboards, @@ -88,24 +208,25 @@ impl Default for CacheKey { } } -// Internal cache implementation +/// Internal cache implementation #[derive(Debug)] struct TileCacheImpl { - tiles: HashMap, - access_order: VecDeque<(u64, TileCoord)>, + regions: Vec, // Stored as Vec since regions can overlap in tile space timestamp: u64, total_memory: usize, cache_key: CacheKey, + /// Current scale (pixels per world unit) - regions are invalidated when this changes + current_scale: f64, } impl Default for TileCacheImpl { fn default() -> Self { Self { - tiles: HashMap::new(), - access_order: VecDeque::new(), + regions: Vec::new(), timestamp: 0, total_memory: 0, cache_key: CacheKey::default(), + current_scale: 0.0, } } } @@ -114,74 +235,108 @@ impl Default for TileCacheImpl { #[derive(Clone, Default, dyn_any::DynAny, Debug)] pub struct TileCache(Arc>); -// Contiguous region to render +/// A contiguous region that needs to be rendered. +/// +/// Created by the cache query when tiles are missing. Groups adjacent +/// missing tiles into a single render operation for efficiency. #[derive(Debug, Clone)] pub struct RenderRegion { + /// Document-space bounds to render pub world_bounds: AxisAlignedBbox, + /// Tiles that this region will cover once rendered pub tiles: Vec, - pub zoom_level: f64, + /// Scale (pixels per world unit) at which to render + pub scale: f64, } // Cache query result #[derive(Debug)] pub struct CacheQuery { - pub cached_tiles: Vec, + pub cached_regions: Vec, pub missing_regions: Vec, } -// Coordinate conversion functions - -/// Quantize zoom level to reduce cache fragmentation -pub fn quantize_zoom(zoom_level: f64) -> i32 { - (zoom_level / ZOOM_BUCKET_STOPS).round() as i32 -} - -/// Convert world-space bounds to tile coordinates at given zoom -pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, zoom_level: f64) -> Vec { - let zoom_bucket = quantize_zoom(zoom_level); - let pixels_per_world_unit = zoom_level.exp2(); // 2^zoom - - // Convert world bounds to pixel bounds - let pixel_start = bounds.start * pixels_per_world_unit; - let pixel_end = bounds.end * pixels_per_world_unit; - - // Convert to tile grid coordinates - let tile_start = IVec2::new((pixel_start.x / TILE_SIZE as f64).floor() as i32, (pixel_start.y / TILE_SIZE as f64).floor() as i32); - let tile_end = IVec2::new((pixel_end.x / TILE_SIZE as f64).ceil() as i32, (pixel_end.y / TILE_SIZE as f64).ceil() as i32); - - // Generate all tile coordinates in range +// ============================================================================ +// COORDINATE CONVERSION FUNCTIONS +// ============================================================================ +// +// All functions use `scale` (pixels per world unit) directly, NOT zoom_level. +// This avoids precision loss from log2/exp2 round-trips. +// +// IMPORTANT: These conversions define how document space maps to the tile grid. +// The tile grid is in PIXEL space, divided into TILE_SIZE × TILE_SIZE squares. + +/// Convert document-space bounds to the tiles that cover them. +/// +/// # Conversion steps: +/// 1. Document bounds → Pixel bounds: `pixel = world * scale` +/// 2. Pixel bounds → Tile range: `tile = floor(pixel / TILE_SIZE)` for start, +/// `tile = ceil(pixel / TILE_SIZE)` for end +/// +/// # Arguments +/// * `bounds` - Bounding box in document (world) space +/// * `scale` - Pixels per world unit +/// +/// # Returns +/// All tiles that intersect the given bounds +pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, scale: f64) -> Vec { + // Step 1: Convert document bounds to pixel bounds + let pixel_start = bounds.start * scale; + let pixel_end = bounds.end * scale; + + // Step 2: Convert pixel bounds to tile grid coordinates + // floor() for start: include any tile that overlaps the start edge + // ceil() for end: include any tile that overlaps the end edge + 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; + + // Generate all tile coordinates in the range [start, end) 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, zoom_bucket }); + for y in tile_start_y..tile_end_y { + for x in tile_start_x..tile_end_x { + tiles.push(TileCoord { x, y }); } } tiles } -/// Convert tile coordinate back to world-space bounds -pub fn tile_to_world_bounds(coord: &TileCoord, actual_zoom: f64) -> AxisAlignedBbox { - let pixels_per_world_unit = actual_zoom.exp2(); - let world_units_per_pixel = 1.0 / pixels_per_world_unit; - - let pixel_start = DVec2::new((coord.x as f64) * (TILE_SIZE as f64), (coord.y as f64) * (TILE_SIZE as f64)); - let pixel_end = pixel_start + DVec2::splat(TILE_SIZE as f64); +/// Get the document-space position of a tile's top-left corner. +/// +/// # Conversion: +/// `world = tile * TILE_SIZE / scale` +/// +/// This is the inverse of the floor operation in world_bounds_to_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) +} +/// Convert a tile coordinate to its document-space bounding box. +/// +/// # Returns +/// The axis-aligned box in document space that this tile covers: +/// - Start: `(tile.x * TILE_SIZE / scale, tile.y * TILE_SIZE / scale)` +/// - End: `((tile.x + 1) * TILE_SIZE / scale, (tile.y + 1) * TILE_SIZE / 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: pixel_start * world_units_per_pixel, - end: pixel_end * world_units_per_pixel, + start, + end: start + DVec2::splat(tile_world_size), } } -/// Get bounding box of multiple tiles in world space -pub fn tiles_to_world_bounds(tiles: &[TileCoord], zoom_level: f64) -> AxisAlignedBbox { +/// Get the combined document-space bounding box of multiple tiles. +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], zoom_level); + let mut result = tile_to_world_bounds(&tiles[0], scale); for tile in &tiles[1..] { - let bounds = tile_to_world_bounds(tile, zoom_level); + let bounds = tile_to_world_bounds(tile, scale); result = result.union(&bounds); } result @@ -190,71 +345,79 @@ pub fn tiles_to_world_bounds(tiles: &[TileCoord], zoom_level: f64) -> AxisAligne // Cache implementation impl TileCacheImpl { - /// Query cache for viewport bounds at given zoom level - fn query(&mut self, viewport_bounds: &AxisAlignedBbox, zoom_level: f64, cache_key: &CacheKey) -> CacheQuery { - // Check if cache needs invalidation + /// Query cache for viewport bounds at given scale (pixels per world unit) + fn query(&mut self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey) -> CacheQuery { + // Check if cache needs invalidation due to cache key change if &self.cache_key != cache_key { self.invalidate_all(); self.cache_key = cache_key.clone(); } - let required_tiles = world_bounds_to_tiles(viewport_bounds, zoom_level); - let mut cached_tiles = Vec::new(); - let mut missing_tiles = Vec::new(); + // Check if scale changed - invalidate regions but keep cache key + if (self.current_scale - scale).abs() > 0.001 { + self.invalidate_all(); + self.current_scale = scale; + } - for tile_coord in required_tiles { - if let Some(cached) = self.tiles.get_mut(&tile_coord) { + 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(); + + // Find cached regions that cover any of the required tiles + 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)) { // Update LRU - cached.last_access = self.timestamp; + region.last_access = self.timestamp; self.timestamp += 1; - self.access_order.push_back((cached.last_access, tile_coord)); - cached_tiles.push(cached.clone()); - } else { - missing_tiles.push(tile_coord); + + cached_regions.push(region.clone()); + covered_tiles.extend(region_tiles); } } - // Group missing tiles into contiguous regions (will be implemented next) - let missing_regions = group_into_regions(&missing_tiles, zoom_level); + // Find missing 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_tiles, missing_regions } + CacheQuery { cached_regions, missing_regions } } - /// Store newly rendered tiles - fn store_tiles(&mut self, new_tiles: Vec<(TileCoord, CachedTile)>) { - for (coord, mut tile) in new_tiles { - tile.last_access = self.timestamp; + /// Store newly rendered 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 += tile.memory_size; - self.access_order.push_back((tile.last_access, coord)); - self.tiles.insert(coord, tile); + self.total_memory += region.memory_size; + self.regions.push(region); } - // Evict old tiles if over memory limit + // Evict old regions if over memory limit self.evict_until_under_budget(); } /// LRU eviction to stay under memory budget fn evict_until_under_budget(&mut self) { - while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.access_order.is_empty() { - if let Some((timestamp, coord)) = self.access_order.pop_front() { - // Only remove if this is still the oldest access for this tile - if let Some(tile) = self.tiles.get(&coord) { - if tile.last_access == timestamp { - if let Some(removed) = self.tiles.remove(&coord) { - self.total_memory = self.total_memory.saturating_sub(removed.memory_size); - } - } - } + while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() { + // Find oldest region + 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; } } } - /// Clear all cached tiles + /// Clear all cached regions fn invalidate_all(&mut self) { - self.tiles.clear(); - self.access_order.clear(); + for region in &self.regions { + region.texture.destroy(); + } + self.regions.clear(); self.total_memory = 0; // Don't reset timestamp - it's monotonic } @@ -262,17 +425,21 @@ impl TileCacheImpl { // Public TileCache API impl TileCache { - pub fn query(&self, viewport_bounds: &AxisAlignedBbox, zoom_level: f64, cache_key: &CacheKey) -> CacheQuery { - self.0.lock().unwrap().query(viewport_bounds, zoom_level, cache_key) + 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_tiles(&self, tiles: Vec<(TileCoord, CachedTile)>) { - self.0.lock().unwrap().store_tiles(tiles); + pub fn store_regions(&self, regions: Vec) { + self.0.lock().unwrap().store_regions(regions); } } -/// Group tiles into contiguous regions using flood-fill, then split oversized regions -fn group_into_regions(tiles: &[TileCoord], zoom_level: f64) -> Vec { +/// Group tiles into contiguous regions using flood-fill, then split oversized regions. +/// +/// # Arguments +/// * `tiles` - Tile coordinates to group (in tile grid space) +/// * `scale` - Pixels per world unit (used to convert tiles back to world bounds) +fn group_into_regions(tiles: &[TileCoord], scale: f64) -> Vec { if tiles.is_empty() { return Vec::new(); } @@ -288,27 +455,30 @@ fn group_into_regions(tiles: &[TileCoord], zoom_level: f64) -> Vec // Flood-fill to find connected region let region_tiles = flood_fill(&tile, &tile_set, &mut visited); - let world_bounds = tiles_to_world_bounds(®ion_tiles, zoom_level); + let world_bounds = tiles_to_world_bounds(®ion_tiles, scale); - let mut region = RenderRegion { + let region = RenderRegion { world_bounds, tiles: region_tiles, - zoom_level, + scale, }; // Split if region exceeds MAX_REGION_DIMENSION - let split_regions = split_oversized_region(region, zoom_level); + let split_regions = split_oversized_region(region, scale); regions.extend(split_regions); } regions } -/// Split region if it exceeds MAX_REGION_DIMENSION, aligned to tile boundaries -fn split_oversized_region(region: RenderRegion, zoom_level: f64) -> Vec { - let pixels_per_world_unit = zoom_level.exp2(); +/// Split region if it exceeds MAX_REGION_DIMENSION, aligned to tile boundaries. +/// +/// # Arguments +/// * `region` - The region to potentially split +/// * `scale` - Pixels per world unit +fn split_oversized_region(region: RenderRegion, scale: f64) -> Vec { let region_size = region.world_bounds.size(); - let pixel_size = region_size * pixels_per_world_unit; + let pixel_size = region_size * scale; // Check if region fits within limits if pixel_size.x <= MAX_REGION_DIMENSION as f64 && pixel_size.y <= MAX_REGION_DIMENSION as f64 { @@ -331,12 +501,8 @@ fn split_oversized_region(region: RenderRegion, zoom_level: f64) -> Vec, visited: &mut Ha // Check 4-connected neighbors let neighbors = [ - TileCoord { - x: current.x - 1, - y: current.y, - zoom_bucket: current.zoom_bucket, - }, - TileCoord { - x: current.x + 1, - y: current.y, - zoom_bucket: current.zoom_bucket, - }, - TileCoord { - x: current.x, - y: current.y - 1, - zoom_bucket: current.zoom_bucket, - }, - TileCoord { - x: current.x, - y: current.y + 1, - zoom_bucket: current.zoom_bucket, - }, + 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 }, ]; for neighbor in neighbors { @@ -388,31 +538,111 @@ fn flood_fill(start: &TileCoord, tile_set: &HashSet, visited: &mut Ha result } -// Rendering and texture operations - -/// Render a single region to texture using a render function +// ============================================================================ +// RENDERING AND TEXTURE OPERATIONS +// ============================================================================ + +/// Render a single region to a tile-aligned texture. +/// +/// # Transform construction +/// +/// The region footprint transform maps document space to the region's pixel space: +/// +/// ```text +/// region_transform = scale_transform * translation +/// +/// where: +/// scale_transform: scales document units to pixels (same as viewport) +/// translation: shifts origin from (0,0) to region's top-left corner +/// +/// For a point P in document space: +/// pixel = (P - region_origin) * scale +/// +/// This ensures the tile grid aligns exactly: each tile boundary in pixel space +/// corresponds to an integer multiple of TILE_SIZE. +/// ``` +/// +/// # Metadata handling +/// +/// The render function produces metadata in the region's pixel space. +/// We convert it back to document space before storing in the cache: +/// `metadata_document = metadata_region * inverse(region_transform)` +/// +/// # Returns +/// * `RenderOutput` - The rendered output with metadata in document space +/// * `UVec2` - The actual texture dimensions (always tile-aligned) pub async fn render_region<'a, F, Fut>( region: &RenderRegion, render_fn: F, - editor_api: &'a WasmEditorApi, - base_render_params: &RenderParams, - base_ctx: &(impl Ctx + Clone), - contains_artboard: bool, -) -> wgpu::Texture + _editor_api: &'a WasmEditorApi, + _base_render_params: &RenderParams, + base_ctx: impl Ctx + ExtractAll + CloneVarArgs, + base_footprint: &Footprint, +) -> (RenderOutput, UVec2) where F: FnOnce(Context<'static>) -> Fut, - Fut: std::future::Future, + Fut: std::future::Future, { - let region_size = region.world_bounds.size(); - let pixels_per_world_unit = region.zoom_level.exp2(); - let physical_size = UVec2::new((region_size.x * pixels_per_world_unit).ceil() as u32, (region_size.y * pixels_per_world_unit).ceil() as u32); - - // Create footprint for this region - let scale = base_render_params.scale; - let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); - let translation = glam::DAffine2::from_translation(-region.world_bounds.start); + // Calculate region texture size from tile count (guaranteed tile-aligned) + 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 min_y = region.tiles.iter().map(|t| t.y).min().unwrap(); + let max_y = region.tiles.iter().map(|t| t.y).max().unwrap(); + + let tiles_wide = (max_x - min_x + 1) as u32; + let tiles_high = (max_y - min_y + 1) as u32; + let physical_size = UVec2::new(tiles_wide * TILE_SIZE, tiles_high * TILE_SIZE); + + // Extract scale from base footprint (pixels per world unit) + let base_scale = base_footprint.decompose_scale(); + + // Calculate region origin in document space from tile coordinates + // This ensures perfect tile alignment: tile(x,y) → world(x * TILE_SIZE / scale, ...) + let world_units_per_pixel = 1.0 / base_scale.x; + let tile_world_size = TILE_SIZE as f64 * world_units_per_pixel; + let region_world_start = DVec2::new(min_x as f64 * tile_world_size, min_y as f64 * tile_world_size); + + // Build region transform: pixel = (document - region_origin) * scale + // In matrix form: scale * translate(-region_origin) + let scale_transform = glam::DAffine2::from_scale(base_scale); + let translation = glam::DAffine2::from_translation(-region_world_start); let region_transform = scale_transform * translation; + // DEBUG: Log the region rendering parameters + log::debug!( + "[render_region] tiles: x=[{}, {}], y=[{}, {}], size: {}x{}", + min_x, + max_x, + min_y, + max_y, + physical_size.x, + physical_size.y + ); + log::debug!( + "[render_region] region_world_start: ({:.2}, {:.2}), base_scale: ({:.4}, {:.4})", + region_world_start.x, + region_world_start.y, + base_scale.x, + base_scale.y + ); + // Verify: document point at region_world_start should map to pixel (0,0) + let test_pixel = region_transform.transform_point2(region_world_start); + log::debug!( + "[render_region] transform check: region_world_start -> pixel ({:.2}, {:.2}) (should be 0,0)", + test_pixel.x, + test_pixel.y + ); + // And check what document point maps to the viewport's start + let viewport_world_start = base_footprint.viewport_bounds_in_local_space().start; + let viewport_in_region_pixels = region_transform.transform_point2(viewport_world_start); + log::debug!( + "[render_region] viewport_world_start ({:.2}, {:.2}) -> region_pixel ({:.2}, {:.2})", + viewport_world_start.x, + viewport_world_start.y, + viewport_in_region_pixels.x, + viewport_in_region_pixels.y + ); + let region_footprint = Footprint { transform: region_transform, resolution: physical_size, @@ -420,94 +650,107 @@ where }; // Create context with region footprint - let mut region_params = base_render_params.clone(); + let mut region_params = _base_render_params.clone(); region_params.footprint = region_footprint; // Build context from base context with new footprint - let region_ctx = OwnedContextImpl::from(base_ctx.clone()) - .with_footprint(region_footprint) - .with_vararg(Box::new(region_params)) - .into_context(); + let region_ctx = OwnedContextImpl::from(base_ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); // Evaluate render function with region context - let render_intermediate = render_fn(region_ctx).await; + let mut result = render_fn(region_ctx).await; - // Render to texture (similar to existing render node logic) - let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().expect("No GPU executor available"); + // Convert metadata back to document space by applying region_transform^-1 + let translation_back = glam::DAffine2::from_translation(region_world_start); + let region_to_document_transform = translation_back * scale_transform.inverse(); + result.metadata.apply_transform(region_to_document_transform); - match &render_intermediate.ty { - RenderIntermediateType::Vello(vello_data) => { - let (child, context) = Arc::as_ref(vello_data); - - let footprint_transform_vello = vello::kurbo::Affine::new(region_transform.to_cols_array()); - - let mut scene = vello::Scene::new(); - scene.append(child, Some(footprint_transform_vello)); + (result, physical_size) +} - // Handle infinite transforms - let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_size.x as f64, physical_size.y as f64); - let encoding = scene.encoding_mut(); - for transform in encoding.transforms.iter_mut() { - if transform.matrix[0] == f32::INFINITY { - *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); - } - } +/// Composite cached region textures into the final viewport output texture. +/// +/// # Two-stage compositing approach +/// +/// ## Stage 1: Assemble tile-aligned intermediate texture +/// All cached regions are copied into a single tile-aligned intermediate texture. +/// Since every region is tile-aligned (dimensions are multiples of TILE_SIZE), +/// no sub-pixel offsets are needed - positions are computed as: +/// `pixel_offset = (region_min_tile - global_min_tile) * TILE_SIZE` +/// +/// ## Stage 2: Copy to viewport output +/// The tile-aligned intermediate is copied to the viewport output texture. +/// This is the ONLY place where sub-tile precision matters: +/// `offset = tile_aligned_origin - viewport_origin` (in pixels) +/// +/// # Coordinate conversion for Stage 2: +/// ```text +/// tile_aligned_world_start = min_tile * TILE_SIZE / scale (document space) +/// offset_world = tile_aligned_world_start - viewport_bounds.start +/// offset_pixels = offset_world * scale +/// ``` +/// +/// # Arguments +/// * `cached_regions` - Regions to composite (all tile-aligned) +/// * `viewport_bounds` - Document-space bounds of the viewport +/// * `output_resolution` - Pixel dimensions of the output texture +/// * `scale` - Pixels per world unit +/// * `editor_api` - For GPU access +pub async fn composite_regions<'a>(cached_regions: Vec, viewport_bounds: &AxisAlignedBbox, output_resolution: UVec2, scale: f64, editor_api: &'a WasmEditorApi) -> wgpu::Texture { + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + let device = &exec.context.device; + let queue = &exec.context.queue; - let background = if contains_artboard || base_render_params.hide_artboards { - Color::from_rgb8_srgb(0x22, 0x22, 0x22) - } else { - Color::WHITE - }; + // STAGE 1: Determine tile-aligned bounds that cover all regions + let mut min_tile = IVec2::new(i32::MAX, i32::MAX); + let mut max_tile = IVec2::new(i32::MIN, i32::MIN); - exec.render_vello_scene_to_texture(&scene, physical_size, context, background).await.expect("Failed to render region") + for region in &cached_regions { + for tile in ®ion.tiles { + min_tile = min_tile.min(IVec2::new(tile.x, tile.y)); + max_tile = max_tile.max(IVec2::new(tile.x, tile.y)); } - _ => panic!("Cache only supports Vello rendering"), } -} -/// Split rendered region texture into individual tile textures -pub async fn split_texture_into_tiles<'a>( - region_texture: wgpu::Texture, - region: &RenderRegion, - editor_api: &'a WasmEditorApi, -) -> Vec<(TileCoord, CachedTile)> { - let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let queue = &exec.context.queue; + // Calculate tile-aligned intermediate texture size + let tile_count = (max_tile - min_tile) + IVec2::ONE; + let tile_aligned_size = tile_count.as_uvec2() * TILE_SIZE; - let mut tiles = Vec::new(); - let pixels_per_world_unit = region.zoom_level.exp2(); - - for &tile_coord in ®ion.tiles { - // Calculate tile bounds in world and pixel space - let tile_world_bounds = tile_to_world_bounds(&tile_coord, region.zoom_level); - - // Calculate offset within region texture - let offset_in_region = tile_world_bounds.start - region.world_bounds.start; - let pixel_offset = (offset_in_region * pixels_per_world_unit).as_uvec2(); - - // Create tile texture - let tile_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("cached_tile"), - size: wgpu::Extent3d { - width: TILE_SIZE, - height: TILE_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); + // Create tile-aligned intermediate texture + let tile_aligned_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("tile_aligned_composite"), + size: wgpu::Extent3d { + width: tile_aligned_size.x, + height: tile_aligned_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_DST | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_composite") }); - // Copy tile region from large texture - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_copy") }); + // STAGE 1: Copy each region to its tile-aligned position + for region in &cached_regions { + let region_min_tile = IVec2::new(region.tiles.iter().map(|t| t.x).min().unwrap(), region.tiles.iter().map(|t| t.y).min().unwrap()); + // Calculate position in tile-aligned texture (in tiles, then convert to pixels) + let tile_offset = region_min_tile - min_tile; + let pixel_offset = tile_offset.as_uvec2() * TILE_SIZE; + + // Simple copy - everything is tile-aligned! encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: ®ion_texture, + texture: ®ion.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: 0, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &tile_aligned_texture, mip_level: 0, origin: wgpu::Origin3d { x: pixel_offset.x, @@ -516,45 +759,51 @@ pub async fn split_texture_into_tiles<'a>( }, aspect: wgpu::TextureAspect::All, }, - wgpu::TexelCopyTextureInfo { - texture: &tile_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, wgpu::Extent3d { - width: TILE_SIZE, - height: TILE_SIZE, + width: region.texture_size.x, + height: region.texture_size.y, depth_or_array_layers: 1, }, ); - - queue.submit([encoder.finish()]); - - tiles.push(( - tile_coord, - CachedTile { - texture: tile_texture, - world_bounds: tile_world_bounds, - zoom_level: region.zoom_level, - last_access: 0, // Will be set by cache - memory_size: (TILE_SIZE * TILE_SIZE * BYTES_PER_PIXEL as u32) as usize, - }, - )); } - tiles -} - -/// Composite cached tiles into final output texture -pub async fn composite_tiles<'a>(cached_tiles: Vec, viewport_bounds: &AxisAlignedBbox, output_resolution: UVec2, editor_api: &'a WasmEditorApi) -> wgpu::Texture { - let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let queue = &exec.context.queue; + // STAGE 2: Copy from tile-aligned texture to viewport output + // Convert tile origin to document space: tile * TILE_SIZE / scale + let tile_aligned_world_start = min_tile.as_dvec2() * (TILE_SIZE as f64 / scale); + + // Calculate offset from tile-aligned texture origin to viewport origin (in document space) + // Then convert to pixels: offset_pixels = offset_world * scale + let offset_world = tile_aligned_world_start - viewport_bounds.start; + let offset_pixels_f64 = offset_world * scale; + let offset_pixels = IVec2::new(offset_pixels_f64.x.floor() as i32, offset_pixels_f64.y.floor() as i32); + + // DEBUG: Log the offset calculation + log::debug!( + "[composite] viewport_world: ({:.2}, {:.2}), tile_aligned_world: ({:.2}, {:.2})", + viewport_bounds.start.x, + viewport_bounds.start.y, + tile_aligned_world_start.x, + tile_aligned_world_start.y + ); + log::debug!( + "[composite] offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {})", + offset_world.x, + offset_world.y, + offset_pixels.x, + offset_pixels.y + ); + log::debug!( + "[composite] min_tile: ({}, {}), scale: {:.4}, tile_aligned_size: ({}, {})", + min_tile.x, + min_tile.y, + scale, + tile_aligned_size.x, + tile_aligned_size.y + ); - // Create output texture + // Create final output texture let output_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("composite_output"), + label: Some("viewport_output"), size: wgpu::Extent3d { width: output_resolution.x, height: output_resolution.y, @@ -568,46 +817,47 @@ pub async fn composite_tiles<'a>(cached_tiles: Vec, viewport_bounds: view_formats: &[], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_composite") }); + // Handle negative offsets (tile-aligned texture extends before viewport) + let (src_x, dst_x, width) = if offset_pixels.x < 0 { + let skip = (-offset_pixels.x) as u32; + let w = tile_aligned_size.x.saturating_sub(skip).min(output_resolution.x); + (skip, 0, w) + } else { + let dst = offset_pixels.x as u32; + let w = tile_aligned_size.x.min(output_resolution.x.saturating_sub(dst)); + (0, dst, w) + }; - let viewport_size = viewport_bounds.size(); - let pixels_per_world_unit = (output_resolution.as_dvec2() / viewport_size).x; // Assuming uniform scaling - - for tile in &cached_tiles { - // Calculate where this tile goes in output texture - let tile_offset_in_viewport = tile.world_bounds.start - viewport_bounds.start; - let pixel_offset = (tile_offset_in_viewport * pixels_per_world_unit).as_uvec2(); - - // Clamp to output bounds (handle edge tiles that might extend beyond viewport) - let copy_width = TILE_SIZE.min(output_resolution.x.saturating_sub(pixel_offset.x)); - let copy_height = TILE_SIZE.min(output_resolution.y.saturating_sub(pixel_offset.y)); - - if copy_width > 0 && copy_height > 0 { - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &tile.texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: &output_texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: pixel_offset.x, - y: pixel_offset.y, - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: copy_width, - height: copy_height, - depth_or_array_layers: 1, - }, - ); - } - } + let (src_y, dst_y, height) = if offset_pixels.y < 0 { + let skip = (-offset_pixels.y) as u32; + let h = tile_aligned_size.y.saturating_sub(skip).min(output_resolution.y); + (skip, 0, h) + } else { + let dst = offset_pixels.y as u32; + let h = tile_aligned_size.y.min(output_resolution.y.saturating_sub(dst)); + (0, dst, h) + }; + + // Single copy from tile-aligned to output + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &tile_aligned_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, + }, + ); queue.submit([encoder.finish()]); output_texture @@ -617,9 +867,9 @@ pub async fn composite_tiles<'a>(cached_tiles: Vec, viewport_bounds: #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( - ctx: impl Ctx + ExtractFootprint + ExtractVarArgs + CloneVarArgs, + ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync, editor_api: &'a WasmEditorApi, - data: impl Node, Output = RenderIntermediate> + Send + Sync, + data: impl Node, Output = RenderOutput> + Send + Sync, #[data] tile_cache: TileCache, ) -> RenderOutput { let footprint = ctx.footprint(); @@ -632,30 +882,21 @@ pub async fn render_output_cache<'a: 'n>( render_params.footprint = *footprint; let render_params = &render_params; + let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + // Only cache Vello (GPU) rendering if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) { // Fall back to regular rendering for SVG - let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); - let intermediate = data.eval(context.into_context()).await; - // Convert intermediate to output (simplified SVG path) - return RenderOutput { - data: RenderOutputType::Svg { - svg: String::new(), - image_data: Vec::new(), - }, - metadata: intermediate.metadata, - }; + return data.eval(context.into_context()).await; } - let scale = render_params.scale; let physical_resolution = footprint.resolution; - let zoom_level = scale.log2(); - let viewport_bounds = footprint.viewport_bounds_in_local_space(); - // Evaluate data node once to get intermediate representation - let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); - let intermediate = data.eval(context.into_context()).await; - let contains_artboard = intermediate.contains_artboard; + // Extract scale (pixels per world unit) from footprint transform + let scale = footprint.decompose_scale().x; + + // Get viewport bounds in document (world) space + let viewport_bounds = footprint.viewport_bounds_in_local_space(); // Compute cache key from render params let mut hasher = DefaultHasher::new(); @@ -678,35 +919,74 @@ pub async fn render_output_cache<'a: 'n>( real_time, ); - // Query cache for tiles - let query = tile_cache.query(&viewport_bounds, zoom_level, &cache_key); + // Query cache for tiles (scale = pixels per world unit) + let query = tile_cache.query(&viewport_bounds, scale, &cache_key); + + // DEBUG: Log viewport and required tiles + let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, scale); + let viewport_min_tile = viewport_tiles.iter().map(|t| (t.x, t.y)).min(); + log::debug!( + "[cache] viewport_bounds: ({:.2}, {:.2}) to ({:.2}, {:.2}), scale: {:.4}", + viewport_bounds.start.x, + viewport_bounds.start.y, + viewport_bounds.end.x, + viewport_bounds.end.y, + scale + ); + log::debug!( + "[cache] viewport needs {} tiles, min: {:?}, missing: {} regions, cached: {} regions", + viewport_tiles.len(), + viewport_min_tile, + query.missing_regions.len(), + query.cached_regions.len() + ); - // Render missing regions - let mut new_tiles = Vec::new(); + // Render missing regions (metadata is converted to document space by render_region) + let mut new_regions = Vec::new(); for region in &query.missing_regions { // Create render closure for this region - let data_clone = data.clone(); - let render_fn = |ctx: Context<'static>| async move { data_clone.eval(ctx).await }; + let render_fn = |ctx: Context<'static>| data.eval(ctx); + + let (region_result, actual_texture_size) = render_region(region, render_fn, editor_api, render_params, ctx.clone(), footprint).await; + let RenderOutputType::Texture(region_texture) = region_result.data else { + panic!("Expected texture output from region rendering, got SVG"); + }; - let region_texture = render_region(region, render_fn, editor_api, render_params, &ctx, contains_artboard).await; - let tiles = split_texture_into_tiles(region_texture, region, editor_api).await; - new_tiles.extend(tiles); + // Calculate memory size using the actual texture size + let memory_size = (actual_texture_size.x * actual_texture_size.y * BYTES_PER_PIXEL as u32) as usize; + + // Store the entire region texture with metadata (already in document space) + new_regions.push(CachedRegion { + texture: region_texture.texture, + texture_size: actual_texture_size, + world_bounds: region.world_bounds.clone(), + tiles: region.tiles.clone(), + metadata: region_result.metadata, + last_access: 0, + memory_size, + }); } - // Store new tiles in cache - if !new_tiles.is_empty() { - tile_cache.store_tiles(new_tiles.clone()); + // Store new regions in cache + if !new_regions.is_empty() { + tile_cache.store_regions(new_regions.clone()); } - // Combine cached and new tiles - let mut all_tiles = query.cached_tiles; - all_tiles.extend(new_tiles.into_iter().map(|(_, tile)| tile)); + // Combine cached and new regions + let mut all_regions = query.cached_regions; + all_regions.extend(new_regions); - // Composite tiles into final output - let output_texture = composite_tiles(all_tiles, &viewport_bounds, physical_resolution, editor_api).await; + // Composite region textures into final output (scale = pixels per world unit) + let output_texture = composite_regions(all_regions.clone(), &viewport_bounds, physical_resolution, scale, editor_api).await; + + // Collect metadata from all regions (metadata is stored in document space) + let mut metadata = rendering::RenderMetadata::default(); + for region in &all_regions { + // TODO: Properly merge metadata from multiple regions + metadata = region.metadata.clone(); + } - // Collect metadata - let mut metadata = intermediate.metadata; + // Apply current viewport transform to metadata metadata.apply_transform(footprint.transform); RenderOutput { @@ -719,39 +999,31 @@ pub async fn render_output_cache<'a: 'n>( mod tests { use super::*; - #[test] - fn test_quantize_zoom() { - assert_eq!(quantize_zoom(0.0), 0); - assert_eq!(quantize_zoom(0.1), 0); - assert_eq!(quantize_zoom(0.125), 1); - assert_eq!(quantize_zoom(0.25), 1); - assert_eq!(quantize_zoom(0.5), 2); - assert_eq!(quantize_zoom(1.0), 4); - } - #[test] fn test_tile_coordinate_conversion() { - let zoom = 2.0; // scale = 4.0 - let coord = TileCoord { x: 0, y: 0, zoom_bucket: quantize_zoom(zoom) }; - let bounds = tile_to_world_bounds(&coord, zoom); + // scale = 4.0 pixels per world unit + let scale = 4.0; + let coord = TileCoord { x: 0, y: 0 }; + let bounds = tile_to_world_bounds(&coord, scale); - // At zoom 2.0, scale = 4.0, so 256 pixels = 64 world units + // At scale 4.0, 256 pixels = 64 world units assert_eq!(bounds.start, DVec2::ZERO); assert_eq!(bounds.end, DVec2::splat(64.0)); } #[test] fn test_world_to_tiles() { - let zoom = 0.0; // scale = 1.0, 1 pixel = 1 world unit + // scale = 1.0, 1 pixel = 1 world unit + let scale = 1.0; let bounds = AxisAlignedBbox { start: DVec2::ZERO, end: DVec2::new(512.0, 256.0), }; - let tiles = world_bounds_to_tiles(&bounds, zoom); + let tiles = world_bounds_to_tiles(&bounds, scale); - // Should be 2x1 tiles (512x256 pixels) + // Should be 2x1 tiles (512x256 pixels at scale 1.0) assert_eq!(tiles.len(), 2); - assert!(tiles.contains(&TileCoord { x: 0, y: 0, zoom_bucket: 0 })); - assert!(tiles.contains(&TileCoord { x: 1, y: 0, zoom_bucket: 0 })); + assert!(tiles.contains(&TileCoord { x: 0, y: 0 })); + assert!(tiles.contains(&TileCoord { x: 1, y: 0 })); } } From 74be91b43f523069ece530ad2d0f722b6b5d59c8 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 00:13:36 +0100 Subject: [PATCH 3/9] Fixed offsets --- node-graph/nodes/gstd/src/render_cache.rs | 616 +++++++++++++++++++--- 1 file changed, 529 insertions(+), 87 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 3a69a7cb0d..20d297f4b4 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -865,12 +865,13 @@ pub async fn composite_regions<'a>(cached_regions: Vec, viewport_b // Node implementation +/// Simplified render cache for debugging - no caching, single tile-aligned region #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync, editor_api: &'a WasmEditorApi, data: impl Node, Output = RenderOutput> + Send + Sync, - #[data] tile_cache: TileCache, + #[data] _tile_cache: TileCache, ) -> RenderOutput { let footprint = ctx.footprint(); let render_params = ctx @@ -878,120 +879,320 @@ pub async fn render_output_cache<'a: 'n>( .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 context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); - - // Only cache Vello (GPU) rendering + // Only use tile-aligned rendering for Vello if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) { - // Fall back to regular rendering for SVG + 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; - - // Extract scale (pixels per world unit) from footprint transform - let scale = footprint.decompose_scale().x; + // DEBUG modes to isolate the bug: + // Mode 0: Full bypass - render directly with original footprint (known working) + // Mode 1: Render with original footprint, do texture copy (tests copy logic) - WORKS + // Mode 2: Render with region footprint (transform + resolution), do copy - BROKEN + // Mode 3: Original transform + region resolution (tests resolution change) - WORKS + // Mode 4: Region transform + original resolution (tests transform change) - BROKEN + // Mode 5: Small offset transform (10 doc units) - tests if ANY translation change breaks + // Mode 6: Reconstructed original transform - tests if reconstruction method matters + const DEBUG_MODE: u8 = 2; - // Get viewport bounds in document (world) space + 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; + // NOTE: The render pipeline applies device_scale to the transform. + // So the rendered texture has content at physical_scale density. + // We use logical_scale for tile calculations to match viewport_bounds coordinate space. + let tile_scale = logical_scale; let viewport_bounds = footprint.viewport_bounds_in_local_space(); - // Compute cache key from render params - let mut hasher = DefaultHasher::new(); - render_params.render_mode.hash(&mut hasher); - let render_mode_hash = hasher.finish(); - - // Extract animation and real time from context - let animation_time = ctx.try_animation_time().unwrap_or(0.0); - let real_time = ctx.try_real_time().unwrap_or(0.0); - - let cache_key = CacheKey::from_times( - render_mode_hash, - render_params.hide_artboards, - render_params.for_export, - render_params.for_mask, - render_params.thumbnail, - render_params.aligned_strokes, - render_params.override_paint_order, - animation_time, - real_time, - ); + if DEBUG_MODE == 0 { + log::debug!("[mode0] Full bypass - rendering directly with original footprint"); + let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + return data.eval(context.into_context()).await; + } - // Query cache for tiles (scale = pixels per world unit) - let query = tile_cache.query(&viewport_bounds, scale, &cache_key); + // Calculate tile-aligned bounds using logical_scale (matches footprint.resolution) + let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, tile_scale); + let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + let max_tile = viewport_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 / tile_scale; + let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); - // DEBUG: Log viewport and required tiles - let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, scale); - let viewport_min_tile = viewport_tiles.iter().map(|t| (t.x, t.y)).min(); log::debug!( - "[cache] viewport_bounds: ({:.2}, {:.2}) to ({:.2}, {:.2}), scale: {:.4}", + "[mode{}] viewport: ({:.2}, {:.2}), tile_scale: {:.4}, device_scale: {:.4}, tiles: ({},{}) to ({},{})", + DEBUG_MODE, viewport_bounds.start.x, viewport_bounds.start.y, - viewport_bounds.end.x, - viewport_bounds.end.y, - scale + tile_scale, + device_scale, + min_tile.x, + min_tile.y, + max_tile.x, + max_tile.y ); log::debug!( - "[cache] viewport needs {} tiles, min: {:?}, missing: {} regions, cached: {} regions", - viewport_tiles.len(), - viewport_min_tile, - query.missing_regions.len(), - query.cached_regions.len() + "[DIAG] region_world_start: ({:.2}, {:.2}), tile_world_size: {:.2}", + region_world_start.x, + region_world_start.y, + tile_world_size + ); + log::debug!("[DIAG] footprint.transform: {:?}", footprint.transform); + log::debug!("[DIAG] Expected at output[0,0]: ({:.2}, {:.2})", viewport_bounds.start.x, viewport_bounds.start.y); + + // Calculate region values needed for modes 2-4 + let tiles_wide = (max_tile.x - min_tile.x + 1) as u32; + let tiles_high = (max_tile.y - min_tile.y + 1) as u32; + // Tiles are calculated at logical_scale, but render applies device_scale. + // So we need to scale up the texture size to cover the same document area. + let region_pixel_size = UVec2::new( + ((tiles_wide * TILE_SIZE) as f64 * device_scale).ceil() as u32, + ((tiles_high * TILE_SIZE) as f64 * device_scale).ceil() as u32, ); + let region_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-region_world_start); + + // Render the content based on mode + let mut result = match DEBUG_MODE { + 1 => { + // Mode 1: Original footprint (transform + resolution) + log::debug!("[mode1] Original footprint"); + let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); + data.eval(context.into_context()).await + } + 2 => { + // Mode 2: Region footprint (region transform + region resolution) + log::debug!("[mode2] Region footprint (transform + resolution)"); + 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.clone()).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); + data.eval(region_ctx).await + } + 3 => { + // Mode 3: Original transform + region resolution (tests resolution change only) + log::debug!("[mode3] Original transform + region resolution"); + let test_footprint = Footprint { + transform: footprint.transform, // ORIGINAL transform + resolution: region_pixel_size, // REGION resolution (larger) + quality: RenderQuality::Full, + }; + let mut test_params = render_params.clone(); + test_params.footprint = test_footprint; + let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); + data.eval(test_ctx).await + } + 4 => { + // Mode 4: Region transform + original resolution (tests transform change only) + log::debug!("[mode4] Region transform + original resolution"); + let test_footprint = Footprint { + transform: region_transform, // REGION transform (different origin) + resolution: physical_resolution, // ORIGINAL resolution + quality: RenderQuality::Full, + }; + let mut test_params = render_params.clone(); + test_params.footprint = test_footprint; + let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); + data.eval(test_ctx).await + } + 5 => { + // Mode 5: Small offset transform (10 doc units) - tests if ANY translation change breaks + let small_offset = DVec2::new(10.0, 10.0); + let small_offset_origin = viewport_bounds.start - small_offset; + let small_offset_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-small_offset_origin); + log::debug!("[mode5] Small offset transform (10 units), origin: ({:.2}, {:.2})", small_offset_origin.x, small_offset_origin.y); + let test_footprint = Footprint { + transform: small_offset_transform, + resolution: physical_resolution, + quality: RenderQuality::Full, + }; + let mut test_params = render_params.clone(); + test_params.footprint = test_footprint; + let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); + data.eval(test_ctx).await + } + 6 => { + // Mode 6: Reconstructed original transform - tests if reconstruction method matters + // Extract the original viewport origin and rebuild the transform the same way we build region_transform + let original_origin = viewport_bounds.start; + let reconstructed_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-original_origin); + log::debug!("[mode6] Reconstructed original transform, origin: ({:.2}, {:.2})", original_origin.x, original_origin.y); + log::debug!("[mode6] Original transform: {:?}", footprint.transform); + log::debug!("[mode6] Reconstructed: {:?}", reconstructed_transform); + let test_footprint = Footprint { + transform: reconstructed_transform, + resolution: physical_resolution, + quality: RenderQuality::Full, + }; + let mut test_params = render_params.clone(); + test_params.footprint = test_footprint; + let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); + data.eval(test_ctx).await + } + _ => panic!("Invalid DEBUG_MODE"), + }; + let RenderOutputType::Texture(rendered_texture) = result.data else { + panic!("Expected texture output"); + }; - // Render missing regions (metadata is converted to document space by render_region) - let mut new_regions = Vec::new(); - for region in &query.missing_regions { - // Create render closure for this region - let render_fn = |ctx: Context<'static>| data.eval(ctx); + // Calculate offset and source texture size based on mode + let (offset_pixels, src_texture_size) = match DEBUG_MODE { + 1 | 3 | 6 => { + // Modes 1, 3, 6: rendered with original TRANSFORM (or reconstructed same), so offset is 0 + let src_size = if DEBUG_MODE == 3 { region_pixel_size } else { physical_resolution }; + log::debug!("[mode{}] Using zero offset (rendered at viewport position), src_size: {:?}", DEBUG_MODE, src_size); + (IVec2::ZERO, src_size) + } + 2 | 4 => { + // Modes 2 & 4: rendered with region TRANSFORM, need offset + let src_size = if DEBUG_MODE == 2 { region_pixel_size } else { physical_resolution }; + let offset_world = region_world_start - viewport_bounds.start; + let offset_px = (offset_world * physical_scale).floor().as_ivec2(); + log::debug!( + "[mode{}] offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {}), src_size: {:?}", + DEBUG_MODE, + offset_world.x, + offset_world.y, + offset_px.x, + offset_px.y, + src_size + ); + (offset_px, src_size) + } + 5 => { + // Mode 5: small offset (10 doc units before viewport) + let small_offset = DVec2::new(10.0, 10.0); + let offset_world = -small_offset; // render origin is before viewport + let offset_px = (offset_world * physical_scale).floor().as_ivec2(); + log::debug!( + "[mode5] Small offset: offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {})", + offset_world.x, + offset_world.y, + offset_px.x, + offset_px.y + ); + (offset_px, physical_resolution) + } + _ => panic!("Invalid DEBUG_MODE"), + }; - let (region_result, actual_texture_size) = render_region(region, render_fn, editor_api, render_params, ctx.clone(), footprint).await; - let RenderOutputType::Texture(region_texture) = region_result.data else { - panic!("Expected texture output from region rendering, got SVG"); - }; + // Create output texture and copy from region + let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + let device = &exec.context.device; + let queue = &exec.context.queue; - // Calculate memory size using the actual texture size - let memory_size = (actual_texture_size.x * actual_texture_size.y * BYTES_PER_PIXEL as u32) as usize; - - // Store the entire region texture with metadata (already in document space) - new_regions.push(CachedRegion { - texture: region_texture.texture, - texture_size: actual_texture_size, - world_bounds: region.world_bounds.clone(), - tiles: region.tiles.clone(), - metadata: region_result.metadata, - last_access: 0, - memory_size, - }); - } + let output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("viewport_output"), + size: wgpu::Extent3d { + width: physical_resolution.x, + height: physical_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: &[], + }); - // Store new regions in cache - if !new_regions.is_empty() { - tile_cache.store_regions(new_regions.clone()); - } + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("simple_copy") }); + + // Copy from source to output with offset + // offset > 0: source starts AFTER viewport, so dst = offset, src = 0 + // offset < 0: source starts BEFORE viewport, so dst = 0, src = -offset + let (src_x, dst_x, width) = if offset_pixels.x >= 0 { + let dst = offset_pixels.x as u32; + let w = src_texture_size.x.min(physical_resolution.x.saturating_sub(dst)); + (0, dst, w) + } else { + let skip = (-offset_pixels.x) as u32; + let w = src_texture_size.x.saturating_sub(skip).min(physical_resolution.x); + (skip, 0, w) + }; - // Combine cached and new regions - let mut all_regions = query.cached_regions; - all_regions.extend(new_regions); + let (src_y, dst_y, height) = if offset_pixels.y >= 0 { + let dst = offset_pixels.y as u32; + let h = src_texture_size.y.min(physical_resolution.y.saturating_sub(dst)); + (0, dst, h) + } else { + let skip = (-offset_pixels.y) as u32; + let h = src_texture_size.y.saturating_sub(skip).min(physical_resolution.y); + (skip, 0, h) + }; - // Composite region textures into final output (scale = pixels per world unit) - let output_texture = composite_regions(all_regions.clone(), &viewport_bounds, physical_resolution, scale, editor_api).await; + // Verify: output[dst] gets source[src], which shows document position + // For modes 1, 3, 6: source origin is viewport_bounds.start (original transform) + // For modes 2, 4: source origin is region_world_start (region transform) + // For mode 5: source origin is viewport_bounds.start - small_offset + let effective_origin = match DEBUG_MODE { + 1 | 3 | 6 => viewport_bounds.start, + 2 | 4 => region_world_start, + // 2 | 4 => viewport_bounds.start, + 5 => viewport_bounds.start - DVec2::new(10.0, 10.0), + _ => panic!("Invalid DEBUG_MODE"), + }; + let doc_at_output_origin = effective_origin + DVec2::new(src_x as f64, src_y as f64) / physical_scale; + log::debug!("[mode{}] copy: src=({},{}) dst=({},{}) size={}x{}", DEBUG_MODE, src_x, src_y, dst_x, dst_y, width, height); + log::debug!( + "[mode{}] VERIFY: output[{},{}] shows doc ({:.2}, {:.2}), should be ({:.2}, {:.2})", + DEBUG_MODE, + dst_x, + dst_y, + doc_at_output_origin.x, + doc_at_output_origin.y, + viewport_bounds.start.x, + viewport_bounds.start.y + ); + // Show the error between what we show and what we should show + let error = doc_at_output_origin - viewport_bounds.start; + let error_in_tiles = error * tile_scale / TILE_SIZE as f64; + log::debug!("[DIAG] ERROR: ({:.4}, {:.4}) doc units = ({:.4}, {:.4}) tiles", error.x, error.y, error_in_tiles.x, error_in_tiles.y); - // Collect metadata from all regions (metadata is stored in document space) - let mut metadata = rendering::RenderMetadata::default(); - for region in &all_regions { - // TODO: Properly merge metadata from multiple regions - metadata = region.metadata.clone(); + if width > 0 && height > 0 { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &rendered_texture.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, + }, + ); } - // Apply current viewport transform to metadata - metadata.apply_transform(footprint.transform); + queue.submit([encoder.finish()]); + + // Transform metadata from region pixel space to viewport pixel space. + // Metadata is in LOGICAL pixel space (matching footprint.resolution), not physical pixels. + // Region logical pixel (0,0) maps to document position region_world_start. + // Viewport logical pixel (0,0) maps to document position viewport_bounds.start. + // So: viewport_logical = region_logical + offset_logical + if DEBUG_MODE == 2 || DEBUG_MODE == 4 || DEBUG_MODE == 5 { + let offset_world = region_world_start - viewport_bounds.start; + let metadata_offset = offset_world * logical_scale; // Use logical scale, not physical + let metadata_transform = glam::DAffine2::from_translation(metadata_offset); + result.metadata.apply_transform(metadata_transform); + } RenderOutput { data: RenderOutputType::Texture(ImageTexture { texture: output_texture }), - metadata, + // data: RenderOutputType::Texture(ImageTexture { texture: rendered_texture.texture }), + metadata: result.metadata, } } @@ -1026,4 +1227,245 @@ mod tests { assert!(tiles.contains(&TileCoord { x: 0, y: 0 })); assert!(tiles.contains(&TileCoord { x: 1, y: 0 })); } + + #[test] + fn test_tile_alignment_offset_calculation() { + // This test verifies that the offset calculation correctly maps + // output[0,0] to show the viewport origin, regardless of tile alignment + // + // Note: floor() rounding in offset calculation causes up to 1/scale document units + // of error. At physical_scale=0.52, this is up to ~2 document units. + let physical_scale = 0.52; // Simulates logical_scale * device_scale + let tile_world_size = TILE_SIZE as f64 / physical_scale; + + // Test case: viewport at position that's NOT tile-aligned + let viewport_start = DVec2::new(-536.51, -493.24); + let viewport_bounds = AxisAlignedBbox { + start: viewport_start, + end: viewport_start + DVec2::new(1000.0, 1000.0), + }; + + // Calculate tiles + let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); + let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + + // Calculate region origin (tile-aligned) + let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); + + // Calculate offset + let offset_world = region_world_start - viewport_start; + let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); + + // Determine src position for copy (where in region texture corresponds to viewport origin) + let src_x = if offset_pixels.x >= 0 { 0 } else { (-offset_pixels.x) as u32 }; + let src_y = if offset_pixels.y >= 0 { 0 } else { (-offset_pixels.y) as u32 }; + + // Calculate what document position output[0,0] would show + let doc_at_output_origin = region_world_start + DVec2::new(src_x as f64, src_y as f64) / physical_scale; + + // The maximum error from floor() is 1 pixel, which is 1/scale document units + // At scale 0.52, that's about 1.92 document units per axis, or ~2.7 total + let max_error = 2.0 / physical_scale; // 1 pixel per axis, diagonal + let error = (doc_at_output_origin - viewport_start).length(); + assert!( + error < max_error, + "Output origin mismatch: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max allowed: {:.4})", + doc_at_output_origin.x, + doc_at_output_origin.y, + viewport_start.x, + viewport_start.y, + error, + max_error + ); + } + + #[test] + fn test_tile_boundary_crossing_consistency() { + // This test verifies that crossing a tile boundary doesn't cause a large position jump. + // The floor() rounding can cause small errors (~2 doc units), but there should NOT be + // a tile-sized discontinuity when crossing boundaries. + let physical_scale = 0.52; + let tile_world_size = TILE_SIZE as f64 / physical_scale; + let max_error = 2.0 / physical_scale; // Maximum error from floor() rounding + + // Two viewport positions: just before and just after a tile boundary + let viewport_before = DVec2::new(-536.51, -490.36); + let viewport_after = DVec2::new(-536.51, -493.24); // Moved down slightly, crosses tile boundary + + let calc_output_origin = |viewport_start: DVec2| -> DVec2 { + let viewport_bounds = AxisAlignedBbox { + start: viewport_start, + end: viewport_start + DVec2::new(1000.0, 1000.0), + }; + + let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); + let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + + let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); + + let offset_world = region_world_start - viewport_start; + let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); + + let src_x = if offset_pixels.x >= 0 { 0 } else { (-offset_pixels.x) as u32 }; + let src_y = if offset_pixels.y >= 0 { 0 } else { (-offset_pixels.y) as u32 }; + + region_world_start + DVec2::new(src_x as f64, src_y as f64) / physical_scale + }; + + let output_before = calc_output_origin(viewport_before); + let output_after = calc_output_origin(viewport_after); + + // Check that output[0,0] shows approximately the correct viewport origin + let error_before = (output_before - viewport_before).length(); + let error_after = (output_after - viewport_after).length(); + + assert!( + error_before < max_error, + "Before crossing: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max: {:.4})", + output_before.x, + output_before.y, + viewport_before.x, + viewport_before.y, + error_before, + max_error + ); + + assert!( + error_after < max_error, + "After crossing: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max: {:.4})", + output_after.x, + output_after.y, + viewport_after.x, + viewport_after.y, + error_after, + max_error + ); + + // CRITICAL: The viewport moved by ~3 units, so output origin should also move by ~3 units. + // A tile-sized jump would be ~492 units - that would indicate a bug. + let viewport_delta = (viewport_after - viewport_before).length(); + let output_delta = (output_after - output_before).length(); + let delta_diff = (output_delta - viewport_delta).abs(); + + // Allow twice the per-position error since both positions have rounding error + assert!( + delta_diff < 2.0 * max_error, + "Position delta mismatch: viewport moved {:.4}, output moved {:.4}, difference: {:.4} (max: {:.4})", + viewport_delta, + output_delta, + delta_diff, + 2.0 * max_error + ); + + // Additional check: the delta should be nowhere near a tile size + let tile_size_doc = tile_world_size; + assert!( + delta_diff < tile_size_doc * 0.1, // Should be much less than 10% of a tile + "TILE-SIZED JUMP DETECTED: viewport moved {:.4}, output moved {:.4}, difference: {:.4} (tile size: {:.4})", + viewport_delta, + output_delta, + delta_diff, + tile_size_doc + ); + } + + #[test] + fn test_negative_tile_coordinates() { + // Test that negative tile coordinates work correctly + let scale = 1.0; + let bounds = AxisAlignedBbox { + start: DVec2::new(-512.0, -256.0), + end: DVec2::new(0.0, 0.0), + }; + let tiles = world_bounds_to_tiles(&bounds, scale); + + // At scale 1.0, 256 pixels = 256 world units per tile + // -512 to 0 should cover tiles -2, -1 in x + // -256 to 0 should cover tile -1 in y + assert!(tiles.contains(&TileCoord { x: -2, y: -1 })); + assert!(tiles.contains(&TileCoord { x: -1, y: -1 })); + } + + #[test] + fn test_round_trip_tile_conversion() { + // Converting world bounds to tiles and back should give tile-aligned bounds + // that fully contain the original bounds + let scale = 2.5; + let original_bounds = AxisAlignedBbox { + start: DVec2::new(100.3, 200.7), + end: DVec2::new(500.1, 800.9), + }; + + let tiles = world_bounds_to_tiles(&original_bounds, scale); + let tile_bounds = tiles_to_world_bounds(&tiles, scale); + + // Tile bounds should contain original bounds + assert!( + tile_bounds.start.x <= original_bounds.start.x, + "Tile start.x {} should be <= original start.x {}", + tile_bounds.start.x, + original_bounds.start.x + ); + assert!( + tile_bounds.start.y <= original_bounds.start.y, + "Tile start.y {} should be <= original start.y {}", + tile_bounds.start.y, + original_bounds.start.y + ); + assert!( + tile_bounds.end.x >= original_bounds.end.x, + "Tile end.x {} should be >= original end.x {}", + tile_bounds.end.x, + original_bounds.end.x + ); + assert!( + tile_bounds.end.y >= original_bounds.end.y, + "Tile end.y {} should be >= original end.y {}", + tile_bounds.end.y, + original_bounds.end.y + ); + } + + #[test] + fn test_device_scale_handling() { + // Test that the physical_scale = logical_scale * device_scale relationship holds + let logical_scale = 0.3467; + let device_scale = 1.5; + let physical_scale = logical_scale * device_scale; + + // At physical_scale, each tile covers this many document units + let tile_world_size = TILE_SIZE as f64 / physical_scale; + + // Verify: rendering at physical_scale to TILE_SIZE pixels covers tile_world_size document units + // This is fundamental to tile alignment working correctly + let rendered_doc_coverage = TILE_SIZE as f64 / physical_scale; + assert!( + (rendered_doc_coverage - tile_world_size).abs() < 0.001, + "Tile world size mismatch: {} vs {}", + tile_world_size, + rendered_doc_coverage + ); + } + + #[test] + fn test_offset_calculation_edge_cases() { + let physical_scale = 0.52; + let tile_world_size = TILE_SIZE as f64 / physical_scale; + + // Test when viewport is exactly at a tile boundary + let tile_aligned_viewport = DVec2::new(-2.0 * tile_world_size, -1.0 * tile_world_size); + let viewport_bounds = AxisAlignedBbox { + start: tile_aligned_viewport, + end: tile_aligned_viewport + DVec2::new(1000.0, 1000.0), + }; + + let tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); + let min_tile = tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); + + let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); + + // When viewport is tile-aligned, region_world_start should equal viewport start + let diff = (region_world_start - tile_aligned_viewport).length(); + assert!(diff < 0.001, "Tile-aligned viewport should have zero offset, got {}", diff); + } } From 15b5ef402ba143fc9b0786e490e9998a9108deb8 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 00:20:08 +0100 Subject: [PATCH 4/9] Initial cleanup --- node-graph/nodes/gstd/src/render_cache.rs | 279 +++++----------------- 1 file changed, 54 insertions(+), 225 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 20d297f4b4..19f3b96938 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -86,9 +86,8 @@ 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::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use std::hash::{Hash, Hasher}; +use std::hash::Hash; use std::sync::{Arc, Mutex}; use crate::render_node::RenderOutputType; @@ -865,7 +864,21 @@ pub async fn composite_regions<'a>(cached_regions: Vec, viewport_b // Node implementation -/// Simplified render cache for debugging - no caching, single tile-aligned region +/// Renders content to a tile-aligned region and copies the visible portion to the viewport. +/// +/// This node renders to a larger tile-aligned texture, then copies the portion that +/// corresponds to the actual viewport. This enables future tile-based caching where +/// previously rendered tiles can be reused when panning. +/// +/// ## Coordinate spaces +/// - Document space: World units of the artwork +/// - Logical pixels: Viewport pixels before device scale (footprint.resolution) +/// - Physical pixels: Actual GPU texture pixels (logical * device_scale) +/// +/// ## Key relationships +/// - `logical_scale` = footprint.decompose_scale().x (document → logical pixels) +/// - `device_scale` = render_params.scale (logical → physical pixels) +/// - `physical_scale` = logical_scale * device_scale (document → physical pixels) #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync, @@ -886,201 +899,55 @@ pub async fn render_output_cache<'a: 'n>( return data.eval(context.into_context()).await; } - // DEBUG modes to isolate the bug: - // Mode 0: Full bypass - render directly with original footprint (known working) - // Mode 1: Render with original footprint, do texture copy (tests copy logic) - WORKS - // Mode 2: Render with region footprint (transform + resolution), do copy - BROKEN - // Mode 3: Original transform + region resolution (tests resolution change) - WORKS - // Mode 4: Region transform + original resolution (tests transform change) - BROKEN - // Mode 5: Small offset transform (10 doc units) - tests if ANY translation change breaks - // Mode 6: Reconstructed original transform - tests if reconstruction method matters - const DEBUG_MODE: u8 = 2; - 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; - // NOTE: The render pipeline applies device_scale to the transform. - // So the rendered texture has content at physical_scale density. - // We use logical_scale for tile calculations to match viewport_bounds coordinate space. - let tile_scale = logical_scale; let viewport_bounds = footprint.viewport_bounds_in_local_space(); - if DEBUG_MODE == 0 { - log::debug!("[mode0] Full bypass - rendering directly with original footprint"); - let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); - return data.eval(context.into_context()).await; - } - - // Calculate tile-aligned bounds using logical_scale (matches footprint.resolution) - let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, tile_scale); + // Calculate tile-aligned bounds that cover the viewport + let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, logical_scale); let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); let max_tile = viewport_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 / tile_scale; + // Calculate region origin in document space (tile-aligned) + 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); - log::debug!( - "[mode{}] viewport: ({:.2}, {:.2}), tile_scale: {:.4}, device_scale: {:.4}, tiles: ({},{}) to ({},{})", - DEBUG_MODE, - viewport_bounds.start.x, - viewport_bounds.start.y, - tile_scale, - device_scale, - min_tile.x, - min_tile.y, - max_tile.x, - max_tile.y - ); - log::debug!( - "[DIAG] region_world_start: ({:.2}, {:.2}), tile_world_size: {:.2}", - region_world_start.x, - region_world_start.y, - tile_world_size - ); - log::debug!("[DIAG] footprint.transform: {:?}", footprint.transform); - log::debug!("[DIAG] Expected at output[0,0]: ({:.2}, {:.2})", viewport_bounds.start.x, viewport_bounds.start.y); - - // Calculate region values needed for modes 2-4 + // Calculate region texture size in physical pixels let tiles_wide = (max_tile.x - min_tile.x + 1) as u32; let tiles_high = (max_tile.y - min_tile.y + 1) as u32; - // Tiles are calculated at logical_scale, but render applies device_scale. - // So we need to scale up the texture size to cover the same document area. let region_pixel_size = UVec2::new( ((tiles_wide * TILE_SIZE) as f64 * device_scale).ceil() as u32, ((tiles_high * TILE_SIZE) as f64 * device_scale).ceil() as u32, ); + + // Build transform for the tile-aligned region let region_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-region_world_start); - // Render the content based on mode - let mut result = match DEBUG_MODE { - 1 => { - // Mode 1: Original footprint (transform + resolution) - log::debug!("[mode1] Original footprint"); - let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); - data.eval(context.into_context()).await - } - 2 => { - // Mode 2: Region footprint (region transform + region resolution) - log::debug!("[mode2] Region footprint (transform + resolution)"); - 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.clone()).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); - data.eval(region_ctx).await - } - 3 => { - // Mode 3: Original transform + region resolution (tests resolution change only) - log::debug!("[mode3] Original transform + region resolution"); - let test_footprint = Footprint { - transform: footprint.transform, // ORIGINAL transform - resolution: region_pixel_size, // REGION resolution (larger) - quality: RenderQuality::Full, - }; - let mut test_params = render_params.clone(); - test_params.footprint = test_footprint; - let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); - data.eval(test_ctx).await - } - 4 => { - // Mode 4: Region transform + original resolution (tests transform change only) - log::debug!("[mode4] Region transform + original resolution"); - let test_footprint = Footprint { - transform: region_transform, // REGION transform (different origin) - resolution: physical_resolution, // ORIGINAL resolution - quality: RenderQuality::Full, - }; - let mut test_params = render_params.clone(); - test_params.footprint = test_footprint; - let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); - data.eval(test_ctx).await - } - 5 => { - // Mode 5: Small offset transform (10 doc units) - tests if ANY translation change breaks - let small_offset = DVec2::new(10.0, 10.0); - let small_offset_origin = viewport_bounds.start - small_offset; - let small_offset_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-small_offset_origin); - log::debug!("[mode5] Small offset transform (10 units), origin: ({:.2}, {:.2})", small_offset_origin.x, small_offset_origin.y); - let test_footprint = Footprint { - transform: small_offset_transform, - resolution: physical_resolution, - quality: RenderQuality::Full, - }; - let mut test_params = render_params.clone(); - test_params.footprint = test_footprint; - let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); - data.eval(test_ctx).await - } - 6 => { - // Mode 6: Reconstructed original transform - tests if reconstruction method matters - // Extract the original viewport origin and rebuild the transform the same way we build region_transform - let original_origin = viewport_bounds.start; - let reconstructed_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-original_origin); - log::debug!("[mode6] Reconstructed original transform, origin: ({:.2}, {:.2})", original_origin.x, original_origin.y); - log::debug!("[mode6] Original transform: {:?}", footprint.transform); - log::debug!("[mode6] Reconstructed: {:?}", reconstructed_transform); - let test_footprint = Footprint { - transform: reconstructed_transform, - resolution: physical_resolution, - quality: RenderQuality::Full, - }; - let mut test_params = render_params.clone(); - test_params.footprint = test_footprint; - let test_ctx = OwnedContextImpl::from(ctx.clone()).with_footprint(test_footprint).with_vararg(Box::new(test_params)).into_context(); - data.eval(test_ctx).await - } - _ => panic!("Invalid DEBUG_MODE"), + // Render to tile-aligned region + 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.clone()) + .with_footprint(region_footprint) + .with_vararg(Box::new(region_params)) + .into_context(); + let mut result = data.eval(region_ctx).await; + let RenderOutputType::Texture(rendered_texture) = result.data else { - panic!("Expected texture output"); + panic!("Expected texture output from render"); }; - // Calculate offset and source texture size based on mode - let (offset_pixels, src_texture_size) = match DEBUG_MODE { - 1 | 3 | 6 => { - // Modes 1, 3, 6: rendered with original TRANSFORM (or reconstructed same), so offset is 0 - let src_size = if DEBUG_MODE == 3 { region_pixel_size } else { physical_resolution }; - log::debug!("[mode{}] Using zero offset (rendered at viewport position), src_size: {:?}", DEBUG_MODE, src_size); - (IVec2::ZERO, src_size) - } - 2 | 4 => { - // Modes 2 & 4: rendered with region TRANSFORM, need offset - let src_size = if DEBUG_MODE == 2 { region_pixel_size } else { physical_resolution }; - let offset_world = region_world_start - viewport_bounds.start; - let offset_px = (offset_world * physical_scale).floor().as_ivec2(); - log::debug!( - "[mode{}] offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {}), src_size: {:?}", - DEBUG_MODE, - offset_world.x, - offset_world.y, - offset_px.x, - offset_px.y, - src_size - ); - (offset_px, src_size) - } - 5 => { - // Mode 5: small offset (10 doc units before viewport) - let small_offset = DVec2::new(10.0, 10.0); - let offset_world = -small_offset; // render origin is before viewport - let offset_px = (offset_world * physical_scale).floor().as_ivec2(); - log::debug!( - "[mode5] Small offset: offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {})", - offset_world.x, - offset_world.y, - offset_px.x, - offset_px.y - ); - (offset_px, physical_resolution) - } - _ => panic!("Invalid DEBUG_MODE"), - }; + // Calculate pixel offset from region origin to viewport origin + let offset_world = region_world_start - viewport_bounds.start; + let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); - // Create output texture and copy from region + // Create output texture let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); let device = &exec.context.device; let queue = &exec.context.queue; @@ -1100,60 +967,31 @@ pub async fn render_output_cache<'a: 'n>( view_formats: &[], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("simple_copy") }); - - // Copy from source to output with offset - // offset > 0: source starts AFTER viewport, so dst = offset, src = 0 - // offset < 0: source starts BEFORE viewport, so dst = 0, src = -offset + // Copy visible portion from region texture to output + // offset < 0 means region starts before viewport, so we skip pixels in source + // offset > 0 means region starts after viewport, so we offset in destination let (src_x, dst_x, width) = if offset_pixels.x >= 0 { let dst = offset_pixels.x as u32; - let w = src_texture_size.x.min(physical_resolution.x.saturating_sub(dst)); + let w = region_pixel_size.x.min(physical_resolution.x.saturating_sub(dst)); (0, dst, w) } else { let skip = (-offset_pixels.x) as u32; - let w = src_texture_size.x.saturating_sub(skip).min(physical_resolution.x); + let w = region_pixel_size.x.saturating_sub(skip).min(physical_resolution.x); (skip, 0, w) }; let (src_y, dst_y, height) = if offset_pixels.y >= 0 { let dst = offset_pixels.y as u32; - let h = src_texture_size.y.min(physical_resolution.y.saturating_sub(dst)); + let h = region_pixel_size.y.min(physical_resolution.y.saturating_sub(dst)); (0, dst, h) } else { let skip = (-offset_pixels.y) as u32; - let h = src_texture_size.y.saturating_sub(skip).min(physical_resolution.y); + let h = region_pixel_size.y.saturating_sub(skip).min(physical_resolution.y); (skip, 0, h) }; - // Verify: output[dst] gets source[src], which shows document position - // For modes 1, 3, 6: source origin is viewport_bounds.start (original transform) - // For modes 2, 4: source origin is region_world_start (region transform) - // For mode 5: source origin is viewport_bounds.start - small_offset - let effective_origin = match DEBUG_MODE { - 1 | 3 | 6 => viewport_bounds.start, - 2 | 4 => region_world_start, - // 2 | 4 => viewport_bounds.start, - 5 => viewport_bounds.start - DVec2::new(10.0, 10.0), - _ => panic!("Invalid DEBUG_MODE"), - }; - let doc_at_output_origin = effective_origin + DVec2::new(src_x as f64, src_y as f64) / physical_scale; - log::debug!("[mode{}] copy: src=({},{}) dst=({},{}) size={}x{}", DEBUG_MODE, src_x, src_y, dst_x, dst_y, width, height); - log::debug!( - "[mode{}] VERIFY: output[{},{}] shows doc ({:.2}, {:.2}), should be ({:.2}, {:.2})", - DEBUG_MODE, - dst_x, - dst_y, - doc_at_output_origin.x, - doc_at_output_origin.y, - viewport_bounds.start.x, - viewport_bounds.start.y - ); - // Show the error between what we show and what we should show - let error = doc_at_output_origin - viewport_bounds.start; - let error_in_tiles = error * tile_scale / TILE_SIZE as f64; - log::debug!("[DIAG] ERROR: ({:.4}, {:.4}) doc units = ({:.4}, {:.4}) tiles", error.x, error.y, error_in_tiles.x, error_in_tiles.y); - if width > 0 && height > 0 { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_copy") }); encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &rendered_texture.texture, @@ -1173,25 +1011,16 @@ pub async fn render_output_cache<'a: 'n>( depth_or_array_layers: 1, }, ); + queue.submit([encoder.finish()]); } - queue.submit([encoder.finish()]); - - // Transform metadata from region pixel space to viewport pixel space. - // Metadata is in LOGICAL pixel space (matching footprint.resolution), not physical pixels. - // Region logical pixel (0,0) maps to document position region_world_start. - // Viewport logical pixel (0,0) maps to document position viewport_bounds.start. - // So: viewport_logical = region_logical + offset_logical - if DEBUG_MODE == 2 || DEBUG_MODE == 4 || DEBUG_MODE == 5 { - let offset_world = region_world_start - viewport_bounds.start; - let metadata_offset = offset_world * logical_scale; // Use logical scale, not physical - let metadata_transform = glam::DAffine2::from_translation(metadata_offset); - result.metadata.apply_transform(metadata_transform); - } + // Transform metadata from region space to viewport space + // Metadata is in logical pixels, so use logical_scale for the offset + let metadata_offset = offset_world * logical_scale; + result.metadata.apply_transform(glam::DAffine2::from_translation(metadata_offset)); RenderOutput { data: RenderOutputType::Texture(ImageTexture { texture: output_texture }), - // data: RenderOutputType::Texture(ImageTexture { texture: rendered_texture.texture }), metadata: result.metadata, } } From b5993503d23ede1201e4ccd90dc4e912970c0c5e Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 19:06:12 +0100 Subject: [PATCH 5/9] Integrate cache with context invalidation --- .../interpreted-executor/src/node_registry.rs | 1 + node-graph/interpreted-executor/src/util.rs | 4 +- .../libraries/rendering/src/renderer.rs | 10 + node-graph/nodes/gstd/src/render_cache.rs | 242 ++++++++++++------ 4 files changed, 180 insertions(+), 77 deletions(-) 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 081edd4cc4..de9b57ba9b 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -46,7 +46,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc(cached_regions: Vec, viewport_b // Node implementation -/// Renders content to a tile-aligned region and copies the visible portion to the viewport. +/// Renders content with tile-based caching for efficient viewport panning. /// -/// This node renders to a larger tile-aligned texture, then copies the portion that -/// corresponds to the actual viewport. This enables future tile-based caching where -/// previously rendered tiles can be reused when panning. +/// This node caches rendered tiles and reuses them when the viewport pans, only +/// rendering newly visible tiles. This significantly improves performance during +/// navigation by avoiding redundant rendering of previously visible content. /// /// ## Coordinate spaces /// - Document space: World units of the artwork @@ -879,12 +879,17 @@ pub async fn composite_regions<'a>(cached_regions: Vec, viewport_b /// - `logical_scale` = footprint.decompose_scale().x (document → logical pixels) /// - `device_scale` = render_params.scale (logical → physical pixels) /// - `physical_scale` = logical_scale * device_scale (document → physical pixels) +/// +/// ## Caching strategy +/// - Tiles are computed at `logical_scale` (256 logical pixels per tile) +/// - Textures are stored at `physical_scale` density +/// - Cache is invalidated when scale or render parameters change #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( - ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync, + ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + Sync, editor_api: &'a WasmEditorApi, data: impl Node, Output = RenderOutput> + Send + Sync, - #[data] _tile_cache: TileCache, + #[data] tile_cache: TileCache, ) -> RenderOutput { let footprint = ctx.footprint(); let render_params = ctx @@ -905,12 +910,67 @@ pub async fn render_output_cache<'a: 'n>( let physical_scale = logical_scale * device_scale; let viewport_bounds = footprint.viewport_bounds_in_local_space(); - // Calculate tile-aligned bounds that cover the viewport - let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, logical_scale); - let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); - let max_tile = viewport_tiles.iter().fold(IVec2::new(i32::MIN, i32::MIN), |acc, t| acc.max(IVec2::new(t.x, t.y))); + // Create cache key from render parameters + let cache_key = CacheKey::from_times( + 0, // TODO: hash render_mode properly + render_params.hide_artboards, + render_params.for_export, + false, // for_mask + render_params.thumbnail, + render_params.aligned_strokes, + false, // override_paint_order + ctx.try_animation_time().unwrap_or(0.0), + ctx.try_real_time().unwrap_or(0.0), + ); + + // Query cache for existing tiles and missing regions + let cache_query = tile_cache.query(&viewport_bounds, logical_scale, &cache_key); + + // Render missing regions + let mut new_regions = Vec::new(); + if cache_query.missing_regions.is_empty() { + println!("reusing cache"); + } else { + println!("rerendering {} regions", cache_query.missing_regions.len()); + } + 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); + } + + // Store newly rendered regions in cache + tile_cache.store_regions(new_regions.clone()); + + // Collect all regions (cached + newly rendered) + let all_regions: Vec<_> = cache_query.cached_regions.into_iter().chain(new_regions.into_iter()).collect(); + + // Composite all regions into output + 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, + } +} + +/// Render a missing region and create a CachedRegion for storage. +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, +{ + // Calculate region bounds from tiles + 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))); - // Calculate region origin in document space (tile-aligned) 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); @@ -933,30 +993,50 @@ pub async fn render_output_cache<'a: 'n>( }; let mut region_params = render_params.clone(); region_params.footprint = region_footprint; - let region_ctx = OwnedContextImpl::from(ctx.clone()) - .with_footprint(region_footprint) - .with_vararg(Box::new(region_params)) - .into_context(); - let mut result = data.eval(region_ctx).await; + 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"); }; - // Calculate pixel offset from region origin to viewport origin - let offset_world = region_world_start - viewport_bounds.start; - let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); + // Transform metadata from region pixel space to document space for storage + // Region pixel (0,0) = region_world_start in document space + // So: document = region_world_start + pixel / logical_scale + 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); - // Create output texture - let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); + 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, // Will be set by cache + memory_size, + } +} + +/// Composite cached regions into the final viewport output texture. +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; + // Create output texture let output_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("viewport_output"), size: wgpu::Extent3d { - width: physical_resolution.x, - height: physical_resolution.y, + width: output_resolution.x, + height: output_resolution.y, depth_or_array_layers: 1, }, mip_level_count: 1, @@ -967,62 +1047,74 @@ pub async fn render_output_cache<'a: 'n>( view_formats: &[], }); - // Copy visible portion from region texture to output - // offset < 0 means region starts before viewport, so we skip pixels in source - // offset > 0 means region starts after viewport, so we offset in destination - let (src_x, dst_x, width) = if offset_pixels.x >= 0 { - let dst = offset_pixels.x as u32; - let w = region_pixel_size.x.min(physical_resolution.x.saturating_sub(dst)); - (0, dst, w) - } else { - let skip = (-offset_pixels.x) as u32; - let w = region_pixel_size.x.saturating_sub(skip).min(physical_resolution.x); - (skip, 0, w) - }; + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("composite") }); + let mut combined_metadata = rendering::RenderMetadata::default(); - let (src_y, dst_y, height) = if offset_pixels.y >= 0 { - let dst = offset_pixels.y as u32; - let h = region_pixel_size.y.min(physical_resolution.y.saturating_sub(dst)); - (0, dst, h) - } else { - let skip = (-offset_pixels.y) as u32; - let h = region_pixel_size.y.saturating_sub(skip).min(physical_resolution.y); - (skip, 0, h) - }; + // Copy each region to the output texture + for region in regions { + // Find region's min tile to calculate its origin + let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(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); - if width > 0 && height > 0 { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_copy") }); - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &rendered_texture.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, - }, - ); - queue.submit([encoder.finish()]); - } + // Calculate pixel offset from region origin to viewport origin + let offset_world = region_world_start - viewport_bounds.start; + let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); - // Transform metadata from region space to viewport space - // Metadata is in logical pixels, so use logical_scale for the offset - let metadata_offset = offset_world * logical_scale; - result.metadata.apply_transform(glam::DAffine2::from_translation(metadata_offset)); + // Calculate copy parameters + let (src_x, dst_x, width) = if offset_pixels.x >= 0 { + let dst = offset_pixels.x as u32; + let w = region.texture_size.x.min(output_resolution.x.saturating_sub(dst)); + (0, dst, w) + } else { + let skip = (-offset_pixels.x) as u32; + let w = region.texture_size.x.saturating_sub(skip).min(output_resolution.x); + (skip, 0, w) + }; - RenderOutput { - data: RenderOutputType::Texture(ImageTexture { texture: output_texture }), - metadata: result.metadata, + let (src_y, dst_y, height) = if offset_pixels.y >= 0 { + let dst = offset_pixels.y as u32; + let h = region.texture_size.y.min(output_resolution.y.saturating_sub(dst)); + (0, dst, h) + } else { + let skip = (-offset_pixels.y) as u32; + let h = region.texture_size.y.saturating_sub(skip).min(output_resolution.y); + (skip, 0, h) + }; + + 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 and merge metadata + // Metadata is stored in document space, convert 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) } #[cfg(test)] From 455e069ba2a32e63f31abce869f0ea3c2676c959 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 19:40:42 +0100 Subject: [PATCH 6/9] Store ClickTargets in Arc --- .../portfolio/document/document_message.rs | 3 ++- .../document/document_message_handler.rs | 12 ++++++++++-- .../document/utility_types/document_metadata.rs | 11 ++++++----- .../document/utility_types/network_interface.rs | 5 +++-- node-graph/libraries/rendering/src/renderer.rs | 16 ++++++++-------- node-graph/nodes/gstd/src/render_cache.rs | 11 +++-------- 6 files changed, 32 insertions(+), 26 deletions(-) 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/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 7166fffd82..8936690b5e 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -247,7 +247,7 @@ pub struct RenderMetadata { pub upstream_footprints: HashMap, pub local_transforms: HashMap, pub first_element_source_id: HashMap>, - pub click_targets: HashMap>, + pub click_targets: HashMap>>, pub clip_targets: HashSet, } @@ -481,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 { @@ -676,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()); } } @@ -1183,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 } @@ -1192,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); } @@ -1376,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() { @@ -1436,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/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 0b0e3932d2..729ded7e59 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -912,13 +912,13 @@ pub async fn render_output_cache<'a: 'n>( // Create cache key from render parameters let cache_key = CacheKey::from_times( - 0, // TODO: hash render_mode properly + render_params.render_mode as u64, render_params.hide_artboards, render_params.for_export, - false, // for_mask + render_params.for_mask, render_params.thumbnail, render_params.aligned_strokes, - false, // override_paint_order + render_params.override_paint_order, ctx.try_animation_time().unwrap_or(0.0), ctx.try_real_time().unwrap_or(0.0), ); @@ -928,11 +928,6 @@ pub async fn render_output_cache<'a: 'n>( // Render missing regions let mut new_regions = Vec::new(); - if cache_query.missing_regions.is_empty() { - println!("reusing cache"); - } else { - println!("rerendering {} regions", cache_query.missing_regions.len()); - } 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); From a7ea4b67c94b53860bc9c3ef3bef8dba63e8c0a0 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 19:52:44 +0100 Subject: [PATCH 7/9] Cleanup --- node-graph/nodes/gstd/src/render_cache.rs | 962 +--------------------- 1 file changed, 30 insertions(+), 932 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 729ded7e59..d9e2847395 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -1,82 +1,4 @@ -//! # Render Cache Module -//! -//! This module implements tile-based caching for rendered output to enable efficient -//! incremental rendering when panning the viewport. -//! -//! ## Coordinate Spaces -//! -//! The render cache operates across three coordinate spaces: -//! -//! ```text -//! ┌─────────────────────────────────────────────────────────────────────────────┐ -//! │ COORDINATE SPACES │ -//! ├─────────────────────────────────────────────────────────────────────────────┤ -//! │ │ -//! │ 1. DOCUMENT SPACE (World Space) │ -//! │ - The canonical coordinate system for artwork │ -//! │ - Units: abstract "world units" (typically 1 unit = 1 pixel at 100%) │ -//! │ - Origin: document origin (0,0) │ -//! │ - Used for: storing artwork, metadata click targets │ -//! │ │ -//! │ 2. TILE GRID SPACE │ -//! │ - Integer grid for cache management │ -//! │ - Units: tile indices (i32) │ -//! │ - Each tile covers TILE_SIZE (256) pixels at current scale │ -//! │ - Tile (0,0) covers world region [0, TILE_SIZE/scale) │ -//! │ - Tile grid is scale-dependent: different scales = different grids │ -//! │ │ -//! │ 3. PIXEL SPACE (Viewport Space) │ -//! │ - Screen pixels for final rendering │ -//! │ - Units: pixels (u32 for sizes, i32 for positions) │ -//! │ - Origin: viewport top-left corner │ -//! │ - Resolution: footprint.resolution │ -//! │ │ -//! ├─────────────────────────────────────────────────────────────────────────────┤ -//! │ COORDINATE CONVERSIONS │ -//! ├─────────────────────────────────────────────────────────────────────────────┤ -//! │ │ -//! │ scale = pixels per world unit (from footprint.transform) │ -//! │ │ -//! │ Document → Pixel: │ -//! │ pixel = (world - viewport_origin) * scale │ -//! │ │ -//! │ Pixel → Document: │ -//! │ world = pixel / scale + viewport_origin │ -//! │ │ -//! │ Document → Tile: │ -//! │ tile.x = floor(world.x * scale / TILE_SIZE) │ -//! │ tile.y = floor(world.y * scale / TILE_SIZE) │ -//! │ │ -//! │ Tile → Document (tile origin): │ -//! │ world.x = tile.x * TILE_SIZE / scale │ -//! │ world.y = tile.y * TILE_SIZE / scale │ -//! │ │ -//! ├─────────────────────────────────────────────────────────────────────────────┤ -//! │ COMPOSITING PIPELINE │ -//! ├─────────────────────────────────────────────────────────────────────────────┤ -//! │ │ -//! │ Stage 1: Render missing regions to tile-aligned textures │ -//! │ - Each region covers an integer number of tiles │ -//! │ - Region texture size = (tiles_wide, tiles_high) * TILE_SIZE │ -//! │ - Region transform: scale + translate to region origin │ -//! │ │ -//! │ Stage 2: Copy regions to tile-aligned intermediate texture │ -//! │ - Position = (region_min_tile - global_min_tile) * TILE_SIZE │ -//! │ - No fractional offsets - everything is tile-aligned │ -//! │ │ -//! │ Stage 3: Copy from intermediate to viewport output │ -//! │ - Single offset calculation: tile_origin - viewport_origin │ -//! │ - This is the ONLY place with sub-tile precision │ -//! │ │ -//! └─────────────────────────────────────────────────────────────────────────────┘ -//! ``` -//! -//! ## Key Invariants -//! -//! - All cached region textures are tile-aligned (dimensions are multiples of TILE_SIZE) -//! - Tile coordinates are computed from world coordinates at the current scale -//! - When scale changes, the entire cache is invalidated (tile grid changes) -//! - Metadata is stored in document space and transformed to viewport space on output +//! Tile-based render caching for efficient viewport panning. use core_types::math::bbox::AxisAlignedBbox; use core_types::transform::{Footprint, RenderQuality, Transform}; @@ -92,66 +14,30 @@ use std::sync::{Arc, Mutex}; use crate::render_node::RenderOutputType; -// Constants -/// Size of each cache tile in pixels. Tiles form a grid in pixel space. pub const TILE_SIZE: u32 = 256; -/// Maximum memory budget for cached regions (512MB). pub const MAX_CACHE_MEMORY_BYTES: usize = 512 * 1024 * 1024; -/// Maximum dimension for a single region texture (4096px = 16 tiles). pub const MAX_REGION_DIMENSION: u32 = 4096; -const BYTES_PER_PIXEL: usize = 4; // RGBA8Unorm - -/// Tile coordinate in the tile grid. -/// -/// The tile grid divides pixel space into TILE_SIZE × TILE_SIZE squares. -/// Tile (x, y) covers pixels from (x * TILE_SIZE, y * TILE_SIZE) to -/// ((x+1) * TILE_SIZE, (y+1) * TILE_SIZE) exclusive. -/// -/// In document space, tile (x, y) covers the region: -/// - Start: (x * TILE_SIZE / scale, y * TILE_SIZE / scale) -/// - End: ((x+1) * TILE_SIZE / scale, (y+1) * TILE_SIZE / scale) -/// -/// Note: No scale/zoom is stored in the coordinate itself. The tile grid -/// is specific to a given scale; when scale changes, all cached regions -/// are invalidated. +const BYTES_PER_PIXEL: usize = 4; + #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] pub struct TileCoord { pub x: i32, pub y: i32, } -/// A cached rendered region. -/// -/// Each region is rendered at tile-aligned boundaries (texture dimensions are -/// multiples of TILE_SIZE). The region covers one or more contiguous tiles. -/// -/// ## Coordinate spaces stored: -/// - `texture_size`: Pixel dimensions of the cached texture -/// - `world_bounds`: Document-space bounds (used for debugging/validation) -/// - `tiles`: Which tiles this region covers (tile grid space) -/// - `metadata`: Click targets etc. stored in document space #[derive(Debug, Clone)] pub struct CachedRegion { - /// The GPU texture containing rendered content pub texture: wgpu::Texture, - /// Pixel dimensions of the texture (always tile-aligned: multiples of TILE_SIZE) pub texture_size: UVec2, - /// Document-space bounds this region covers pub world_bounds: AxisAlignedBbox, - /// Tiles covered by this region (for cache lookup by tile coordinate) pub tiles: Vec, - /// Metadata (click targets, etc.) stored in document space pub metadata: rendering::RenderMetadata, - /// LRU timestamp for eviction last_access: u64, - /// Memory consumption in bytes memory_size: usize, } -// Cache key for invalidation based on RenderParams #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct CacheKey { - // Fields from RenderParams that affect rendering output pub render_mode_hash: u64, pub hide_artboards: bool, pub for_export: bool, @@ -159,13 +45,11 @@ pub struct CacheKey { pub thumbnail: bool, pub aligned_strokes: bool, pub override_paint_order: bool, - // Time fields quantized to milliseconds for Eq/Hash pub animation_time_ms: i64, pub real_time_ms: i64, } impl CacheKey { - /// Create a cache key from f64 times (quantizes to milliseconds) pub fn from_times( render_mode_hash: u64, hide_artboards: bool, @@ -207,14 +91,12 @@ impl Default for CacheKey { } } -/// Internal cache implementation #[derive(Debug)] struct TileCacheImpl { - regions: Vec, // Stored as Vec since regions can overlap in tile space + regions: Vec, timestamp: u64, total_memory: usize, cache_key: CacheKey, - /// Current scale (pixels per world unit) - regions are invalidated when this changes current_scale: f64, } @@ -230,68 +112,30 @@ impl Default for TileCacheImpl { } } -// Public thread-safe wrapper #[derive(Clone, Default, dyn_any::DynAny, Debug)] pub struct TileCache(Arc>); -/// A contiguous region that needs to be rendered. -/// -/// Created by the cache query when tiles are missing. Groups adjacent -/// missing tiles into a single render operation for efficiency. #[derive(Debug, Clone)] pub struct RenderRegion { - /// Document-space bounds to render pub world_bounds: AxisAlignedBbox, - /// Tiles that this region will cover once rendered pub tiles: Vec, - /// Scale (pixels per world unit) at which to render pub scale: f64, } -// Cache query result #[derive(Debug)] pub struct CacheQuery { pub cached_regions: Vec, pub missing_regions: Vec, } -// ============================================================================ -// COORDINATE CONVERSION FUNCTIONS -// ============================================================================ -// -// All functions use `scale` (pixels per world unit) directly, NOT zoom_level. -// This avoids precision loss from log2/exp2 round-trips. -// -// IMPORTANT: These conversions define how document space maps to the tile grid. -// The tile grid is in PIXEL space, divided into TILE_SIZE × TILE_SIZE squares. - -/// Convert document-space bounds to the tiles that cover them. -/// -/// # Conversion steps: -/// 1. Document bounds → Pixel bounds: `pixel = world * scale` -/// 2. Pixel bounds → Tile range: `tile = floor(pixel / TILE_SIZE)` for start, -/// `tile = ceil(pixel / TILE_SIZE)` for end -/// -/// # Arguments -/// * `bounds` - Bounding box in document (world) space -/// * `scale` - Pixels per world unit -/// -/// # Returns -/// All tiles that intersect the given bounds pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, scale: f64) -> Vec { - // Step 1: Convert document bounds to pixel bounds let pixel_start = bounds.start * scale; let pixel_end = bounds.end * scale; - - // Step 2: Convert pixel bounds to tile grid coordinates - // floor() for start: include any tile that overlaps the start edge - // ceil() for end: include any tile that overlaps the end edge 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; - // Generate all tile coordinates in the range [start, end) let mut tiles = Vec::new(); for y in tile_start_y..tile_end_y { for x in tile_start_x..tile_end_x { @@ -301,90 +145,56 @@ pub fn world_bounds_to_tiles(bounds: &AxisAlignedBbox, scale: f64) -> Vec DVec2 { DVec2::new(tile.x as f64, tile.y as f64) * (TILE_SIZE as f64 / scale) } -/// Convert a tile coordinate to its document-space bounding box. -/// -/// # Returns -/// The axis-aligned box in document space that this tile covers: -/// - Start: `(tile.x * TILE_SIZE / scale, tile.y * TILE_SIZE / scale)` -/// - End: `((tile.x + 1) * TILE_SIZE / scale, (tile.y + 1) * TILE_SIZE / 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), - } + AxisAlignedBbox { start, end: start + DVec2::splat(tile_world_size) } } -/// Get the combined document-space bounding box of multiple tiles. 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..] { - let bounds = tile_to_world_bounds(tile, scale); - result = result.union(&bounds); + result = result.union(&tile_to_world_bounds(tile, scale)); } result } -// Cache implementation - impl TileCacheImpl { - /// Query cache for viewport bounds at given scale (pixels per world unit) fn query(&mut self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey) -> CacheQuery { - // Check if cache needs invalidation due to cache key change - if &self.cache_key != cache_key { + if &self.cache_key != cache_key || (self.current_scale - scale).abs() > 0.001 { self.invalidate_all(); self.cache_key = cache_key.clone(); - } - - // Check if scale changed - invalidate regions but keep cache key - if (self.current_scale - scale).abs() > 0.001 { - self.invalidate_all(); 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(); - // Find cached regions that cover any of the required tiles 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)) { - // Update LRU region.last_access = self.timestamp; self.timestamp += 1; - cached_regions.push(region.clone()); covered_tiles.extend(region_tiles); } } - // Find missing 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 } } - /// Store newly rendered regions fn store_regions(&mut self, new_regions: Vec) { for mut region in new_regions { region.last_access = self.timestamp; @@ -392,15 +202,11 @@ impl TileCacheImpl { self.total_memory += region.memory_size; self.regions.push(region); } - - // Evict old regions if over memory limit self.evict_until_under_budget(); } - /// LRU eviction to stay under memory budget fn evict_until_under_budget(&mut self) { while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() { - // Find oldest region 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(); @@ -411,18 +217,15 @@ impl TileCacheImpl { } } - /// Clear all cached regions fn invalidate_all(&mut self) { for region in &self.regions { region.texture.destroy(); } self.regions.clear(); self.total_memory = 0; - // Don't reset timestamp - it's monotonic } } -// Public TileCache API 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) @@ -433,11 +236,6 @@ impl TileCache { } } -/// Group tiles into contiguous regions using flood-fill, then split oversized regions. -/// -/// # Arguments -/// * `tiles` - Tile coordinates to group (in tile grid space) -/// * `scale` - Pixels per world unit (used to convert tiles back to world bounds) fn group_into_regions(tiles: &[TileCoord], scale: f64) -> Vec { if tiles.is_empty() { return Vec::new(); @@ -451,43 +249,21 @@ fn group_into_regions(tiles: &[TileCoord], scale: f64) -> Vec { if visited.contains(&tile) { continue; } - - // Flood-fill to find connected region 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, - }; - - // Split if region exceeds MAX_REGION_DIMENSION - let split_regions = split_oversized_region(region, scale); - regions.extend(split_regions); + let region = RenderRegion { world_bounds, tiles: region_tiles, scale }; + regions.extend(split_oversized_region(region, scale)); } - regions } -/// Split region if it exceeds MAX_REGION_DIMENSION, aligned to tile boundaries. -/// -/// # Arguments -/// * `region` - The region to potentially split -/// * `scale` - Pixels per world unit fn split_oversized_region(region: RenderRegion, scale: f64) -> Vec { - let region_size = region.world_bounds.size(); - let pixel_size = region_size * scale; - - // Check if region fits within limits + 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]; } - // Calculate how many tiles fit in MAX_REGION_DIMENSION - let max_tiles_per_dimension = (MAX_REGION_DIMENSION / TILE_SIZE) as i32; // Should be 16 - - // Group tiles into grid of chunks + 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 { @@ -496,17 +272,16 @@ fn split_oversized_region(region: RenderRegion, scale: f64) -> Vec chunks.entry((chunk_x, chunk_y)).or_default().push(tile); } - // Convert chunks into regions chunks .into_iter() - .map(|(_, tiles)| { - let world_bounds = tiles_to_world_bounds(&tiles, scale); - RenderRegion { world_bounds, tiles, scale } + .map(|(_, tiles)| RenderRegion { + world_bounds: tiles_to_world_bounds(&tiles, scale), + tiles, + scale, }) .collect() } -/// Flood-fill to find connected tiles (4-connected neighbors) fn flood_fill(start: &TileCoord, tile_set: &HashSet, visited: &mut HashSet) -> Vec { let mut result = Vec::new(); let mut stack = vec![*start]; @@ -515,375 +290,23 @@ fn flood_fill(start: &TileCoord, tile_set: &HashSet, visited: &mut Ha if visited.contains(¤t) || !tile_set.contains(¤t) { continue; } - visited.insert(current); result.push(current); - // Check 4-connected neighbors - let neighbors = [ + 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 }, - ]; - - for neighbor in neighbors { + ] { if tile_set.contains(&neighbor) && !visited.contains(&neighbor) { stack.push(neighbor); } } } - result } -// ============================================================================ -// RENDERING AND TEXTURE OPERATIONS -// ============================================================================ - -/// Render a single region to a tile-aligned texture. -/// -/// # Transform construction -/// -/// The region footprint transform maps document space to the region's pixel space: -/// -/// ```text -/// region_transform = scale_transform * translation -/// -/// where: -/// scale_transform: scales document units to pixels (same as viewport) -/// translation: shifts origin from (0,0) to region's top-left corner -/// -/// For a point P in document space: -/// pixel = (P - region_origin) * scale -/// -/// This ensures the tile grid aligns exactly: each tile boundary in pixel space -/// corresponds to an integer multiple of TILE_SIZE. -/// ``` -/// -/// # Metadata handling -/// -/// The render function produces metadata in the region's pixel space. -/// We convert it back to document space before storing in the cache: -/// `metadata_document = metadata_region * inverse(region_transform)` -/// -/// # Returns -/// * `RenderOutput` - The rendered output with metadata in document space -/// * `UVec2` - The actual texture dimensions (always tile-aligned) -pub async fn render_region<'a, F, Fut>( - region: &RenderRegion, - render_fn: F, - _editor_api: &'a WasmEditorApi, - _base_render_params: &RenderParams, - base_ctx: impl Ctx + ExtractAll + CloneVarArgs, - base_footprint: &Footprint, -) -> (RenderOutput, UVec2) -where - F: FnOnce(Context<'static>) -> Fut, - Fut: std::future::Future, -{ - // Calculate region texture size from tile count (guaranteed tile-aligned) - 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 min_y = region.tiles.iter().map(|t| t.y).min().unwrap(); - let max_y = region.tiles.iter().map(|t| t.y).max().unwrap(); - - let tiles_wide = (max_x - min_x + 1) as u32; - let tiles_high = (max_y - min_y + 1) as u32; - let physical_size = UVec2::new(tiles_wide * TILE_SIZE, tiles_high * TILE_SIZE); - - // Extract scale from base footprint (pixels per world unit) - let base_scale = base_footprint.decompose_scale(); - - // Calculate region origin in document space from tile coordinates - // This ensures perfect tile alignment: tile(x,y) → world(x * TILE_SIZE / scale, ...) - let world_units_per_pixel = 1.0 / base_scale.x; - let tile_world_size = TILE_SIZE as f64 * world_units_per_pixel; - let region_world_start = DVec2::new(min_x as f64 * tile_world_size, min_y as f64 * tile_world_size); - - // Build region transform: pixel = (document - region_origin) * scale - // In matrix form: scale * translate(-region_origin) - let scale_transform = glam::DAffine2::from_scale(base_scale); - let translation = glam::DAffine2::from_translation(-region_world_start); - let region_transform = scale_transform * translation; - - // DEBUG: Log the region rendering parameters - log::debug!( - "[render_region] tiles: x=[{}, {}], y=[{}, {}], size: {}x{}", - min_x, - max_x, - min_y, - max_y, - physical_size.x, - physical_size.y - ); - log::debug!( - "[render_region] region_world_start: ({:.2}, {:.2}), base_scale: ({:.4}, {:.4})", - region_world_start.x, - region_world_start.y, - base_scale.x, - base_scale.y - ); - // Verify: document point at region_world_start should map to pixel (0,0) - let test_pixel = region_transform.transform_point2(region_world_start); - log::debug!( - "[render_region] transform check: region_world_start -> pixel ({:.2}, {:.2}) (should be 0,0)", - test_pixel.x, - test_pixel.y - ); - // And check what document point maps to the viewport's start - let viewport_world_start = base_footprint.viewport_bounds_in_local_space().start; - let viewport_in_region_pixels = region_transform.transform_point2(viewport_world_start); - log::debug!( - "[render_region] viewport_world_start ({:.2}, {:.2}) -> region_pixel ({:.2}, {:.2})", - viewport_world_start.x, - viewport_world_start.y, - viewport_in_region_pixels.x, - viewport_in_region_pixels.y - ); - - let region_footprint = Footprint { - transform: region_transform, - resolution: physical_size, - quality: RenderQuality::Full, - }; - - // Create context with region footprint - let mut region_params = _base_render_params.clone(); - region_params.footprint = region_footprint; - - // Build context from base context with new footprint - let region_ctx = OwnedContextImpl::from(base_ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); - - // Evaluate render function with region context - let mut result = render_fn(region_ctx).await; - - // Convert metadata back to document space by applying region_transform^-1 - let translation_back = glam::DAffine2::from_translation(region_world_start); - let region_to_document_transform = translation_back * scale_transform.inverse(); - result.metadata.apply_transform(region_to_document_transform); - - (result, physical_size) -} - -/// Composite cached region textures into the final viewport output texture. -/// -/// # Two-stage compositing approach -/// -/// ## Stage 1: Assemble tile-aligned intermediate texture -/// All cached regions are copied into a single tile-aligned intermediate texture. -/// Since every region is tile-aligned (dimensions are multiples of TILE_SIZE), -/// no sub-pixel offsets are needed - positions are computed as: -/// `pixel_offset = (region_min_tile - global_min_tile) * TILE_SIZE` -/// -/// ## Stage 2: Copy to viewport output -/// The tile-aligned intermediate is copied to the viewport output texture. -/// This is the ONLY place where sub-tile precision matters: -/// `offset = tile_aligned_origin - viewport_origin` (in pixels) -/// -/// # Coordinate conversion for Stage 2: -/// ```text -/// tile_aligned_world_start = min_tile * TILE_SIZE / scale (document space) -/// offset_world = tile_aligned_world_start - viewport_bounds.start -/// offset_pixels = offset_world * scale -/// ``` -/// -/// # Arguments -/// * `cached_regions` - Regions to composite (all tile-aligned) -/// * `viewport_bounds` - Document-space bounds of the viewport -/// * `output_resolution` - Pixel dimensions of the output texture -/// * `scale` - Pixels per world unit -/// * `editor_api` - For GPU access -pub async fn composite_regions<'a>(cached_regions: Vec, viewport_bounds: &AxisAlignedBbox, output_resolution: UVec2, scale: f64, editor_api: &'a WasmEditorApi) -> wgpu::Texture { - let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let queue = &exec.context.queue; - - // STAGE 1: Determine tile-aligned bounds that cover all regions - let mut min_tile = IVec2::new(i32::MAX, i32::MAX); - let mut max_tile = IVec2::new(i32::MIN, i32::MIN); - - for region in &cached_regions { - for tile in ®ion.tiles { - min_tile = min_tile.min(IVec2::new(tile.x, tile.y)); - max_tile = max_tile.max(IVec2::new(tile.x, tile.y)); - } - } - - // Calculate tile-aligned intermediate texture size - let tile_count = (max_tile - min_tile) + IVec2::ONE; - let tile_aligned_size = tile_count.as_uvec2() * TILE_SIZE; - - // Create tile-aligned intermediate texture - let tile_aligned_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("tile_aligned_composite"), - size: wgpu::Extent3d { - width: tile_aligned_size.x, - height: tile_aligned_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_DST | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_composite") }); - - // STAGE 1: Copy each region to its tile-aligned position - for region in &cached_regions { - let region_min_tile = IVec2::new(region.tiles.iter().map(|t| t.x).min().unwrap(), region.tiles.iter().map(|t| t.y).min().unwrap()); - - // Calculate position in tile-aligned texture (in tiles, then convert to pixels) - let tile_offset = region_min_tile - min_tile; - let pixel_offset = tile_offset.as_uvec2() * TILE_SIZE; - - // Simple copy - everything is tile-aligned! - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: ®ion.texture, - mip_level: 0, - origin: wgpu::Origin3d { x: 0, y: 0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: &tile_aligned_texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: pixel_offset.x, - y: pixel_offset.y, - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: region.texture_size.x, - height: region.texture_size.y, - depth_or_array_layers: 1, - }, - ); - } - - // STAGE 2: Copy from tile-aligned texture to viewport output - // Convert tile origin to document space: tile * TILE_SIZE / scale - let tile_aligned_world_start = min_tile.as_dvec2() * (TILE_SIZE as f64 / scale); - - // Calculate offset from tile-aligned texture origin to viewport origin (in document space) - // Then convert to pixels: offset_pixels = offset_world * scale - let offset_world = tile_aligned_world_start - viewport_bounds.start; - let offset_pixels_f64 = offset_world * scale; - let offset_pixels = IVec2::new(offset_pixels_f64.x.floor() as i32, offset_pixels_f64.y.floor() as i32); - - // DEBUG: Log the offset calculation - log::debug!( - "[composite] viewport_world: ({:.2}, {:.2}), tile_aligned_world: ({:.2}, {:.2})", - viewport_bounds.start.x, - viewport_bounds.start.y, - tile_aligned_world_start.x, - tile_aligned_world_start.y - ); - log::debug!( - "[composite] offset_world: ({:.2}, {:.2}), offset_pixels: ({}, {})", - offset_world.x, - offset_world.y, - offset_pixels.x, - offset_pixels.y - ); - log::debug!( - "[composite] min_tile: ({}, {}), scale: {:.4}, tile_aligned_size: ({}, {})", - min_tile.x, - min_tile.y, - scale, - tile_aligned_size.x, - tile_aligned_size.y - ); - - // Create final output texture - 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: &[], - }); - - // Handle negative offsets (tile-aligned texture extends before viewport) - let (src_x, dst_x, width) = if offset_pixels.x < 0 { - let skip = (-offset_pixels.x) as u32; - let w = tile_aligned_size.x.saturating_sub(skip).min(output_resolution.x); - (skip, 0, w) - } else { - let dst = offset_pixels.x as u32; - let w = tile_aligned_size.x.min(output_resolution.x.saturating_sub(dst)); - (0, dst, w) - }; - - let (src_y, dst_y, height) = if offset_pixels.y < 0 { - let skip = (-offset_pixels.y) as u32; - let h = tile_aligned_size.y.saturating_sub(skip).min(output_resolution.y); - (skip, 0, h) - } else { - let dst = offset_pixels.y as u32; - let h = tile_aligned_size.y.min(output_resolution.y.saturating_sub(dst)); - (0, dst, h) - }; - - // Single copy from tile-aligned to output - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &tile_aligned_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, - }, - ); - - queue.submit([encoder.finish()]); - output_texture -} - -// Node implementation - -/// Renders content with tile-based caching for efficient viewport panning. -/// -/// This node caches rendered tiles and reuses them when the viewport pans, only -/// rendering newly visible tiles. This significantly improves performance during -/// navigation by avoiding redundant rendering of previously visible content. -/// -/// ## Coordinate spaces -/// - Document space: World units of the artwork -/// - Logical pixels: Viewport pixels before device scale (footprint.resolution) -/// - Physical pixels: Actual GPU texture pixels (logical * device_scale) -/// -/// ## Key relationships -/// - `logical_scale` = footprint.decompose_scale().x (document → logical pixels) -/// - `device_scale` = render_params.scale (logical → physical pixels) -/// - `physical_scale` = logical_scale * device_scale (document → physical pixels) -/// -/// ## Caching strategy -/// - Tiles are computed at `logical_scale` (256 logical pixels per tile) -/// - Textures are stored at `physical_scale` density -/// - Cache is invalidated when scale or render parameters change #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + Sync, @@ -892,13 +315,8 @@ pub async fn render_output_cache<'a: 'n>( #[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"); + let render_params = ctx.vararg(0).expect("Did not find var args").downcast_ref::().expect("Downcasting render params yielded invalid type"); - // Only use tile-aligned rendering for Vello 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; @@ -910,7 +328,6 @@ pub async fn render_output_cache<'a: 'n>( let physical_scale = logical_scale * device_scale; let viewport_bounds = footprint.viewport_bounds_in_local_space(); - // Create cache key from render parameters let cache_key = CacheKey::from_times( render_params.render_mode as u64, render_params.hide_artboards, @@ -923,23 +340,17 @@ pub async fn render_output_cache<'a: 'n>( ctx.try_real_time().unwrap_or(0.0), ); - // Query cache for existing tiles and missing regions let cache_query = tile_cache.query(&viewport_bounds, logical_scale, &cache_key); - // Render missing regions 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); } - // Store newly rendered regions in cache tile_cache.store_regions(new_regions.clone()); - // Collect all regions (cached + newly rendered) let all_regions: Vec<_> = cache_query.cached_regions.into_iter().chain(new_regions.into_iter()).collect(); - - // Composite all regions into output 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); @@ -949,7 +360,6 @@ pub async fn render_output_cache<'a: 'n>( } } -/// Render a missing region and create a CachedRegion for storage. async fn render_missing_region( region: &RenderRegion, render_fn: F, @@ -962,14 +372,12 @@ where F: Fn(Context<'static>) -> Fut, Fut: std::future::Future, { - // Calculate region bounds from tiles 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 region texture size in physical pixels let tiles_wide = (max_tile.x - min_tile.x + 1) as u32; let tiles_high = (max_tile.y - min_tile.y + 1) as u32; let region_pixel_size = UVec2::new( @@ -977,15 +385,13 @@ where ((tiles_high * TILE_SIZE) as f64 * device_scale).ceil() as u32, ); - // Build transform for the tile-aligned region let region_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-region_world_start); - - // Render to tile-aligned region 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(); @@ -995,9 +401,7 @@ where panic!("Expected texture output from render"); }; - // Transform metadata from region pixel space to document space for storage - // Region pixel (0,0) = region_world_start in document space - // So: document = region_world_start + pixel / logical_scale + // 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); @@ -1009,12 +413,11 @@ where world_bounds: region.world_bounds.clone(), tiles: region.tiles.clone(), metadata: result.metadata, - last_access: 0, // Will be set by cache + last_access: 0, memory_size, } } -/// Composite cached regions into the final viewport output texture. fn composite_cached_regions( regions: &[CachedRegion], viewport_bounds: &AxisAlignedBbox, @@ -1026,14 +429,9 @@ fn composite_cached_regions( let device = &exec.context.device; let queue = &exec.context.queue; - // Create output texture 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, - }, + 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, @@ -1045,62 +443,37 @@ fn composite_cached_regions( let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("composite") }); let mut combined_metadata = rendering::RenderMetadata::default(); - // Copy each region to the output texture for region in regions { - // Find region's min tile to calculate its origin let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(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 offset from region origin to viewport origin let offset_world = region_world_start - viewport_bounds.start; let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); - // Calculate copy parameters let (src_x, dst_x, width) = if offset_pixels.x >= 0 { - let dst = offset_pixels.x as u32; - let w = region.texture_size.x.min(output_resolution.x.saturating_sub(dst)); - (0, dst, w) + (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; - let w = region.texture_size.x.saturating_sub(skip).min(output_resolution.x); - (skip, 0, w) + (skip, 0, region.texture_size.x.saturating_sub(skip).min(output_resolution.x)) }; let (src_y, dst_y, height) = if offset_pixels.y >= 0 { - let dst = offset_pixels.y as u32; - let h = region.texture_size.y.min(output_resolution.y.saturating_sub(dst)); - (0, dst, h) + (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; - let h = region.texture_size.y.saturating_sub(skip).min(output_resolution.y); - (skip, 0, h) + (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, - }, + 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 and merge metadata - // Metadata is stored in document space, convert to viewport logical pixels + // 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); @@ -1108,280 +481,5 @@ fn composite_cached_regions( } queue.submit([encoder.finish()]); - (output_texture, combined_metadata) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tile_coordinate_conversion() { - // scale = 4.0 pixels per world unit - let scale = 4.0; - let coord = TileCoord { x: 0, y: 0 }; - let bounds = tile_to_world_bounds(&coord, scale); - - // At scale 4.0, 256 pixels = 64 world units - assert_eq!(bounds.start, DVec2::ZERO); - assert_eq!(bounds.end, DVec2::splat(64.0)); - } - - #[test] - fn test_world_to_tiles() { - // scale = 1.0, 1 pixel = 1 world unit - let scale = 1.0; - let bounds = AxisAlignedBbox { - start: DVec2::ZERO, - end: DVec2::new(512.0, 256.0), - }; - let tiles = world_bounds_to_tiles(&bounds, scale); - - // Should be 2x1 tiles (512x256 pixels at scale 1.0) - assert_eq!(tiles.len(), 2); - assert!(tiles.contains(&TileCoord { x: 0, y: 0 })); - assert!(tiles.contains(&TileCoord { x: 1, y: 0 })); - } - - #[test] - fn test_tile_alignment_offset_calculation() { - // This test verifies that the offset calculation correctly maps - // output[0,0] to show the viewport origin, regardless of tile alignment - // - // Note: floor() rounding in offset calculation causes up to 1/scale document units - // of error. At physical_scale=0.52, this is up to ~2 document units. - let physical_scale = 0.52; // Simulates logical_scale * device_scale - let tile_world_size = TILE_SIZE as f64 / physical_scale; - - // Test case: viewport at position that's NOT tile-aligned - let viewport_start = DVec2::new(-536.51, -493.24); - let viewport_bounds = AxisAlignedBbox { - start: viewport_start, - end: viewport_start + DVec2::new(1000.0, 1000.0), - }; - - // Calculate tiles - let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); - let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); - - // Calculate region origin (tile-aligned) - let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); - - // Calculate offset - let offset_world = region_world_start - viewport_start; - let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); - - // Determine src position for copy (where in region texture corresponds to viewport origin) - let src_x = if offset_pixels.x >= 0 { 0 } else { (-offset_pixels.x) as u32 }; - let src_y = if offset_pixels.y >= 0 { 0 } else { (-offset_pixels.y) as u32 }; - - // Calculate what document position output[0,0] would show - let doc_at_output_origin = region_world_start + DVec2::new(src_x as f64, src_y as f64) / physical_scale; - - // The maximum error from floor() is 1 pixel, which is 1/scale document units - // At scale 0.52, that's about 1.92 document units per axis, or ~2.7 total - let max_error = 2.0 / physical_scale; // 1 pixel per axis, diagonal - let error = (doc_at_output_origin - viewport_start).length(); - assert!( - error < max_error, - "Output origin mismatch: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max allowed: {:.4})", - doc_at_output_origin.x, - doc_at_output_origin.y, - viewport_start.x, - viewport_start.y, - error, - max_error - ); - } - - #[test] - fn test_tile_boundary_crossing_consistency() { - // This test verifies that crossing a tile boundary doesn't cause a large position jump. - // The floor() rounding can cause small errors (~2 doc units), but there should NOT be - // a tile-sized discontinuity when crossing boundaries. - let physical_scale = 0.52; - let tile_world_size = TILE_SIZE as f64 / physical_scale; - let max_error = 2.0 / physical_scale; // Maximum error from floor() rounding - - // Two viewport positions: just before and just after a tile boundary - let viewport_before = DVec2::new(-536.51, -490.36); - let viewport_after = DVec2::new(-536.51, -493.24); // Moved down slightly, crosses tile boundary - - let calc_output_origin = |viewport_start: DVec2| -> DVec2 { - let viewport_bounds = AxisAlignedBbox { - start: viewport_start, - end: viewport_start + DVec2::new(1000.0, 1000.0), - }; - - let viewport_tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); - let min_tile = viewport_tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); - - let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); - - let offset_world = region_world_start - viewport_start; - let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); - - let src_x = if offset_pixels.x >= 0 { 0 } else { (-offset_pixels.x) as u32 }; - let src_y = if offset_pixels.y >= 0 { 0 } else { (-offset_pixels.y) as u32 }; - - region_world_start + DVec2::new(src_x as f64, src_y as f64) / physical_scale - }; - - let output_before = calc_output_origin(viewport_before); - let output_after = calc_output_origin(viewport_after); - - // Check that output[0,0] shows approximately the correct viewport origin - let error_before = (output_before - viewport_before).length(); - let error_after = (output_after - viewport_after).length(); - - assert!( - error_before < max_error, - "Before crossing: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max: {:.4})", - output_before.x, - output_before.y, - viewport_before.x, - viewport_before.y, - error_before, - max_error - ); - - assert!( - error_after < max_error, - "After crossing: got ({:.4}, {:.4}), expected ({:.4}, {:.4}), error: {:.4} (max: {:.4})", - output_after.x, - output_after.y, - viewport_after.x, - viewport_after.y, - error_after, - max_error - ); - - // CRITICAL: The viewport moved by ~3 units, so output origin should also move by ~3 units. - // A tile-sized jump would be ~492 units - that would indicate a bug. - let viewport_delta = (viewport_after - viewport_before).length(); - let output_delta = (output_after - output_before).length(); - let delta_diff = (output_delta - viewport_delta).abs(); - - // Allow twice the per-position error since both positions have rounding error - assert!( - delta_diff < 2.0 * max_error, - "Position delta mismatch: viewport moved {:.4}, output moved {:.4}, difference: {:.4} (max: {:.4})", - viewport_delta, - output_delta, - delta_diff, - 2.0 * max_error - ); - - // Additional check: the delta should be nowhere near a tile size - let tile_size_doc = tile_world_size; - assert!( - delta_diff < tile_size_doc * 0.1, // Should be much less than 10% of a tile - "TILE-SIZED JUMP DETECTED: viewport moved {:.4}, output moved {:.4}, difference: {:.4} (tile size: {:.4})", - viewport_delta, - output_delta, - delta_diff, - tile_size_doc - ); - } - - #[test] - fn test_negative_tile_coordinates() { - // Test that negative tile coordinates work correctly - let scale = 1.0; - let bounds = AxisAlignedBbox { - start: DVec2::new(-512.0, -256.0), - end: DVec2::new(0.0, 0.0), - }; - let tiles = world_bounds_to_tiles(&bounds, scale); - - // At scale 1.0, 256 pixels = 256 world units per tile - // -512 to 0 should cover tiles -2, -1 in x - // -256 to 0 should cover tile -1 in y - assert!(tiles.contains(&TileCoord { x: -2, y: -1 })); - assert!(tiles.contains(&TileCoord { x: -1, y: -1 })); - } - - #[test] - fn test_round_trip_tile_conversion() { - // Converting world bounds to tiles and back should give tile-aligned bounds - // that fully contain the original bounds - let scale = 2.5; - let original_bounds = AxisAlignedBbox { - start: DVec2::new(100.3, 200.7), - end: DVec2::new(500.1, 800.9), - }; - - let tiles = world_bounds_to_tiles(&original_bounds, scale); - let tile_bounds = tiles_to_world_bounds(&tiles, scale); - - // Tile bounds should contain original bounds - assert!( - tile_bounds.start.x <= original_bounds.start.x, - "Tile start.x {} should be <= original start.x {}", - tile_bounds.start.x, - original_bounds.start.x - ); - assert!( - tile_bounds.start.y <= original_bounds.start.y, - "Tile start.y {} should be <= original start.y {}", - tile_bounds.start.y, - original_bounds.start.y - ); - assert!( - tile_bounds.end.x >= original_bounds.end.x, - "Tile end.x {} should be >= original end.x {}", - tile_bounds.end.x, - original_bounds.end.x - ); - assert!( - tile_bounds.end.y >= original_bounds.end.y, - "Tile end.y {} should be >= original end.y {}", - tile_bounds.end.y, - original_bounds.end.y - ); - } - - #[test] - fn test_device_scale_handling() { - // Test that the physical_scale = logical_scale * device_scale relationship holds - let logical_scale = 0.3467; - let device_scale = 1.5; - let physical_scale = logical_scale * device_scale; - - // At physical_scale, each tile covers this many document units - let tile_world_size = TILE_SIZE as f64 / physical_scale; - - // Verify: rendering at physical_scale to TILE_SIZE pixels covers tile_world_size document units - // This is fundamental to tile alignment working correctly - let rendered_doc_coverage = TILE_SIZE as f64 / physical_scale; - assert!( - (rendered_doc_coverage - tile_world_size).abs() < 0.001, - "Tile world size mismatch: {} vs {}", - tile_world_size, - rendered_doc_coverage - ); - } - - #[test] - fn test_offset_calculation_edge_cases() { - let physical_scale = 0.52; - let tile_world_size = TILE_SIZE as f64 / physical_scale; - - // Test when viewport is exactly at a tile boundary - let tile_aligned_viewport = DVec2::new(-2.0 * tile_world_size, -1.0 * tile_world_size); - let viewport_bounds = AxisAlignedBbox { - start: tile_aligned_viewport, - end: tile_aligned_viewport + DVec2::new(1000.0, 1000.0), - }; - - let tiles = world_bounds_to_tiles(&viewport_bounds, physical_scale); - let min_tile = tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y))); - - let region_world_start = DVec2::new(min_tile.x as f64 * tile_world_size, min_tile.y as f64 * tile_world_size); - - // When viewport is tile-aligned, region_world_start should equal viewport start - let diff = (region_world_start - tile_aligned_viewport).length(); - assert!(diff < 0.001, "Tile-aligned viewport should have zero offset, got {}", diff); - } -} From f237657c8af6604857f88b27a3de634c6d31b56f Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 20:07:02 +0100 Subject: [PATCH 8/9] Improve rounding and reduce tile size to fix vello not rendering --- node-graph/nodes/gstd/src/render_cache.rs | 67 +++++++++++++++++------ 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index d9e2847395..e05e34beb3 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -16,7 +16,7 @@ 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 = 4096; +pub const MAX_REGION_DIMENSION: u32 = 1024; const BYTES_PER_PIXEL: usize = 4; #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] @@ -153,7 +153,10 @@ pub fn tile_world_start(tile: &TileCoord, scale: f64) -> DVec2 { 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) } + AxisAlignedBbox { + start, + end: start + DVec2::splat(tile_world_size), + } } pub fn tiles_to_world_bounds(tiles: &[TileCoord], scale: f64) -> AxisAlignedBbox { @@ -251,7 +254,11 @@ fn group_into_regions(tiles: &[TileCoord], scale: f64) -> Vec { } 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 }; + let region = RenderRegion { + world_bounds, + tiles: region_tiles, + scale, + }; regions.extend(split_oversized_region(region, scale)); } regions @@ -315,7 +322,11 @@ pub async fn render_output_cache<'a: 'n>( #[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"); + 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())); @@ -378,12 +389,11 @@ where 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); - let tiles_wide = (max_tile.x - min_tile.x + 1) as u32; - let tiles_high = (max_tile.y - min_tile.y + 1) as u32; - let region_pixel_size = UVec2::new( - ((tiles_wide * TILE_SIZE) as f64 * device_scale).ceil() as u32, - ((tiles_high * TILE_SIZE) as f64 * device_scale).ceil() as u32, - ); + // 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 { @@ -431,7 +441,11 @@ fn composite_cached_regions( 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 }, + 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, @@ -443,13 +457,16 @@ fn composite_cached_regions( 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))); - 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); - let offset_world = region_world_start - viewport_bounds.start; - let offset_pixels = (offset_world * physical_scale).floor().as_ivec2(); + // 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))) @@ -467,9 +484,23 @@ fn composite_cached_regions( 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 }, + 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, + }, ); } From 01b4c1f88d7dc2c8b6f718fee818eb7f8e15519f Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 5 Feb 2026 21:05:44 +0100 Subject: [PATCH 9/9] Include pointer position in cache key --- node-graph/nodes/gstd/src/render_cache.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index e05e34beb3..2d93761645 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -2,7 +2,7 @@ use core_types::math::bbox::AxisAlignedBbox; use core_types::transform::{Footprint, RenderQuality, Transform}; -use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractRealTime, OwnedContextImpl}; +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; @@ -47,10 +47,11 @@ pub struct CacheKey { pub override_paint_order: bool, pub animation_time_ms: i64, pub real_time_ms: i64, + pub pointer: [u8; 16], } impl CacheKey { - pub fn from_times( + pub fn new( render_mode_hash: u64, hide_artboards: bool, for_export: bool, @@ -60,7 +61,16 @@ impl CacheKey { 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, @@ -71,6 +81,7 @@ impl CacheKey { 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, } } } @@ -87,6 +98,7 @@ impl Default for CacheKey { override_paint_order: false, animation_time_ms: 0, real_time_ms: 0, + pointer: [0u8; 16], } } } @@ -316,7 +328,7 @@ fn flood_fill(start: &TileCoord, tile_set: &HashSet, visited: &mut Ha #[node_macro::node(category(""))] pub async fn render_output_cache<'a: 'n>( - ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + Sync, + ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + ExtractPointerPosition + Sync, editor_api: &'a WasmEditorApi, data: impl Node, Output = RenderOutput> + Send + Sync, #[data] tile_cache: TileCache, @@ -339,7 +351,7 @@ pub async fn render_output_cache<'a: 'n>( let physical_scale = logical_scale * device_scale; let viewport_bounds = footprint.viewport_bounds_in_local_space(); - let cache_key = CacheKey::from_times( + let cache_key = CacheKey::new( render_params.render_mode as u64, render_params.hide_artboards, render_params.for_export, @@ -349,6 +361,7 @@ pub async fn render_output_cache<'a: 'n>( 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);