🔧 Use atlas back

This commit is contained in:
Elena Torro 2026-06-12 08:58:39 +02:00
parent c59bd644b0
commit bbf505ea4f
3 changed files with 290 additions and 392 deletions

View File

@ -42,7 +42,12 @@ pub use images::*;
type ClipStack = Vec<(Rect, Option<Corners>, 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);
}

View File

@ -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<skia::Surface> {
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()
}
}

View File

@ -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<skia::Image> {
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<bool>,
pub rects: Vec<Rect>,
}
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<TileAtlasTextureRef> {
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<skia::RSXform>,
textures: Vec<skia::Rect>,
side: usize,
slots: usize,
surface: skia::Surface,
grid: HashMap<Tile, TileAtlasTextureRef>,
free: Vec<usize>,
next: usize,
removed: HashSet<Tile>,
stale: HashSet<Tile>,
}
impl TileTextureCache {
pub fn new(texture_size: i32, capacity: usize) -> Self {
Self {
pub fn try_new(max_texture_size: i32) -> Result<Self> {
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<Tile> = 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<skia::RSXform>, Vec<skia::Rect>) {
let mut xforms: Vec<skia::RSXform> = vec![];
let mut texs: Vec<skia::Rect> = 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<usize> {
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<usize> {
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<TileAtlasTextureRef> {
if self.removed.contains(&tile) {
return None;
}
self.grid.get(&tile)
self.grid.get(&tile).cloned()
}
pub fn remove(&mut self, tile: Tile) {