🎉 Tiles atlas support

This commit is contained in:
Alejandro Alonso 2026-04-13 17:02:56 +02:00
parent 6fa440cf92
commit c08c3bd160
7 changed files with 386 additions and 8 deletions

View File

@ -1539,6 +1539,8 @@
(h/call wasm/internal-module "_set_render_options" flags dpr)
(when-let [t (wasm-aa-threshold-from-route-params)]
(h/call wasm/internal-module "_set_antialias_threshold" t))
(when-let [max-tex (webgl/max-texture-size context)]
(h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)

View File

@ -12,6 +12,15 @@
[app.util.dom :as dom]
[promesa.core :as p]))
(defn max-texture-size
"Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if
unavailable."
[gl]
(when gl
(let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))]
(when (and (number? n) (pos? n) (js/isFinite n))
(js/Math.floor n)))))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]

View File

@ -135,6 +135,28 @@
(wasm.mem/free)
text)))
(defn ^:export wasmAtlasConsole
"Logs the current render-wasm atlas as an image in the JS console (if present)."
[]
(let [module wasm/internal-module
f (when module (unchecked-get module "_debug_atlas_console"))]
(if (fn? f)
(wasm.h/call module "_debug_atlas_console")
(js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console"))))
(defn ^:export wasmAtlasBase64
"Returns the atlas PNG base64 (empty string if missing/empty)."
[]
(let [module wasm/internal-module
f (when module (unchecked-get module "_debug_atlas_base64"))]
(if (fn? f)
(let [ptr (wasm.h/call module "_debug_atlas_base64")
s (or (wasm-read-len-prefixed-utf8 ptr) "")]
s)
(do
(js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64")
""))))
(defn ^:export wasmCacheConsole
"Logs the current render-wasm cache surface as an image in the JS console."
[]

View File

@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
with_state_mut!(state, {
state
.render_state_mut()
.surfaces
.set_max_atlas_texture_size(max_px);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {

View File

@ -715,12 +715,14 @@ impl RenderState {
// In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap.
self.surfaces.cache_current_tile_texture(
&mut self.gpu_state,
&self.tile_viewbox,
&self
.current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect,
fast_mode,
self.render_area,
);
self.surfaces.draw_cached_tile_surface(
@ -1459,6 +1461,28 @@ impl RenderState {
performance::begin_measure!("render_from_cache");
let cached_scale = self.get_cached_scale();
let bg_color = self.background_color;
// During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
// always prefer the persistent atlas. The atlas is incrementally updated as tiles finish,
// 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);
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, shapes);
debug::render_wasm_label(self);
self.flush_and_submit();
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
@ -1475,7 +1499,62 @@ impl RenderState {
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
let bg_color = self.background_color;
// For zoom-out, prefer cache only if it fully covers the viewport.
// Otherwise, atlas will provide a more correct full-viewport preview.
let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom;
if zooming_out {
let cache_dim = self.surfaces.cache_dimensions();
let cache_w = cache_dim.width as f32;
let cache_h = cache_dim.height as f32;
// Viewport in target pixels.
let vw = (self.viewbox.width * self.options.dpr()).max(1.0);
let vh = (self.viewbox.height * self.options.dpr()).max(1.0);
// Inverse-map viewport corners into cache coordinates.
// target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
// => cache = (target / navigate_zoom) - translate
let inv = if navigate_zoom.abs() > f32::EPSILON {
1.0 / navigate_zoom
} else {
0.0
};
let cx0 = (0.0 * inv) - translate_x;
let cy0 = (0.0 * inv) - translate_y;
let cx1 = (vw * inv) - translate_x;
let cy1 = (vh * inv) - translate_y;
let min_x = cx0.min(cx1);
let min_y = cy0.min(cy1);
let max_x = cx0.max(cx1);
let max_y = cy0.max(cy1);
let cache_covers =
min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h;
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.viewbox,
self.options.dpr(),
bg_color,
);
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, shapes);
debug::render_wasm_label(self);
self.flush_and_submit();
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
}
}
// Setup canvas transform
{
@ -1531,6 +1610,7 @@ impl RenderState {
self.flush_and_submit();
}
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
}
@ -2700,13 +2780,8 @@ impl RenderState {
}
} else {
performance::begin_measure!("render_shape_tree::uncached");
// Only allow stopping (yielding) if the current tile is NOT visible.
// This ensures all visible tiles render synchronously before showing,
// eliminating empty squares during zoom. Interest-area tiles can still yield.
let tile_is_visible = self.tile_viewbox.is_visible(&current_tile);
let can_stop = allow_stop && !tile_is_visible;
let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?;
let (is_empty, early_return) = self
.render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?;
if early_return {
return Ok(());

View File

@ -194,6 +194,15 @@ 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;')"));
}
pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) {
let base64_image = render_state
.surfaces
.base64_snapshot(id)
.expect("Failed to get base64 image");
println!("{}", base64_image);
}
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) {
@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> {
});
Ok(())
}
#[no_mangle]
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_cache_base64() -> Result<()> {
with_state_mut!(state, {
console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_console() -> Result<()> {
with_state_mut!(state, {
console_debug_surface(state.render_state_mut(), SurfaceId::Atlas);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_base64() -> Result<()> {
with_state_mut!(state, {
console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas);
});
Ok(())
}

View File

@ -1,6 +1,7 @@
use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::Shape;
use crate::view::Viewbox;
use skia_safe::{self as skia, IRect, Paint, RRect};
@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256;
// If it's too big it could affect performance.
const TILE_SIZE_MULTIPLIER: i32 = 2;
/// Atlas texture size limits (px per side).
///
/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the
/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via
/// [`Surfaces::set_max_atlas_texture_size`].
/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value
/// (defensive cap to avoid accidentally creating oversized GPU textures).
const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096;
const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024;
#[repr(u32)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SurfaceId {
@ -30,6 +41,7 @@ pub enum SurfaceId {
Export = 0b010_0000_0000,
UI = 0b100_0000_0000,
Debug = 0b100_0000_0001,
Atlas = 0b100_0000_0010,
}
pub struct Surfaces {
@ -57,6 +69,18 @@ pub struct Surfaces {
export: skia::Surface,
tiles: TileTextureCache,
// Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
// It grows dynamically to include any rendered document rect.
atlas: skia::Surface,
atlas_origin: skia::Point,
atlas_size: skia::ISize,
/// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px).
/// When the atlas would exceed `max_atlas_texture_size`, this value is
/// reduced so the atlas stays within the fixed texture cap.
atlas_scale: f32,
/// Max width/height in pixels for the atlas surface (typically browser
/// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
max_atlas_texture_size: i32,
sampling_options: skia::SamplingOptions,
pub margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask)
@ -99,6 +123,10 @@ 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)?;
// Keep atlas as a regular surface like the rest. Start with a tiny
// transparent surface and grow it on demand.
let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?;
atlas.canvas().clear(skia::Color::TRANSPARENT);
let tiles = TileTextureCache::new();
Ok(Surfaces {
@ -115,6 +143,11 @@ impl Surfaces {
debug,
export,
tiles,
atlas,
atlas_origin: skia::Point::new(0.0, 0.0),
atlas_size: skia::ISize::new(0, 0),
atlas_scale: 1.0,
max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE,
sampling_options,
margins,
dirty_surfaces: 0,
@ -122,10 +155,185 @@ impl Surfaces {
})
}
/// Sets the maximum atlas texture dimension (one side). Should match the
/// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to
/// a small minimum so the atlas logic stays well-defined.
pub fn set_max_atlas_texture_size(&mut self, max_px: i32) {
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
}
fn ensure_atlas_contains(
&mut self,
gpu_state: &mut GpuState,
doc_rect: skia::Rect,
) -> Result<()> {
if doc_rect.is_empty() {
return Ok(());
}
// Current atlas bounds in document space (1 unit == 1 px).
let current_left = self.atlas_origin.x;
let current_top = self.atlas_origin.y;
let atlas_scale = self.atlas_scale.max(0.01);
let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale;
let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale;
let mut new_left = current_left;
let mut new_top = current_top;
let mut new_right = current_right;
let mut new_bottom = current_bottom;
// If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs).
let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0;
if needs_init {
new_left = doc_rect.left.floor();
new_top = doc_rect.top.floor();
new_right = doc_rect.right.ceil();
new_bottom = doc_rect.bottom.ceil();
} else {
new_left = new_left.min(doc_rect.left.floor());
new_top = new_top.min(doc_rect.top.floor());
new_right = new_right.max(doc_rect.right.ceil());
new_bottom = new_bottom.max(doc_rect.bottom.ceil());
}
// Add padding to reduce realloc frequency.
let pad = TILE_SIZE;
new_left -= pad;
new_top -= pad;
new_right += pad;
new_bottom += pad;
let doc_w = (new_right - new_left).max(1.0);
let doc_h = (new_bottom - new_top).max(1.0);
// Compute atlas scale needed to fit within the fixed texture cap.
// Keep the highest possible scale (closest to 1.0) that still fits.
let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32;
let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0);
// Never upscale the atlas (it would add blur and churn).
let new_scale = self.atlas_scale.min(required_scale).max(0.01);
let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32;
let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32;
// Fast path: existing atlas already contains the rect.
if !needs_init
&& doc_rect.left >= current_left
&& doc_rect.top >= current_top
&& doc_rect.right <= current_right
&& doc_rect.bottom <= current_bottom
{
return Ok(());
}
let mut new_atlas =
gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?;
new_atlas.canvas().clear(skia::Color::TRANSPARENT);
// Copy old atlas into the new one with offset.
if !needs_init {
let old_scale = self.atlas_scale.max(0.01);
let scale_ratio = new_scale / old_scale;
let dx = (current_left - new_left) * new_scale;
let dy = (current_top - new_top) * new_scale;
let image = self.atlas.image_snapshot();
let src = skia::Rect::from_xywh(
0.0,
0.0,
self.atlas_size.width as f32,
self.atlas_size.height as f32,
);
let dst = skia::Rect::from_xywh(
dx,
dy,
(self.atlas_size.width as f32) * scale_ratio,
(self.atlas_size.height as f32) * scale_ratio,
);
new_atlas.canvas().draw_image_rect(
&image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
}
self.atlas_origin = skia::Point::new(new_left, new_top);
self.atlas_size = skia::ISize::new(new_w, new_h);
self.atlas_scale = new_scale;
self.atlas = new_atlas;
Ok(())
}
fn blit_tile_image_into_atlas(
&mut self,
gpu_state: &mut GpuState,
tile_image: &skia::Image,
doc_rect: skia::Rect,
) -> Result<()> {
self.ensure_atlas_contains(gpu_state, doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
(doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
doc_rect.width() * self.atlas_scale,
doc_rect.height() * self.atlas_scale,
);
self.atlas
.canvas()
.draw_image_rect(tile_image, None, dst, &skia::Paint::default());
Ok(())
}
pub fn clear_tiles(&mut self) {
self.tiles.clear();
}
pub fn has_atlas(&self) -> bool {
self.atlas_size.width > 0 && self.atlas_size.height > 0
}
/// 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) {
if !self.has_atlas() {
return;
};
let canvas = self.target.canvas();
canvas.save();
canvas.reset_matrix();
let size = canvas.base_layer_size();
canvas.clip_rect(
skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32),
None,
true,
);
let s = viewbox.zoom * dpr;
let atlas_scale = self.atlas_scale.max(0.01);
canvas.clear(background);
canvas.translate((
(self.atlas_origin.x + viewbox.pan_x) * s,
(self.atlas_origin.y + viewbox.pan_y) * s,
));
canvas.scale((s / atlas_scale, s / atlas_scale));
self.atlas.draw(
canvas,
(0.0, 0.0),
self.sampling_options,
Some(&skia::Paint::default()),
);
canvas.restore();
}
pub fn margins(&self) -> skia::ISize {
self.margins
}
@ -255,6 +463,10 @@ impl Surfaces {
);
}
pub fn cache_dimensions(&self) -> skia::ISize {
skia::ISize::new(self.cache.width(), self.cache.height())
}
pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) {
performance::begin_measure!("apply_mut::flags");
if ids & SurfaceId::Target as u32 != 0 {
@ -352,6 +564,7 @@ impl Surfaces {
SurfaceId::Debug => &mut self.debug,
SurfaceId::UI => &mut self.ui,
SurfaceId::Export => &mut self.export,
SurfaceId::Atlas => &mut self.atlas,
}
}
@ -369,6 +582,7 @@ impl Surfaces {
SurfaceId::Debug => &self.debug,
SurfaceId::UI => &self.ui,
SurfaceId::Export => &self.export,
SurfaceId::Atlas => &self.atlas,
}
}
@ -546,10 +760,12 @@ impl Surfaces {
pub fn cache_current_tile_texture(
&mut self,
gpu_state: &mut GpuState,
tile_viewbox: &TileViewbox,
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
tile_doc_rect: skia::Rect,
) {
let rect = IRect::from_xywh(
self.margins.width,
@ -571,6 +787,9 @@ 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.tiles.add(tile_viewbox, tile, tile_image);
}
}