From 5b83f9ccdceb177df8d6ede6d2c1574425f52b78 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 23 Apr 2026 10:52:55 +0200 Subject: [PATCH] wip --- render-wasm/src/main.rs | 8 ++++ render-wasm/src/render.rs | 69 ++++++++++++++++++++++++------ render-wasm/src/render/options.rs | 23 ++++++++++ render-wasm/src/render/surfaces.rs | 35 +++++++++++++-- 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9f298c3900..498fc4376b 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -395,6 +395,9 @@ pub extern "C" fn set_view_start() -> Result<()> { } performance::begin_measure!("set_view_start"); state.render_state.options.set_fast_mode(true); + // If a previous two-pass rebuild was mid-flight, discard its + // intent — the new gesture supersedes it. + state.render_state.options.set_defer_effects(false); performance::end_measure!("set_view_start"); }); Ok(()) @@ -429,6 +432,11 @@ pub extern "C" fn set_view_end() -> Result<()> { // preview of the old content while new tiles render. state.render_state.rebuild_tile_index(&state.shapes); state.render_state.surfaces.invalidate_tile_cache(); + // Start the progressive two-pass rebuild. Pass 1 renders + // tiles without blur/shadow for fast feedback; when it + // completes, process_animation_frame flips this off and + // kicks pass 2 which adds the effects back in place. + state.render_state.options.set_defer_effects(true); } else { // Pure pan at the same zoom level: tile contents have not // changed — only the viewport position moved. Update the diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 0d249f06e7..b99b77fb08 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -334,6 +334,12 @@ pub(crate) struct RenderState { /// Cleared at the beginning of a render pass; set to true after we clear Cache the first /// time we are about to blit a tile into Cache for this pass. pub cache_cleared_this_render: bool, + /// One-shot flag consumed by `start_render_loop`. When set, the Cache + /// surface is NOT wiped for the upcoming pass — its current content + /// (typically a first-pass preview) stays visible while new tiles + /// overwrite it in place. Used by the progressive two-pass rebuild + /// after a zoom ends. + pub preserve_cache_this_render: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -407,6 +413,7 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + preserve_cache_this_render: false, }) } @@ -464,7 +471,7 @@ impl RenderState { /// Must be called BEFORE any save_layer for the shape's own opacity/blend, /// so that the backdrop blur is independent of the shape's visual properties. fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) { - if self.options.is_fast_mode() { + if self.options.should_skip_effects() { return; } if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) @@ -713,6 +720,7 @@ impl RenderState { pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { let fast_mode = self.options.is_fast_mode(); + let skip_atlas = self.options.is_defer_effects(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // while still preventing stale pixels from surviving across full-quality renders. @@ -733,6 +741,7 @@ impl RenderState { ¤t_tile, &tile_rect, fast_mode, + skip_atlas, self.render_area, ); @@ -867,7 +876,7 @@ impl RenderState { let antialias = shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); - let fast_mode = self.options.is_fast_mode(); + let skip_effects = self.options.should_skip_effects(); let has_nested_fills = self .nested_fills .last() @@ -995,7 +1004,7 @@ impl RenderState { // Remove background blur from the shape so it doesn't get processed // as a layer blur. The actual rendering is done before the save_layer // in render_background_blur() so it's independent of shape opacity. - if !fast_mode + if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1021,14 +1030,14 @@ impl RenderState { } else if shape_has_blur { shape.to_mut().set_blur(None); } - if fast_mode { + if skip_effects { shape.to_mut().set_blur(None); } // For non-text, non-SVG shapes in the normal rendering path, apply blur // via a single save_layer on each render surface // Clip correctness is preserved - let blur_sigma_for_layers: Option = if !fast_mode + let blur_sigma_for_layers: Option = if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1107,7 +1116,7 @@ impl RenderState { ) }) .unzip(); - if fast_mode { + if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), @@ -1396,7 +1405,7 @@ impl RenderState { antialias, outset, )?; - if !fast_mode { + if !skip_effects { for stroke in &visible_strokes { shadows::render_stroke_inner_shadows( self, @@ -1409,7 +1418,7 @@ impl RenderState { } } - if !fast_mode { + if !skip_effects { shadows::render_fill_inner_shadows( self, shape, @@ -1669,7 +1678,12 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); - self.cache_cleared_this_render = false; + // When preserve_cache_this_render is set, pretend the Cache surface + // has already been cleared for this pass. The first-tile clear guards + // in apply_render_to_final_canvas keep their pass-1 content intact so + // pass-2 tiles overwrite in place (no flicker between passes). + self.cache_cleared_this_render = self.preserve_cache_this_render; + self.preserve_cache_this_render = false; self.reset_canvas(); // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) @@ -1687,12 +1701,26 @@ impl RenderState { // 1:1 atlas as a stable backdrop so every flush presents a // coherent picture: unchanged tiles come from the atlas and // invalidated tiles are overwritten on top as they finish. + // + // The same applies to the post-zoom pass 1 (`defer_effects`): + // tiles at the new scale are still being rebuilt, so paint the + // atlas as a scaled backdrop until they land. Unlike the + // interactive-transform case we do NOT clear Target first — + // during early-load gestures the atlas may only cover part of + // the viewport and clearing would flash bg-colored rectangles + // where pass-1 tiles haven't landed yet. Target already holds + // whatever `render_from_cache` drew during the gesture (or + // direct tile renders from a prior rAF), which is a strictly + // better backdrop than the raw background color. if self.options.is_interactive_transform() && self.surfaces.has_atlas() { self.surfaces.draw_atlas_to_target( self.viewbox, self.options.dpr(), self.background_color, ); + } else if self.options.is_defer_effects() && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_over_target(self.viewbox, self.options.dpr()); } let surface_ids = SurfaceId::Strokes as u32 @@ -1834,6 +1862,17 @@ impl RenderState { if self.render_in_progress { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); + } else if self.options.is_defer_effects() { + // Pass 1 just finished — tiles are on screen without + // blur/shadow. Immediately launch pass 2 to upgrade them + // in place. The tile texture cache is invalidated so + // every tile re-renders; the Cache surface is preserved + // so pass-1 content stays visible until each pass-2 + // tile overwrites it. + self.options.set_defer_effects(false); + self.surfaces.invalidate_tile_cache(); + self.preserve_cache_this_render = true; + self.start_render_loop(base_object, tree, timestamp, false)?; } else { wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); @@ -2030,8 +2069,9 @@ impl RenderState { paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - // Skip frame-level blur in fast mode (pan/zoom) - if !self.options.is_fast_mode() { + // Skip frame-level blur in fast mode (pan/zoom) or during the + // post-gesture first rebuild pass. + if !self.options.should_skip_effects() { if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { let scale = self.get_scale(); let sigma = radius_to_sigma(frame_blur.value * scale); @@ -2717,7 +2757,7 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() + && !self.options.should_skip_effects() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() && element.drop_shadows_visible().next().is_some(); @@ -2753,8 +2793,9 @@ impl RenderState { .surfaces .get_render_context_translation(self.render_area, scale); - // Skip expensive drop shadow rendering in fast mode (during pan/zoom) - let skip_shadows = self.options.is_fast_mode(); + // Skip expensive drop shadow rendering in fast mode (during + // pan/zoom) or during the post-gesture first rebuild pass. + let skip_shadows = self.options.should_skip_effects(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 40a3125ccd..ce88fdc0a0 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -22,6 +22,11 @@ pub struct RenderOptions { /// keeps per-frame flushing enabled (unlike pan/zoom, where /// `render_from_cache` drives target presentation). interactive_transform: bool, + /// Active during the first rebuild pass after a zoom ends. Skips + /// blur/shadow (like `fast_mode`) but renders tiles normally rather + /// than using the atlas backdrop, so the user sees a fast full-fidelity + /// shape preview before effects come in on a second pass. + defer_effects: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, pub viewport_interest_area_threshold: i32, @@ -37,6 +42,7 @@ impl Default for RenderOptions { dpr: None, fast_mode: false, interactive_transform: false, + defer_effects: false, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, max_blocking_time_ms: MAX_BLOCKING_TIME_MS, @@ -76,6 +82,23 @@ impl RenderOptions { self.interactive_transform = enabled; } + pub fn is_defer_effects(&self) -> bool { + self.defer_effects + } + + pub fn set_defer_effects(&mut self, enabled: bool) { + self.defer_effects = enabled; + } + + /// True when expensive per-shape effects (blur, shadow) should be + /// skipped. Covers both the active viewport gesture (`fast_mode`) + /// and the post-gesture first rebuild pass (`defer_effects`). + /// Do NOT use this to gate atlas-backdrop / cache-presentation logic + /// — those must key off `is_fast_mode()` specifically. + pub fn should_skip_effects(&self) -> bool { + self.fast_mode || self.defer_effects + } + /// 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 diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 8d769eea0f..5d29c9a1d5 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -400,6 +400,26 @@ impl Surfaces { /// Draw the persistent atlas onto the target using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + self.draw_atlas_to_target_inner(viewbox, dpr, Some(background)); + } + + /// Same as `draw_atlas_to_target` but preserves whatever is already on + /// Target instead of clearing it to the background color first. Used + /// by the progressive pass-1 rebuild so that, when the atlas only + /// partially covers the current viewport, uncovered regions keep + /// their previous content (e.g. tiles rendered directly during an + /// earlier render) instead of flashing to the background color until + /// pass 1 catches up. + pub fn draw_atlas_over_target(&mut self, viewbox: Viewbox, dpr: f32) { + self.draw_atlas_to_target_inner(viewbox, dpr, None); + } + + fn draw_atlas_to_target_inner( + &mut self, + viewbox: Viewbox, + dpr: f32, + background: Option, + ) { if !self.has_atlas() { return; }; @@ -417,7 +437,9 @@ impl Surfaces { let s = viewbox.zoom * dpr; let atlas_scale = self.atlas_scale.max(0.01); - canvas.clear(background); + if let Some(bg) = background { + canvas.clear(bg); + } canvas.translate(( (self.atlas_origin.x + viewbox.pan_x) * s, (self.atlas_origin.y + viewbox.pan_y) * s, @@ -858,6 +880,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn cache_current_tile_texture( &mut self, gpu_state: &mut GpuState, @@ -865,6 +888,7 @@ impl Surfaces { tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + skip_atlas: bool, tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( @@ -889,8 +913,13 @@ impl Surfaces { // Incrementally update persistent 1:1 atlas in document space. // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); + // Skipped during the progressive pass 1 (defer_effects) so we do + // not contaminate the atlas with shape previews that lack blur + // or shadows — pass 2 will write the final full-quality tiles. + if !skip_atlas { + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); + self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); + } self.tiles.add(tile_viewbox, tile, tile_image); } }