Merge pull request #9572 from penpot/azazeln28-refactor-target-and-backbuffer-rendering

♻️ Refactor how target and backbuffer works
This commit is contained in:
Alejandro Alonso 2026-05-13 16:11:17 +02:00 committed by GitHub
commit 757aae1df3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 170 additions and 148 deletions

View File

@ -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."
[]

View File

@ -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,
&current_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.

View File

@ -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(())
}

View File

@ -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
}

View File

@ -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.

View File

@ -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,