From a5da9449b50130a14285bd74729c74a4ff1d5ead Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 11 May 2026 15:44:07 +0200 Subject: [PATCH] :recycle: Refactor how target and backbuffer works --- frontend/src/debug.cljs | 9 ++ render-wasm/src/render.rs | 104 +++++++++-------------- render-wasm/src/render/debug.rs | 56 ++++++++++++- render-wasm/src/render/options.rs | 8 -- render-wasm/src/render/surfaces.rs | 128 ++++++++++++++--------------- render-wasm/src/render/ui.rs | 13 +-- 6 files changed, 170 insertions(+), 148 deletions(-) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 4ae8be8b17..985a936bcc 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -165,6 +165,15 @@ (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") "")))) +(defn ^:export wasmSurfaceConsole + "Logs the render-wasm surface id as an image in the JS console." + [id] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_surface_console"))] + (if (fn? f) + (wasm.h/call module "_debug_surface_console" id) + (js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2fc04534a2..545755ba81 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -804,7 +804,12 @@ impl RenderState { Ok(()) } + pub fn flush(&mut self) { + self.surfaces.flush(SurfaceId::Backbuffer); + } + pub fn flush_and_submit(&mut self) { + self.surfaces.copy_backbuffer_to_target(); self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -816,7 +821,7 @@ impl RenderState { /// This is currently not being used, but it's set there for testing purposes on /// upcoming tasks pub fn render_loading_overlay(&mut self) { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); let skia::ISize { width, height } = canvas.base_layer_size(); canvas.save(); @@ -863,8 +868,11 @@ impl RenderState { // the interaction ends. if self.options.is_interactive_transform() { let tile_rect = self.get_current_aligned_tile_bounds()?; - self.surfaces - .draw_current_tile_direct_target_only(&tile_rect, self.background_color); + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); return Ok(()); } @@ -879,10 +887,12 @@ impl RenderState { // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. let tile_rect = self.get_current_aligned_tile_bounds()?; + let current_tile = *self .current_tile .as_ref() .ok_or(Error::CriticalError("Current tile not found".to_string()))?; + self.surfaces.cache_current_tile_texture( &self.tile_viewbox, ¤t_tile, @@ -1759,7 +1769,7 @@ impl RenderState { // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { self.surfaces - .draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color); + .draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color); if self.options.is_debug_visible() { debug::render(self); @@ -1827,7 +1837,7 @@ impl RenderState { if !cache_covers { // Early return only if atlas exists; otherwise keep cache path. if self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_target( + self.surfaces.draw_atlas_to_backbuffer( self.viewbox, self.options.dpr, bg_color, @@ -1849,7 +1859,7 @@ impl RenderState { // Setup canvas transform { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); canvas.save(); canvas.scale((navigate_zoom, navigate_zoom)); canvas.translate((translate_x, translate_y)); @@ -1857,10 +1867,10 @@ impl RenderState { } // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_target(); + self.surfaces.draw_cache_to_backbuffer(); // Restore canvas state - self.surfaces.canvas(SurfaceId::Target).restore(); + self.surfaces.canvas(SurfaceId::Backbuffer).restore(); // During pure pan (same zoom), draw tiles from the HashMap // on top of the scaled Cache surface. Cached tile textures @@ -1967,7 +1977,6 @@ impl RenderState { if !self.interactive_target_seeded { // Seed from the last presented frame; this is stable even when // fast_mode skips cache updates and regardless of atlas coverage. - self.surfaces.seed_target_from_backbuffer(); self.interactive_target_seeded = true; } } else { @@ -2027,6 +2036,7 @@ impl RenderState { self.nested_shadows.clear(); // reorder by distance to the center. self.current_tile = None; + self.render_in_progress = true; self.apply_drawing_to_render_canvas(None, SurfaceId::Current); @@ -2090,38 +2100,24 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + self.render_shape_tree_partial(base_object, tree, timestamp, true)?; + if self.render_in_progress { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - } - - // In a pure viewport interaction (pan/zoom), render_from_cache - // owns the Target surface — skip flush so we don't present - // stale tile positions. The rAF still populates the Cache - // surface and tile HashMap so render_from_cache progressively - // shows more complete content. - // - // During interactive shape transforms (drag/resize/rotate) we - // still need to flush every rAF so the user sees the updated - // shape position — render_from_cache is not in the loop here. - if !self.options.is_viewport_interaction() { - self.flush_and_submit(); - } - - if self.render_in_progress { - self.cancel_animation_frame(); - self.render_request_id = Some(wapi::request_animation_frame!()); - } else { - // A full-quality frame is now complete. Refresh Backbuffer and regenerate - // the per-shape crop cache so interactive drags can reuse pixels. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.surfaces.copy_target_to_backbuffer(); - self.rebuild_backbuffer_crop_cache(tree); - } - wapi::notify_tiles_render_complete!(); - performance::end_measure!("render"); + self.flush(); + self.cancel_animation_frame(); + self.render_request_id = Some(wapi::request_animation_frame!()); + } else { + // A full-quality frame is now complete. Refresh Backbuffer and regenerate + // the per-shape crop cache so interactive drags can reuse pixels. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.surfaces.copy_backbuffer_to_target(); + self.rebuild_backbuffer_crop_cache(tree); } + self.flush_and_submit(); + wapi::notify_tiles_render_complete!(); + performance::end_measure!("render"); } + performance::end_measure!("process_animation_frame"); Ok(()) } @@ -2132,9 +2128,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result<()> { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, false)?; - } + self.render_shape_tree_partial(base_object, tree, timestamp, false)?; self.flush_and_submit(); Ok(()) } @@ -3308,6 +3302,7 @@ impl RenderState { return Ok(()); } performance::end_measure!("render_shape_tree::uncached"); + let tile_rect = self.get_current_tile_bounds()?; // Composite if the walker did work in this PAF (`!is_empty`) OR // the tile has unfinished work from a previous PAF @@ -3317,8 +3312,11 @@ impl RenderState { if self.options.is_interactive_transform() { // During drag, avoid snapshot-based caching. Draw Current directly // into Target (and Cache) to reduce stalls. - self.surfaces - .draw_current_tile_direct(&tile_rect, self.background_color); + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::Yes, + ); } else { self.apply_render_to_final_canvas(tile_rect)?; } @@ -3331,25 +3329,6 @@ impl RenderState { tile_rect, ); } - } else { - self.surfaces.apply_mut(SurfaceId::Target as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(tile_rect, &paint); - }); - // Keep Cache surface coherent for render_from_cache. - if !self.options.is_fast_mode() { - if !self.cache_cleared_this_render { - self.surfaces.clear_cache(self.background_color); - self.cache_cleared_this_render = true; - } - let aligned_rect = self.get_aligned_tile_bounds(current_tile); - self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(aligned_rect, &paint); - }); - } } } } @@ -3422,7 +3401,6 @@ impl RenderState { } self.render_in_progress = false; - self.surfaces.gc(); // Mark cache as valid for render_from_cache. diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 266a9043de..cddf371851 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -187,8 +187,52 @@ pub fn render_debug_shape( } } -#[cfg(target_arch = "wasm32")] #[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn trap() { + run_script!("debugger"); +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +#[derive(Debug, PartialEq)] +pub enum SurfaceBackendKind { + BackendTexture, // GPU Framebuffer (Texture) + BackendRenderTarget, // GPU Framebuffer (Renderbuffer) + Raster, // CPU + Unknown, +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind { + if skia::gpu::surfaces::get_backend_texture( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendTexture; + } + + if skia::gpu::surfaces::get_backend_render_target( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendRenderTarget; + } + + if surface.peek_pixels().is_some() { + return SurfaceBackendKind::Raster; + } + + SurfaceBackendKind::Unknown +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -198,6 +242,8 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -258,3 +304,11 @@ pub extern "C" fn debug_atlas_base64() -> Result<()> { console_debug_surface_base64(get_render_state(), SurfaceId::Atlas); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_surface_console(id: SurfaceId) -> Result<()> { + console_debug_surface(get_render_state(), id); + Ok(()) +} diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 0f80b28bcb..8142f1f52c 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -96,14 +96,6 @@ impl RenderOptions { self.interactive_transform = enabled; } - /// True only when the viewport is the one being moved (pan/zoom) - /// and the dedicated `render_from_cache` path owns Target - /// presentation. In this mode `process_animation_frame` must not - /// flush to avoid presenting stale tile positions. - pub fn is_viewport_interaction(&self) -> bool { - self.fast_mode && !self.interactive_transform - } - pub fn is_text_editor_v3(&self) -> bool { self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3 } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 381d69c730..6949e1ba2f 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -26,6 +26,12 @@ const TILE_SIZE_MULTIPLIER: i32 = 2; const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; +#[derive(Debug, PartialEq)] +pub enum DrawOnCache { + Yes, + No, +} + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -456,16 +462,21 @@ impl Surfaces { self.atlas_size.width > 0 && self.atlas_size.height > 0 } - /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). - /// Clears Target to `background` first so atlas-uncovered regions don't + /// Clears Backbuffer to `background` first so atlas-uncovered regions don't /// show stale content when the atlas only partially covers the viewport. - pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + pub fn draw_atlas_to_backbuffer( + &mut self, + viewbox: Viewbox, + dpr: f32, + background: skia::Color, + ) { if !self.has_atlas() { return; } - let canvas = self.target.canvas(); + let canvas = self.backbuffer.canvas(); canvas.save(); canvas.reset_matrix(); let size = canvas.base_layer_size(); @@ -597,6 +608,12 @@ impl Surfaces { self.dirty_surfaces = 0; } + pub fn flush(&mut self, id: SurfaceId) { + let gpu_state = get_gpu_state(); + let surface = self.get_mut(id); + gpu_state.context.flush_surface(surface); + } + pub fn flush_and_submit(&mut self, id: SurfaceId) { let gpu_state = get_gpu_state(); let surface = self.get_mut(id); @@ -614,12 +631,12 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the target canvas. + /// Draws the cache surface directly to the backbuffer canvas. /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_target(&mut self) { + pub fn draw_cache_to_backbuffer(&mut self) { let sampling_options = self.sampling_options; - self.cache.clone().draw( - self.target.canvas(), + self.cache.draw( + self.backbuffer.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -715,7 +732,7 @@ impl Surfaces { }); } - #[inline] + #[inline(always)] pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface { match id { SurfaceId::Target => &mut self.target, @@ -735,6 +752,7 @@ impl Surfaces { } } + #[inline(always)] fn get(&self, id: SurfaceId) -> &skia::Surface { match id { SurfaceId::Target => &self.target, @@ -759,23 +777,23 @@ impl Surfaces { (s.width(), s.height()) } - /// Copy the current `Target` contents into the persistent `Backbuffer`. + /// Copy the current `Backbuffer` contents into `Target`. /// This is a GPU→GPU copy via Skia (no ReadPixels). - pub fn copy_target_to_backbuffer(&mut self) { + pub fn copy_backbuffer_to_target(&mut self) { let sampling_options = self.sampling_options; - self.target.clone().draw( - self.backbuffer.canvas(), + self.backbuffer.draw( + self.target.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), ); } - /// Seed `Target` from `Backbuffer` (last presented frame). - pub fn seed_target_from_backbuffer(&mut self) { + /// Seed `Backbuffer` from `Target` (last presented frame). + pub fn seed_backbuffer_from_target(&mut self) { let sampling_options = self.sampling_options; - self.backbuffer.clone().draw( - self.target.canvas(), + self.target.draw( + self.backbuffer.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -801,7 +819,6 @@ impl Surfaces { .target .new_surface_with_dimensions(dim) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; - // The rest are tile size surfaces Ok(()) } @@ -1052,17 +1069,17 @@ impl Surfaces { let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(rect, &paint); + self.backbuffer.canvas().draw_rect(rect, &paint); - self.target + self.backbuffer .canvas() .draw_image_rect(&image, None, rect, &skia::Paint::default()); } } - /// Draws a cached tile texture to the Cache surface at the given + /// Draws a cached tile texture to the Cache self.backbuffer at the given /// cache-aligned rect. This keeps the Cache surface in sync with - /// Target so that `render_from_cache` (used during pan) has the + /// Backbuffer so that `render_from_cache` (used during pan) has the /// full scene including tiles served from the texture cache. pub fn draw_cached_tile_to_cache( &mut self, @@ -1083,53 +1100,14 @@ impl Surfaces { } } - /// Draws the current tile directly to the target and cache surfaces without + /// Draws the current tile directly to the backbuffer and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests). - pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) { - let sampling_options = self.sampling_options; - let src_rect = IRect::from_xywh( - self.margins.width, - self.margins.height, - self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, - self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, - ); - let src_rect_f = skia::Rect::from(src_rect); - - // Draw background - let mut paint = skia::Paint::default(); - paint.set_color(color); - self.target.canvas().draw_rect(tile_rect, &paint); - - // Draw current surface directly to target (no snapshot) - self.current.clone().draw( - self.target.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - - // Also draw to cache for render_from_cache - self.current.clone().draw( - self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - } - - /// Same as `draw_current_tile_direct` but draws only into Target. - /// Useful during interactive transforms to reduce extra GPU work. - pub fn draw_current_tile_direct_target_only( + pub fn draw_current_tile_direct( &mut self, tile_rect: &skia::Rect, color: skia::Color, + draw_on_cache: DrawOnCache, ) { let sampling_options = self.sampling_options; let src_rect = IRect::from_xywh( @@ -1140,12 +1118,15 @@ impl Surfaces { ); let src_rect_f = skia::Rect::from(src_rect); + let backbuffer_canvas = self.backbuffer.canvas(); + // Draw background let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(tile_rect, &paint); + backbuffer_canvas.draw_rect(tile_rect, &paint); - self.current.clone().draw( - self.target.canvas(), + // Draw current surface directly to target (no snapshot) + self.current.draw( + backbuffer_canvas, ( tile_rect.left - src_rect_f.left, tile_rect.top - src_rect_f.top, @@ -1153,6 +1134,19 @@ impl Surfaces { sampling_options, None, ); + + // Also draw to cache for render_from_cache + if draw_on_cache == DrawOnCache::Yes { + self.current.draw( + self.cache.canvas(), + ( + tile_rect.left - src_rect_f.left, + tile_rect.top - src_rect_f.top, + ), + sampling_options, + None, + ); + } } /// Full cache reset: clears both the tile texture cache and the cache canvas. diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 243d775812..71ff44eec6 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -6,21 +6,15 @@ use crate::shapes::{Layout, Type}; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let canvas = render_state.surfaces.canvas(SurfaceId::UI); + let viewbox = render_state.viewbox; + let zoom = viewbox.zoom * render_state.options.dpr; + let show_grid_id = render_state.show_grid; canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); canvas.save(); - - let viewbox = render_state.viewbox; - let zoom = viewbox.zoom * render_state.options.dpr; - canvas.scale((zoom, zoom)); - canvas.translate((-viewbox.area.left, -viewbox.area.top)); - let canvas = render_state.surfaces.canvas(SurfaceId::UI); - - let show_grid_id = render_state.show_grid; - if let Some(id) = show_grid_id { if let Some(shape) = shapes.get(&id) { grid_layout::render_overlay( @@ -67,6 +61,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { } canvas.restore(); + render_state.surfaces.draw_into( SurfaceId::UI, SurfaceId::Target,