mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
🎉 Atlas
This commit is contained in:
parent
e99b6ec213
commit
48d26563ab
@ -369,7 +369,8 @@
|
||||
;; in the future (when we handle the UI in the render) should be better to
|
||||
;; have a "wasm.api/pointer-move" function that works as an entry point for
|
||||
;; all the pointer-move events.
|
||||
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))
|
||||
(when (wasm.api/text-editor-is-active?)
|
||||
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)))
|
||||
|
||||
(rx/push! move-stream pt)
|
||||
(reset! last-position raw-pt)
|
||||
|
||||
@ -966,12 +966,13 @@
|
||||
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
|
||||
|
||||
(def render-pan
|
||||
(letfn [(do-render-pan [ts]
|
||||
;; Check if context is still initialized before executing
|
||||
;; to prevent errors when navigating quickly
|
||||
(when wasm/context-initialized?
|
||||
(letfn [(do-render-pan [_ts]
|
||||
;; During panning we want the cheapest possible draw path.
|
||||
;; `_render_from_cache` should use the persistent atlas (when present)
|
||||
;; and avoid triggering heavy tile rendering work per frame.
|
||||
(when (and wasm/context-initialized? (not @wasm/context-lost?))
|
||||
(perf/begin-measure "render-pan")
|
||||
(render ts)
|
||||
(h/call wasm/internal-module "_render_from_cache" 0)
|
||||
(perf/end-measure "render-pan")))]
|
||||
(fns/throttle do-render-pan THROTTLE_DELAY_MS)))
|
||||
|
||||
|
||||
@ -31,6 +31,9 @@
|
||||
[app.main.errors :as errors]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.helpers :as wasm.h]
|
||||
[app.render-wasm.mem :as wasm.mem]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.http :as http]
|
||||
@ -117,6 +120,100 @@
|
||||
(js/console.log str (json/->js val))
|
||||
val))
|
||||
|
||||
(defn- wasm-read-len-prefixed-utf8
|
||||
"Reads a `[u32 byte_len][utf8 bytes...]` buffer returned by WASM and frees it.
|
||||
Returns a JS string (possibly empty)."
|
||||
[ptr]
|
||||
(when (and ptr (not (zero? ptr)))
|
||||
(let [heap-u8 (wasm.mem/get-heap-u8)
|
||||
heap-u32 (wasm.mem/get-heap-u32)
|
||||
len (aget heap-u32 (wasm.mem/->offset-32 ptr))
|
||||
start (+ ptr 4)
|
||||
end (+ start len)
|
||||
decoder (js/TextDecoder. "utf-8")
|
||||
text (.decode decoder (.subarray heap-u8 start end))]
|
||||
(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 wasmAtlasRectBase64
|
||||
"Returns the atlas rect PNG base64 (doc-space x/y/w/h)."
|
||||
[x y w h]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_atlas_rect_base64"))]
|
||||
(if (fn? f)
|
||||
(let [ptr (wasm.h/call module "_debug_atlas_rect_base64" x y w h)
|
||||
s (or (wasm-read-len-prefixed-utf8 ptr) "")]
|
||||
s)
|
||||
(do
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_rect_base64")
|
||||
""))))
|
||||
|
||||
(defn ^:export wasmZoomAtlasConsole
|
||||
"Logs the current render-wasm zoom_atlas as an image in the JS console (if present)."
|
||||
[]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_zoom_atlas_console"))]
|
||||
(if (fn? f)
|
||||
(wasm.h/call module "_debug_zoom_atlas_console")
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_zoom_atlas_console"))))
|
||||
|
||||
(defn ^:export wasmZoomAtlasBase64
|
||||
"Returns the zoom_atlas PNG base64 (empty string if missing/empty)."
|
||||
[]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_zoom_atlas_base64"))]
|
||||
(if (fn? f)
|
||||
(let [ptr (wasm.h/call module "_debug_zoom_atlas_base64")
|
||||
s (or (wasm-read-len-prefixed-utf8 ptr) "")]
|
||||
s)
|
||||
(do
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_zoom_atlas_base64")
|
||||
""))))
|
||||
|
||||
(defn ^:export wasmCacheConsole
|
||||
"Logs the current render-wasm cache surface as an image in the JS console."
|
||||
[]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_cache_console"))]
|
||||
(if (fn? f)
|
||||
(wasm.h/call module "_debug_cache_console")
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_console"))))
|
||||
|
||||
(defn ^:export wasmCacheBase64
|
||||
"Returns the cache surface PNG base64 (empty string if missing/empty)."
|
||||
[]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_debug_cache_base64"))]
|
||||
(if (fn? f)
|
||||
(let [ptr (wasm.h/call module "_debug_cache_base64")
|
||||
s (or (wasm-read-len-prefixed-utf8 ptr) "")]
|
||||
s)
|
||||
(do
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_base64")
|
||||
""))))
|
||||
|
||||
(when (exists? js/window)
|
||||
(set! (.-dbg ^js js/window) json/->js)
|
||||
(set! (.-pp ^js js/window) pprint))
|
||||
|
||||
@ -882,6 +882,112 @@ pub extern "C" fn render_shape_pixels(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn debug_atlas_console() -> Result<()> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use crate::run_script;
|
||||
with_state_mut!(state, {
|
||||
let b64 = state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.base64_snapshot_atlas()?
|
||||
.unwrap_or_default();
|
||||
|
||||
if b64.is_empty() {
|
||||
run_script!("console.log('[render-wasm] atlas: <empty>')".to_string());
|
||||
} else {
|
||||
run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{b64}) no-repeat; padding: 256px; background-size: contain;')"));
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `[u32 byte_len][utf8 bytes...]` with the base64 PNG of the atlas.
|
||||
/// When atlas is empty, returns len=0 (no bytes).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn debug_atlas_base64() -> Result<*mut u8> {
|
||||
with_state_mut!(state, {
|
||||
let b64 = state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.base64_snapshot_atlas()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let bytes = b64.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let mut buf = Vec::with_capacity(4 + bytes.len());
|
||||
buf.extend_from_slice(&len.to_le_bytes());
|
||||
buf.extend_from_slice(bytes);
|
||||
Ok(mem::write_bytes(buf))
|
||||
})
|
||||
}
|
||||
|
||||
/// Same as `debug_atlas_base64` but only for a doc-space rect (x,y,w,h).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn debug_atlas_rect_base64(x: f32, y: f32, w: f32, h: f32) -> Result<*mut u8> {
|
||||
with_state_mut!(state, {
|
||||
let rect = skia::Rect::from_xywh(x, y, w, h);
|
||||
let b64 = state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.base64_snapshot_atlas_rect(rect)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let bytes = b64.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let mut buf = Vec::with_capacity(4 + bytes.len());
|
||||
buf.extend_from_slice(&len.to_le_bytes());
|
||||
buf.extend_from_slice(bytes);
|
||||
Ok(mem::write_bytes(buf))
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn debug_cache_console() -> Result<()> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use crate::run_script;
|
||||
with_state_mut!(state, {
|
||||
let b64 = state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.base64_snapshot(crate::render::SurfaceId::Cache)?;
|
||||
|
||||
if b64.is_empty() {
|
||||
run_script!("console.log('[render-wasm] cache: <empty>')".to_string());
|
||||
} else {
|
||||
run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{b64}) no-repeat; padding: 256px; background-size: contain;')"));
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `[u32 byte_len][utf8 bytes...]` with the base64 PNG of the cache surface.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn debug_cache_base64() -> Result<*mut u8> {
|
||||
with_state_mut!(state, {
|
||||
let b64 = state
|
||||
.render_state_mut()
|
||||
.surfaces
|
||||
.base64_snapshot(crate::render::SurfaceId::Cache)?;
|
||||
|
||||
let bytes = b64.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let mut buf = Vec::with_capacity(4 + bytes.len());
|
||||
buf.extend_from_slice(&len.to_le_bytes());
|
||||
buf.extend_from_slice(bytes);
|
||||
Ok(mem::write_bytes(buf))
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
init_gl!();
|
||||
|
||||
@ -337,6 +337,9 @@ pub(crate) struct RenderState {
|
||||
/// Preview render mode - when true, uses simplified rendering for progressive loading
|
||||
pub preview_mode: bool,
|
||||
pub export_context: Option<(Rect, f32)>,
|
||||
/// Cleared at the beginning of a render pass; set to true after we clear Cache the first
|
||||
/// time we are about to blit a tile into Cache for this pass.
|
||||
pub cache_cleared_this_render: bool,
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
|
||||
@ -411,6 +414,7 @@ impl RenderState {
|
||||
ignore_nested_blurs: false,
|
||||
preview_mode: false,
|
||||
export_context: None,
|
||||
cache_cleared_this_render: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -665,13 +669,22 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> {
|
||||
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
|
||||
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
|
||||
// while still preventing stale pixels from surviving across full-quality renders.
|
||||
if !self.options.is_fast_mode() && !self.cache_cleared_this_render {
|
||||
self.surfaces.clear_cache(self.background_color);
|
||||
self.cache_cleared_this_render = true;
|
||||
}
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
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,
|
||||
self.render_area,
|
||||
);
|
||||
|
||||
self.surfaces.draw_cached_tile_surface(
|
||||
@ -1410,8 +1423,15 @@ impl RenderState {
|
||||
performance::begin_measure!("render_from_cache");
|
||||
let scale = self.get_cached_scale();
|
||||
|
||||
println!("render_from_cache");
|
||||
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
|
||||
if self.cached_viewbox.area.width() > 0.0 {
|
||||
// For zoom-in, the best fast-path is to reproject the cached frame
|
||||
// (viewport-sized) with a matrix. This avoids depending on atlas
|
||||
// resolution (which may be downscaled for huge documents) and avoids
|
||||
// any tile/atlas composition.
|
||||
let zooming_in = self.viewbox.zoom > self.cached_viewbox.zoom;
|
||||
|
||||
// Scale and translate the target according to the cached data
|
||||
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
|
||||
|
||||
@ -1427,6 +1447,61 @@ impl RenderState {
|
||||
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;
|
||||
println!("cache_covers: {}", cache_covers);
|
||||
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(),
|
||||
self.background_color,
|
||||
);
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render(self);
|
||||
}
|
||||
|
||||
ui::render(self, shapes);
|
||||
debug::render_wasm_label(self);
|
||||
self.flush_and_submit();
|
||||
println!("atlas drawn");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup canvas transform
|
||||
{
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||
@ -1437,6 +1512,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// Draw directly from cache surface, avoiding snapshot overhead
|
||||
println!("drawn from cache");
|
||||
self.surfaces.draw_cache_to_target();
|
||||
|
||||
// Restore canvas state
|
||||
@ -1451,6 +1527,7 @@ impl RenderState {
|
||||
|
||||
self.flush_and_submit();
|
||||
}
|
||||
|
||||
performance::end_measure!("render_from_cache");
|
||||
performance::end_timed_log!("render_from_cache", _start);
|
||||
}
|
||||
@ -1497,6 +1574,7 @@ impl RenderState {
|
||||
performance::begin_measure!("render");
|
||||
performance::begin_measure!("start_render_loop");
|
||||
|
||||
self.cache_cleared_this_render = false;
|
||||
self.reset_canvas();
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
| SurfaceId::Fills as u32
|
||||
|
||||
@ -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,13 @@ const TEXTURES_BATCH_DELETE: usize = 256;
|
||||
// If it's too big it could affect performance.
|
||||
const TILE_SIZE_MULTIPLIER: i32 = 2;
|
||||
|
||||
/// Hard cap for the persistent atlas texture dimensions.
|
||||
///
|
||||
/// WebGL textures have a maximum size. When the document would require a larger
|
||||
/// atlas, we keep the atlas within this cap by downscaling its stored content.
|
||||
// TODO: set this value from frontend
|
||||
const MAX_ATLAS_TEXTURE_SIZE: i32 = 16384;
|
||||
|
||||
#[repr(u32)]
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum SurfaceId {
|
||||
@ -57,6 +65,15 @@ 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: Option<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,
|
||||
sampling_options: skia::SamplingOptions,
|
||||
pub margins: skia::ISize,
|
||||
// Tracks which surfaces have content (dirty flag bitmask)
|
||||
@ -115,6 +132,10 @@ impl Surfaces {
|
||||
debug,
|
||||
export,
|
||||
tiles,
|
||||
atlas: None,
|
||||
atlas_origin: skia::Point::new(0.0, 0.0),
|
||||
atlas_size: skia::ISize::new(0, 0),
|
||||
atlas_scale: 1.0,
|
||||
sampling_options,
|
||||
margins,
|
||||
dirty_surfaces: 0,
|
||||
@ -122,10 +143,180 @@ impl Surfaces {
|
||||
})
|
||||
}
|
||||
|
||||
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.is_none() || 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.max(256.0);
|
||||
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 required_scale = ((MAX_ATLAS_TEXTURE_SIZE as f32) / doc_w)
|
||||
.min((MAX_ATLAS_TEXTURE_SIZE as f32) / doc_h)
|
||||
.min(1.0)
|
||||
.max(0.01);
|
||||
|
||||
// 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, MAX_ATLAS_TEXTURE_SIZE as f32) as i32;
|
||||
let new_h = (doc_h * new_scale)
|
||||
.ceil()
|
||||
.clamp(1.0, MAX_ATLAS_TEXTURE_SIZE as f32) 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 let Some(mut old) = self.atlas.take() {
|
||||
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 = old.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 = Some(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)?;
|
||||
let Some(atlas) = self.atlas.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
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.is_some() && 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) {
|
||||
let Some(atlas) = self.atlas.as_mut() else {
|
||||
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));
|
||||
|
||||
atlas.draw(canvas, (0.0, 0.0), self.sampling_options, Some(&skia::Paint::default()));
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
pub fn margins(&self) -> skia::ISize {
|
||||
self.margins
|
||||
}
|
||||
@ -178,6 +369,68 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base64_snapshot_atlas(&mut self) -> Result<Option<String>> {
|
||||
let Some(atlas) = self.atlas.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let image = atlas.image_snapshot();
|
||||
let mut context = atlas.direct_context();
|
||||
let encoded_image = image
|
||||
.encode(context.as_mut(), skia::EncodedImageFormat::PNG, None)
|
||||
.ok_or(Error::CriticalError("Failed to encode image".to_string()))?;
|
||||
Ok(Some(
|
||||
general_purpose::STANDARD.encode(encoded_image.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
/// doc_rect is in document space (1 unit == 1px at 100%).
|
||||
/// Returns None when atlas is empty or rect doesn't intersect atlas.
|
||||
pub fn base64_snapshot_atlas_rect(&mut self, doc_rect: skia::Rect) -> Result<Option<String>> {
|
||||
let Some(atlas) = self.atlas.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if doc_rect.is_empty() || self.atlas_size.width <= 0 || self.atlas_size.height <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut local = 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,
|
||||
);
|
||||
|
||||
let atlas_bounds = skia::Rect::from_xywh(
|
||||
0.0,
|
||||
0.0,
|
||||
self.atlas_size.width as f32,
|
||||
self.atlas_size.height as f32,
|
||||
);
|
||||
|
||||
if !local.intersect(atlas_bounds) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let irect = skia::IRect::from_ltrb(
|
||||
local.left.floor() as i32,
|
||||
local.top.floor() as i32,
|
||||
local.right.ceil() as i32,
|
||||
local.bottom.ceil() as i32,
|
||||
);
|
||||
|
||||
if let Some(image) = atlas.image_snapshot_with_bounds(irect) {
|
||||
let mut context = atlas.direct_context();
|
||||
let encoded_image = image
|
||||
.encode(context.as_mut(), skia::EncodedImageFormat::PNG, None)
|
||||
.ok_or(Error::CriticalError("Failed to encode image".to_string()))?;
|
||||
Ok(Some(
|
||||
general_purpose::STANDARD.encode(encoded_image.as_bytes()),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the canvas and automatically marks
|
||||
/// render surfaces as dirty when accessed. This tracks which surfaces
|
||||
/// have content for optimization purposes.
|
||||
@ -255,6 +508,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 {
|
||||
@ -533,11 +790,22 @@ impl Surfaces {
|
||||
self.clear_all_dirty();
|
||||
}
|
||||
|
||||
/// Clears the whole cache surface without disturbing its configured transform.
|
||||
pub fn clear_cache(&mut self, color: skia::Color) {
|
||||
let canvas = self.cache.canvas();
|
||||
canvas.save();
|
||||
canvas.reset_matrix();
|
||||
canvas.clear(color);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
pub fn cache_current_tile_texture(
|
||||
&mut self,
|
||||
gpu_state: &mut GpuState,
|
||||
tile_viewbox: &TileViewbox,
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
let rect = IRect::from_xywh(
|
||||
self.margins.width,
|
||||
@ -557,7 +825,12 @@ impl Surfaces {
|
||||
&skia::Paint::default(),
|
||||
);
|
||||
|
||||
self.tiles.add(tile_viewbox, tile, tile_image);
|
||||
// Keep a copy to also blit into the persistent atlas.
|
||||
self.tiles.add(tile_viewbox, tile, tile_image.clone());
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user