From bbf505ea4f98d53c1c17da95f7923e3b6a6fe082 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 12 Jun 2026 08:58:39 +0200 Subject: [PATCH] :wrench: Use atlas back --- render-wasm/src/render.rs | 76 +++-- render-wasm/src/render/gpu_state.rs | 129 ++------ render-wasm/src/render/surfaces.rs | 477 ++++++++++++---------------- 3 files changed, 290 insertions(+), 392 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1f0b2436cc..0f103be1e9 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -42,7 +42,12 @@ pub use images::*; type ClipStack = Vec<(Rect, Option, Matrix)>; -const MIN_TILES_PER_FRAME: i32 = 8; +/// Per-frame deadline for uncached-tile rendering, measured from the rAF +/// timestamp (frame start), so JS work that ran earlier in the tick counts +/// against it. The loop yields (Partial frame) once exceeded so heavy +/// settles stream at frame rate instead of blocking the main thread. +/// ~12ms leaves headroom for compositing + present within a 16.7ms frame. +const TILE_FRAME_TIME_BUDGET_MS: i32 = 12; #[repr(u8)] pub enum FrameType { @@ -399,11 +404,9 @@ pub(crate) struct RenderState { /// (must composite to present the work). Reset when current_tile /// changes. pub current_tile_had_shapes: bool, - /// Count of uncached tiles composited in the current rAF. Reset at the top - /// of `render_shape_tree_partial`; checked against the adaptive per-frame - /// budget (`max(MIN_TILES_PER_FRAME, visible_tile_count)`) to yield (flush + - /// return Partial) so a single rAF doesn't hand the GPU one huge command - /// buffer. See `MIN_TILES_PER_FRAME`. + /// Count of uncached tiles composited in the current rAF. Reset at the + /// top of `render_shape_tree_partial`. The yield decision is time-based: + /// see `TILE_FRAME_TIME_BUDGET_MS`. pub tiles_on_frame: i32, /// During interactive transforms we keep `Target` between rAFs. Seed the /// interactive backdrop exactly once per gesture (first rAF) so we don't @@ -2323,12 +2326,33 @@ impl RenderState { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: flush AND submit so the GPU actually executes this - // chunk's draws now - self.surfaces.flush_and_submit(SurfaceId::Backbuffer); + if !self.options.is_fast_mode() + && !self.options.is_interactive_transform() + && !self.viewer_masked_pass() + { + // Progressive present: DocAtlas preview as backdrop (covers + // not-yet-rendered tiles), finished tiles composited on top. + // Cheap since the page atlas made compositing snapshot-free. + self.surfaces + .draw_atlas_to_backbuffer(self.viewbox, self.background_color); + self.surfaces + .draw_cached_tiles_over_backbuffer(&self.viewbox, &self.tile_viewbox); + self.present_frame(tree); + } else { + // Gesture previews / masked passes present elsewhere: just + // flush so the GPU executes this chunk's draws now. + self.surfaces.flush_and_submit(SurfaceId::Backbuffer); + } } FrameType::Full => { - if !self.options.is_interactive_transform() { + // A settle can complete mid-zoom-gesture (fast_mode with the + // zoom already diverged from cached_viewbox). The grid then + // still holds old-zoom tiles — compositing them at new-zoom + // positions flashes wrong-scale tiles with background gaps. + // Skip the composite + present and let the preview own the + // screen; the post-set_view_end settle presents the real frame. + let mid_zoom_gesture = self.options.is_fast_mode() && self.zoom_changed(); + if !mid_zoom_gesture && !self.options.is_interactive_transform() { self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, &self.tile_viewbox, @@ -2344,9 +2368,13 @@ impl RenderState { // builds the cache from it when a drag actually begins. self.backbuffer_is_clean_full_frame = !self.options.is_fast_mode() && !self.options.is_interactive_transform(); - // present_frame: copy clean Backbuffer → Target, draw UI/debug - // overlays on Target only, then flush. Backbuffer stays overlay-free. - self.present_frame(tree); + if mid_zoom_gesture { + self.surfaces.flush_and_submit(SurfaceId::Backbuffer); + } else { + // present_frame: copy clean Backbuffer → Target, draw UI/debug + // overlays on Target only, then flush. Backbuffer stays overlay-free. + self.present_frame(tree); + } wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } @@ -3568,9 +3596,17 @@ impl RenderState { let mut should_stop = false; self.viewer_render_root = base_object.copied(); self.tiles_on_frame = 0; - // Never fewer than the currently-visible tile count, so a - // normal viewport composites all its visible tiles in one rAF - let tile_budget = MIN_TILES_PER_FRAME.max(self.tile_viewbox.visible_rect.len()); + // Budget against the rAF frame deadline, not this call: `timestamp` is + // the rAF DOMHighResTimeStamp (same epoch as get_time()), so time the + // page spent in JS before this tick counts against the budget too. + // Fall back to "now" when no usable timestamp was passed (0 on the + // first render, sync/test paths, or a stale value). + let now = performance::get_time(); + let frame_start = if timestamp > 0 && now >= timestamp && now - timestamp < 100 { + timestamp + } else { + now + }; let root_ids = { if let Some(shape_id) = base_object { vec![*shape_id] @@ -3632,15 +3668,15 @@ impl RenderState { ); } - // Cap how many such tiles we submit per rAF so the GPU doesn't get one - // giant command buffer. Skipped during interactive - // transforms + // Time-box the work per rAF (at least one tile per + // tick guarantees progress). Skipped during interactive + // transforms. if allow_stop && !self.options.is_interactive_transform() && !self.viewer_masked_pass() { self.tiles_on_frame += 1; - if self.tiles_on_frame >= tile_budget { + if performance::get_time() - frame_start >= TILE_FRAME_TIME_BUDGET_MS { self.viewer_render_root = None; return Ok(FrameType::Partial); } diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index e934dd4f34..3416c55621 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -1,13 +1,15 @@ use crate::error::{Error, Result}; use skia_safe::gpu::{ - self, ganesh::context_options::Enable, gl::FramebufferInfo, gl::TextureInfo, ContextOptions, - DirectContext, + self, ganesh::context_options::Enable, gl::FramebufferInfo, ContextOptions, DirectContext, }; use skia_safe::{self as skia, ISize}; const MIN_MAX_TEXTURE_SIZE: i32 = 512; const MAX_MAX_TEXTURE_SIZE: i32 = 8192 * 2; +/// GPU resource-cache budget for Skia-managed (budgeted) render targets. +const RESOURCE_CACHE_LIMIT_BYTES: usize = 512 * 1024 * 1024; + #[derive(Debug, Clone)] pub struct GpuState { pub context: DirectContext, @@ -28,10 +30,17 @@ impl GpuState { // context_options.allow_multiple_glyph_cache_textures = Enable::Yes; // context_options.allow_path_mask_caching = false; - let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( + let mut context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( Error::CriticalError("Failed to create GL context".to_string()), )?; + // Skia-managed render targets are budgeted against the GPU resource + // cache. The default cap is 256 MB (GrResourceCache kDefaultMaxSize), + // which a single atlas-sized transient can exhaust on its own. Raise it + // so freed atlas/snapshot textures recycle as scratch instead of being + // re-allocated from the driver every frame. + context.set_resource_cache_limit(RESOURCE_CACHE_LIMIT_BYTES); + let framebuffer_info = { let mut fboid: gl::types::GLint = 0; unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; @@ -57,54 +66,6 @@ impl GpuState { .clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE) } - fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { - unsafe { - gl::DeleteTextures(1, &texture_id); - gl::GetError() == 0 - } - } - - fn create_gl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint { - let mut texture_id: gl::types::GLuint = 0; - - unsafe { - gl::GenTextures(1, &mut texture_id); - gl::BindTexture(gl::TEXTURE_2D, texture_id); - - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32); - - gl::TexImage2D( - gl::TEXTURE_2D, - 0, - gl::RGBA8 as i32, - width, - height, - 0, - gl::RGBA, - gl::UNSIGNED_BYTE, - std::ptr::null(), - ); - } - - texture_id - } - - pub fn delete_surface(&mut self, surface: &mut skia::Surface) -> bool { - let Some(texture) = skia::gpu::surfaces::get_backend_texture( - surface, - skia_safe::surface::BackendHandleAccess::FlushRead, - ) else { - return false; - }; - let Some(texture_info) = gpu::backend_textures::get_gl_texture_info(&texture) else { - return false; - }; - self.delete_gl_texture(texture_info.id) - } - pub fn create_surface_with_isize( &mut self, label: String, @@ -115,28 +76,29 @@ impl GpuState { pub fn create_surface_with_dimensions( &mut self, - label: String, + _label: String, width: i32, height: i32, ) -> Result { - let backend_texture = unsafe { - let texture_id = self.create_gl_texture(width, height); - let texture_info = TextureInfo { - target: gl::TEXTURE_2D, - id: texture_id, - format: gl::RGBA8, - protected: skia::gpu::Protected::No, - }; - gpu::backend_textures::make_gl((width, height), gpu::Mipmapped::No, texture_info, label) - }; + // Skia-managed render target (not a wrapped client texture): snapshots + // take the cheap COW-guarded path (`fCachedImage`, dropped for free on + // the next write when uniquely held) instead of scheduling an eager + // full-texture copy at snapshot time. See draw-atlas-analysis Part III. + let image_info = skia::ImageInfo::new( + (width, height), + skia::ColorType::RGBA8888, + skia::AlphaType::Premul, + None, + ); - let surface = gpu::surfaces::wrap_backend_texture( + let surface = gpu::surfaces::render_target( &mut self.context, - &backend_texture, + gpu::Budgeted::Yes, + &image_info, + None, gpu::SurfaceOrigin::BottomLeft, None, - skia::ColorType::RGBA8888, - None, + false, None, ) .ok_or(Error::CriticalError( @@ -165,39 +127,4 @@ impl GpuState { Ok(surface) } - - #[allow(dead_code)] - pub fn create_surface_from_texture( - &mut self, - width: i32, - height: i32, - texture_id: u32, - ) -> skia::Surface { - let texture_info = TextureInfo { - target: gl::TEXTURE_2D, - id: texture_id, - format: gl::RGBA8, - protected: skia::gpu::Protected::No, - }; - - let backend_texture = unsafe { - gpu::backend_textures::make_gl( - (width, height), - gpu::Mipmapped::No, - texture_info, - String::from("export_texture"), - ) - }; - - gpu::surfaces::wrap_backend_texture( - &mut self.context, - &backend_texture, - gpu::SurfaceOrigin::BottomLeft, - None, - skia::ColorType::RGBA8888, - None, - None, - ) - .unwrap() - } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 6324765886..a14e14ecf9 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -165,11 +165,7 @@ impl DocAtlas { new_bottom = new_bottom.max(doc_rect.bottom.ceil()); } - // Pad to reduce realloc frequency. A realloc copies the whole atlas - // (up to max_texture_size², hundreds of MB), so sides that actually - // grow get a geometric margin — half the current atlas span, at least - // a few tiles — making sustained pans cost O(log) reallocs instead of - // one per tile. + // Pad to reduce realloc frequency let pad = tiles::TILE_SIZE; if needs_init { new_left -= pad; @@ -191,17 +187,12 @@ impl DocAtlas { if new_bottom > current_bottom { new_bottom += grow_y; } - // Writes are clamped to doc_bounds, so texture past it is wasted; - // clip the margin to the document (but never below the rect that - // triggered this grow). if let Some(bounds) = self.doc_bounds { new_left = new_left.max((bounds.left - pad).min(doc_rect.left.floor())); new_top = new_top.max((bounds.top - pad).min(doc_rect.top.floor())); new_right = new_right.min((bounds.right + pad).max(doc_rect.right.ceil())); new_bottom = new_bottom.min((bounds.bottom + pad).max(doc_rect.bottom.ceil())); } - // Never shrink below current coverage: the old-atlas copy must fit, - // and shrinking would invite grow/shrink oscillation. new_left = new_left.min(current_left); new_top = new_top.min(current_top); new_right = new_right.max(current_right); @@ -263,7 +254,8 @@ impl DocAtlas { self.origin = skia::Point::new(new_left, new_top); self.size = skia::ISize::new(new_w, new_h); self.scale = new_scale; - gpu_state.delete_surface(&mut self.surface); + // Old surface is Skia-managed: dropping it on reassignment frees the + // texture back to the resource cache. self.surface = new_surface; Ok(()) } @@ -454,8 +446,6 @@ pub struct Surfaces { // Persistent viewport-sized surface used to keep the last presented frame. backbuffer: skia::Surface, // Atlas used to keep tiles. - tile_atlas: skia::Surface, - tiles: TileTextureCache, pub atlas: DocAtlas, sampling_options: skia::SamplingOptions, @@ -467,7 +457,6 @@ pub struct Surfaces { dpr: f32, } -#[allow(dead_code)] impl Surfaces { pub fn try_new( (width, height): (i32, i32), @@ -489,11 +478,6 @@ impl Surfaces { gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; let max_texture_size = gpu_state.max_texture_size(); - let tile_atlas = gpu_state.create_surface_with_dimensions( - "tile_atlas".to_string(), - max_texture_size, - max_texture_size, - )?; let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; @@ -513,8 +497,7 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; - // 512, why not? - let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let tiles = TileTextureCache::try_new(max_texture_size)?; let atlas = DocAtlas::try_new()?; Ok(Self { target, @@ -530,7 +513,6 @@ impl Surfaces { debug, export, backbuffer, - tile_atlas, tiles, atlas, sampling_options, @@ -549,43 +531,41 @@ impl Surfaces { self.dpr = dpr; } - pub fn clear_tiles(&mut self) { - self.tiles.clear(); - } - pub fn draw_tile_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, background: skia::Color, ) { - self.tiles.update(viewbox, tile_viewbox); - let canvas = self.backbuffer.canvas(); - canvas.clear(background); - // Draw each tile via Surface::draw (clip + offset) instead of - // draw_atlas over a full image_snapshot(): the snapshot shares the - // atlas texture, so the first tile write after every present forced - // a copy-on-write of the whole (max_texture_size²) atlas. - for (transform, texture) in self.tiles.transforms.iter().zip(self.tiles.textures.iter()) { - if texture.is_empty() { - continue; - } - let dst = skia::Rect::from_xywh( - transform.tx, - transform.ty, - texture.width(), - texture.height(), - ); - canvas.save(); - canvas.clip_rect(dst, None, false); - self.tile_atlas.draw( - canvas, - (transform.tx - texture.left, transform.ty - texture.top), - self.atlas_sampling_options, - Some(&skia::Paint::default()), - ); - canvas.restore(); + self.backbuffer.canvas().clear(background); + self.draw_cached_tiles_over_backbuffer(viewbox, tile_viewbox); + } + + /// Composite the visible cached tiles onto Backbuffer without clearing it + /// first. Used by progressive (Partial-frame) presents, where the DocAtlas + /// preview is drawn underneath as a backdrop for not-yet-rendered tiles. + pub fn draw_cached_tiles_over_backbuffer( + &mut self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + ) { + let (xforms, texs) = self.tiles.visible_batch(viewbox, tile_viewbox); + if xforms.is_empty() { + return; } + let sampling_options = self.atlas_sampling_options; + // One snapshot of the managed atlas, one batched draw from it. + let image = self.tiles.snapshot(); + self.backbuffer.canvas().draw_atlas( + &image, + &xforms, + &texs, + None, + skia::BlendMode::SrcOver, + sampling_options, + None, + None, + ); } /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. @@ -864,7 +844,7 @@ impl Surfaces { SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, SurfaceId::Atlas => &mut self.atlas.surface, - SurfaceId::TileAtlas => &mut self.tile_atlas, + SurfaceId::TileAtlas => self.tiles.surface_mut(), } } @@ -885,7 +865,7 @@ impl Surfaces { SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, SurfaceId::Atlas => &self.atlas.surface, - SurfaceId::TileAtlas => &self.tile_atlas, + SurfaceId::TileAtlas => self.tiles.surface(), } } @@ -927,18 +907,7 @@ impl Surfaces { } pub fn clear_tile_atlas(&mut self) { - self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); - } - - /// Seed `Backbuffer` from `Target` (last presented frame). - pub fn seed_backbuffer_from_target(&mut self) { - let sampling_options = self.sampling_options; - self.target.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); + self.tiles.reset_all(); } fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { @@ -1097,12 +1066,6 @@ impl Surfaces { self.backbuffer.canvas().clear(color); } - pub fn clear_backbuffer_rect(&mut self, rect: skia::Rect, color: skia::Color) { - let mut paint = Paint::default(); - paint.set_color(color); - self.backbuffer.canvas().draw_rect(rect, &paint); - } - pub fn reset(&mut self, color: skia::Color) { self.canvas(SurfaceId::Fills).restore_to_count(1); self.canvas(SurfaceId::InnerShadows).restore_to_count(1); @@ -1213,9 +1176,9 @@ impl Surfaces { .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); - // Draws current tile into tile atlas + // Write the tile into its allocated slot on the atlas surface. let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( + self.tiles.surface_mut().canvas().draw_image_rect( &tile_image, None, tile_ref.rect, @@ -1261,6 +1224,9 @@ impl Surfaces { return None; } + // One snapshot of the managed atlas, sampled for every covered tile. + let tiles_image = self.tiles.snapshot(); + let canvas = scratch.canvas(); canvas.clear(skia::Color::TRANSPARENT); @@ -1286,31 +1252,21 @@ impl Surfaces { (clip_doc.bottom - vb_top) * scale - iy0, ); - if let Some(tile_ref) = self.tiles.get(tile) { - let bounds = skia::IRect::from_ltrb( - tile_ref.rect.left as i32, - tile_ref.rect.top as i32, - tile_ref.rect.right as i32, - tile_ref.rect.bottom as i32, - ); - let Some(tile_image) = self.tile_atlas.image_snapshot_with_bounds(bounds) - else { - panic!("Cannot retrieve tile image"); - }; - let iw = tile_image.width() as f32; - let ih = tile_image.height() as f32; + if let Some(slot) = self.tiles.slot(tile) { + let iw = slot.rect.width(); + let ih = slot.rect.height(); let td_w = tile_doc.width().max(1e-6); let td_h = tile_doc.height().max(1e-6); + // Sub-rect of the tile, in atlas pixels. let src = skia::Rect::from_ltrb( - ((clip_doc.left - tile_doc.left) / td_w) * iw, - ((clip_doc.top - tile_doc.top) / td_h) * ih, - ((clip_doc.right - tile_doc.left) / td_w) * iw, - ((clip_doc.bottom - tile_doc.top) / td_h) * ih, + slot.rect.left + ((clip_doc.left - tile_doc.left) / td_w) * iw, + slot.rect.top + ((clip_doc.top - tile_doc.top) / td_h) * ih, + slot.rect.left + ((clip_doc.right - tile_doc.left) / td_w) * iw, + slot.rect.top + ((clip_doc.bottom - tile_doc.top) / td_h) * ih, ); - canvas.draw_image_rect( - tile_image, + &tiles_image, Some((&src, skia::canvas::SrcRectConstraint::Fast)), dst, &paint, @@ -1351,45 +1307,22 @@ impl Surfaces { self.tiles.mark_stale(tile); } - pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option { - let Some(tile_ref) = self.tiles.get(tile) else { - panic!("Tile not found {}:{}", tile.0, tile.1); - }; - - let rect = IRect::from_ltrb( - tile_ref.rect.left as i32, - tile_ref.rect.top as i32, - tile_ref.rect.right as i32, - tile_ref.rect.bottom as i32, - ); - self.tile_atlas.image_snapshot_with_bounds(rect) - } - pub fn draw_cached_tile_into_backbuffer(&mut self, tile: Tile, rect: &Rect) { - // Draw the atlas region via Surface::draw (clip + transform) instead of - // image_snapshot_with_bounds + draw_image_rect: a subset snapshot is an - // eager GPU copy, paid per tile per frame on pan previews. - let Some(tile_ref) = self.tiles.get(tile) else { + let Some(slot) = self.tiles.slot(tile) else { panic!("Tile not found {}:{}", tile.0, tile.1); }; - let src = tile_ref.rect; + let src = slot.rect; if src.width() <= 0.0 || src.height() <= 0.0 { return; } - let sampling_options = self.atlas_sampling_options; - let backbuffer_canvas = self.backbuffer.canvas(); - backbuffer_canvas.save(); - backbuffer_canvas.clip_rect(*rect, None, false); - backbuffer_canvas.translate((rect.left, rect.top)); - backbuffer_canvas.scale((rect.width() / src.width(), rect.height() / src.height())); - backbuffer_canvas.translate((-src.left, -src.top)); - self.tile_atlas.draw( - backbuffer_canvas, - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), + // One snapshot of the managed atlas, plain image draw from it. + let image = self.tiles.snapshot(); + self.backbuffer.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + *rect, + &skia::Paint::default(), ); - backbuffer_canvas.restore(); } /// Draws the current tile directly to the backbuffer and cache surfaces without @@ -1519,174 +1452,148 @@ impl Surfaces { } } +/// Side length of the single tile atlas texture (clamped to the GPU cap). +const ATLAS_SIZE: i32 = 4096; + #[derive(Debug, Clone)] pub struct TileAtlasTextureRef { pub index: usize, pub rect: skia::Rect, } -impl TileAtlasTextureRef { - pub fn new(index: usize, rect: skia::Rect) -> Self { - Self { index, rect } - } -} - -pub struct TileAtlasTextureProvider { - pub index: usize, - pub length: usize, - pub in_use: Vec, - pub rects: Vec, -} - -impl TileAtlasTextureProvider { - pub fn new(texture_size: i32, tile_size: i32) -> Self { - let side = texture_size / tile_size; - let length = side * side; - let mut rects = Vec::with_capacity(length as usize); - for i in 0..length { - let left = (i % side) as f32 * tile_size as f32; - let top = (i / side) as f32 * tile_size as f32; - let right = left + tile_size as f32; - let bottom = top + tile_size as f32; - rects.push(Rect::new(left, top, right, bottom)); - } - Self { - index: 0, - length: length as usize, - in_use: vec![false; length as usize], - rects, - } - } - - pub fn allocate(&mut self) -> Option { - let start = self.index; - loop { - if !self.in_use[self.index] { - self.in_use[self.index] = true; - return Some(TileAtlasTextureRef::new(self.index, self.rects[self.index])); - } - - self.index = (self.index + 1) % self.length; - if self.index == start { - return None; - } - } - } - - pub fn deallocate(&mut self, reference: TileAtlasTextureRef) -> bool { - // In this case the user of the provider it's trying to release - // a reference already freed. - if !self.in_use[reference.index] { - return false; - } - self.in_use[reference.index] = false; - self.index = reference.index; - true - } -} - +/// A single Skia-managed atlas texture holding screen-space tiles at the +/// current zoom. Because the surface is Skia-owned (budgeted), `image_snapshot` +/// takes the cheap COW-guarded path: in the render loop's `write → snapshot → +/// draw` order the snapshot is dropped before the next write, so no full-texture +/// copy ever executes. That makes the per-page freeze/recycle/evict machinery +/// (needed only to keep writes and snapshots off the same wrapped texture) +/// unnecessary — one surface plus a slot free list is enough. See +/// draw-atlas-analysis Part III. pub struct TileTextureCache { tile_size: f32, - provider: TileAtlasTextureProvider, - transforms: Vec, - textures: Vec, + side: usize, + slots: usize, + surface: skia::Surface, grid: HashMap, + free: Vec, + next: usize, removed: HashSet, stale: HashSet, } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { - Self { + pub fn try_new(max_texture_size: i32) -> Result { + let atlas_size = ATLAS_SIZE.min(max_texture_size.max(TILE_SIZE)); + let side = (atlas_size / TILE_SIZE) as usize; + let mut surface = get_gpu_state().create_surface_with_dimensions( + "tile_atlas".to_string(), + atlas_size, + atlas_size, + )?; + surface.canvas().clear(skia::Color::TRANSPARENT); + Ok(Self { tile_size: tiles::TILE_SIZE, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), - transforms: Vec::with_capacity(capacity), - textures: Vec::with_capacity(capacity), - grid: HashMap::with_capacity(capacity), - removed: HashSet::with_capacity(capacity), - stale: HashSet::with_capacity(capacity), - } + side, + slots: side * side, + surface, + grid: HashMap::with_capacity(256), + free: Vec::new(), + next: 0, + removed: HashSet::with_capacity(256), + stale: HashSet::with_capacity(256), + }) + } + + fn slot_rect(&self, index: usize) -> skia::Rect { + let ts = TILE_SIZE as f32; + let left = (index % self.side) as f32 * ts; + let top = (index / self.side) as f32 * ts; + Rect::from_xywh(left, top, ts, ts) } fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { - self.provider.deallocate(tile_ref); + let removed: Vec = self.removed.drain().collect(); + for tile in removed { + if let Some(slot) = self.grid.remove(&tile) { + self.free.push(slot.index); } - self.stale.remove(tile); + self.stale.remove(&tile); } - self.removed.clear(); } fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { let marked: Vec<_> = self .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_in_interest_area(tile) { - Some(*tile) - } else { - None - } - }) + .keys() + .filter(|tile| !tile_viewbox.is_in_interest_area(tile)) .take(TEXTURES_BATCH_DELETE) + .copied() .collect(); for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { - self.provider.deallocate(tile_ref); + if let Some(slot) = self.grid.remove(tile) { + self.free.push(slot.index); } + self.removed.remove(tile); self.stale.remove(tile); } } - pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { - if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { - self.transforms.resize( - tile_viewbox.visible_rect.len() as usize, - skia::RSXform::new(1.0, 0.0, Point::default()), - ); - } - - if self.textures.len() != tile_viewbox.visible_rect.len() as usize { - self.textures.resize( - tile_viewbox.visible_rect.len() as usize, - skia::Rect::new_empty(), - ); - } - - for texture in self.textures.iter_mut() { - texture.set_empty(); - } - + /// Visible cached tiles as a single (xform, tex-rect) batch, ready for one + /// `draw_atlas` call from a single snapshot of the atlas surface. + pub fn visible_batch( + &self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + ) -> (Vec, Vec) { + let mut xforms: Vec = vec![]; + let mut texs: Vec = vec![]; let offset = viewbox.get_offset(); - let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { let tile = Tile(x, y); - if self.removed.contains(&tile) { continue; } - - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(slot) = self.grid.get(&tile) else { continue; }; - - self.transforms[index].tx = x as f32 * self.tile_size - offset.x; - self.transforms[index].ty = y as f32 * self.tile_size - offset.y; - - self.textures[index].set_ltrb( - tile_ref.rect.left, - tile_ref.rect.top, - tile_ref.rect.right, - tile_ref.rect.bottom, - ); - - index += 1; + xforms.push(skia::RSXform::new( + 1.0, + 0.0, + Point::new( + x as f32 * self.tile_size - offset.x, + y as f32 * self.tile_size - offset.y, + ), + )); + texs.push(slot.rect); } } + (xforms, texs) + } + + /// A snapshot of the atlas surface, for sampling cached tiles. Cheap on the + /// Skia-managed surface: in `write → snapshot → draw` order the returned + /// image is dropped before the next write, so no copy executes. + pub fn snapshot(&mut self) -> skia::Image { + self.surface.image_snapshot() + } + + pub fn surface(&self) -> &skia::Surface { + &self.surface + } + + pub fn surface_mut(&mut self) -> &mut skia::Surface { + &mut self.surface + } + + pub fn reset_all(&mut self) { + self.grid.clear(); + self.removed.clear(); + self.stale.clear(); + self.free.clear(); + self.next = 0; + self.surface.canvas().clear(skia::Color::TRANSPARENT); } pub fn has(&self, tile: Tile) -> bool { @@ -1703,39 +1610,67 @@ impl TileTextureCache { .collect() } + /// Allocate a writable slot for `tile`. pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - let capacity = self.provider.length; - if self.grid.len() >= capacity { - // First we try to remove the obsolete tiles. - self.gc(); + if let Some(old) = self.grid.remove(tile) { + self.free.push(old.index); } - - // If we still have a texture capacity problem, then - // we try to remove all of those tiles that aren't - // in the interest area. - if self.grid.len() >= capacity { - self.gc_non_visible(tile_viewbox); - } - - let Some(tile_ref) = self.provider.allocate() else { - panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); + let index = self.allocate_index(tile_viewbox); + let slot = TileAtlasTextureRef { + index, + rect: self.slot_rect(index), }; - - if let Some(old) = self.grid.insert(*tile, tile_ref.clone()) { - self.provider.deallocate(old); - } - + self.grid.insert(*tile, slot.clone()); self.removed.remove(tile); self.stale.remove(tile); - - tile_ref + slot } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { + fn try_take_slot(&mut self) -> Option { + self.free.pop().or_else(|| { + (self.next < self.slots).then(|| { + self.next += 1; + self.next - 1 + }) + }) + } + + /// Evict one cached tile (prefer one outside the interest area) and reuse + /// its slot. The evicted tile simply re-renders on a later frame. + fn evict_one(&mut self, tile_viewbox: &TileViewbox) -> Option { + let victim = self + .grid + .keys() + .find(|tile| !tile_viewbox.is_in_interest_area(tile)) + .or_else(|| self.grid.keys().next()) + .copied()?; + let slot = self.grid.remove(&victim)?; + self.removed.remove(&victim); + self.stale.remove(&victim); + Some(slot.index) + } + + fn allocate_index(&mut self, tile_viewbox: &TileViewbox) -> usize { + if let Some(index) = self.try_take_slot() { + return index; + } + self.gc(); + if let Some(index) = self.try_take_slot() { + return index; + } + self.gc_non_visible(tile_viewbox); + if let Some(index) = self.try_take_slot() { + return index; + } + // Atlas full: evict a tile and reuse its slot. + self.evict_one(tile_viewbox).unwrap_or(0) + } + + pub fn slot(&self, tile: Tile) -> Option { if self.removed.contains(&tile) { return None; } - self.grid.get(&tile) + self.grid.get(&tile).cloned() } pub fn remove(&mut self, tile: Tile) {