From f8825af25e05b0b43d373aa300f02c5108b0dc99 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 13 Aug 2026 09:17:11 +0200 Subject: [PATCH] :zap: Keep fill-image display GPU tier lazy for deep zoom Store encoded media in RAM, upload a shape-sized display texture eagerly, and only promote the native full texture when zoom needs it so oversized fillImages do not saturate integrated GPUs. --- frontend/src/app/render_wasm/api.cljs | 75 +--- render-wasm/src/render.rs | 2 +- render-wasm/src/render/fills.rs | 10 +- render-wasm/src/render/images.rs | 493 +++++++++++++++++++++++--- render-wasm/src/render/strokes.rs | 10 +- render-wasm/src/wasm/fills/image.rs | 20 +- 6 files changed, 494 insertions(+), 116 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f3b7242bc7..74af4f0ce0 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -874,30 +874,18 @@ nil) -(defn- get-texture-id-for-gl-object - "Registers a WebGL texture with Emscripten's GL object system and returns its ID" - [texture] - (let [gl-obj (unchecked-get wasm/internal-module "GL") - textures (.-textures ^js gl-obj) - new-id (.getNewId ^js gl-obj textures)] - (aset textures new-id texture) - new-id)) - -(defn- svg-blob? - [^js blob] - (str/starts-with? (.-type blob) "image/svg")) - -(defn- store-svg-image - "Sends raw SVG bytes to WASM so Skia parses and rasterizes them there. - Browsers reject SVG blobs in `createImageBitmap`, so SVGs skip the - shared-texture path." +(defn- store-image-bytes + "Sends encoded image bytes to WASM. Rust keeps them in RAM and builds the + GPU display tier (shape @ 100% zoom); full native stays lazy for deep zoom. + Used for both SVG and raster fills so we never upload a full-res GL texture + from JS." [shape-id image-id thumbnail? ^js blob] (-> (.arrayBuffer blob) (p/then (fn [buffer] (let [image-bytes (js/Uint8Array. buffer) ;; Header: 16 bytes shape uuid + 16 bytes image uuid - ;; + 4 bytes thumbnail flag, then the raw SVG payload. + ;; + 4 bytes thumbnail flag, then the encoded payload. offset (mem/alloc (+ 36 (.-byteLength image-bytes))) heap (mem/get-heap-u8) dview (mem/get-data-view)] @@ -909,46 +897,9 @@ (h/call wasm/internal-module "_store_image") true))))) -(defn- store-image-texture - "Creates a WebGL texture from a decoded image and passes the texture ID to - WASM. This avoids decoding the image twice (once in browser, once in WASM)." - [shape-id image-id thumbnail? img] - (when-let [gl (webgl/get-webgl-context)] - (let [texture (webgl/create-webgl-texture-from-image gl img) - texture-id (get-texture-id-for-gl-object texture) - width (.-width ^js img) - height (.-height ^js img) - ;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail) - ;; + 4 bytes (texture ID) + 8 bytes (dimensions) - total-bytes 48 - offset (mem/alloc->offset-32 total-bytes) - heap32 (mem/get-heap-u32)] - - ;; 1. Set shape id (offset + 0 to offset + 3) - (mem.h32/write-uuid offset heap32 shape-id) - - ;; 2. Set image id (offset + 4 to offset + 7) - (mem.h32/write-uuid (+ offset 4) heap32 image-id) - - ;; 3. Set thumbnail flag as u32 (offset + 8) - (aset heap32 (+ offset 8) (if thumbnail? 1 0)) - - ;; 4. Set texture ID (offset + 9) - (aset heap32 (+ offset 9) texture-id) - - ;; 5. Set width (offset + 10) - (aset heap32 (+ offset 10) width) - - ;; 6. Set height (offset + 11) - (aset heap32 (+ offset 11) height) - - (h/call wasm/internal-module "_store_image_from_texture") - true))) - (defn- fetch-image - "Loads an image and hands it to WASM. Raster images are decoded by the - browser and shared as a WebGL texture; SVG images are sent as raw bytes - so Skia rasterizes them." + "Loads an image and hands encoded bytes to WASM (`_store_image`). LOD / + display-vs-full GPU tiers are handled entirely in Rust." [shape-id image-id thumbnail?] (let [url (cf/resolve-file-media {:id image-id} thumbnail?)] {:key url @@ -959,11 +910,7 @@ (p/then (fn [^js response] (.blob response))))) (rx/mapcat (fn [^js blob] - (rx/from - (if (svg-blob? blob) - (store-svg-image shape-id image-id thumbnail? blob) - (p/then (js/createImageBitmap blob) - (partial store-image-texture shape-id image-id thumbnail?)))))) + (rx/from (store-image-bytes shape-id image-id thumbnail? blob)))) (rx/catch (fn [cause] (log/error :hint "Could not fetch image" @@ -1005,7 +952,7 @@ (defn set-shape-fills [shape-id fills thumbnail?] ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). + ;; browser-only (bytes → Rust ImageStore). (when-let [fills (props/write-shape-fills! fills)] (keep (fn [id] (let [buffer (uuid/get-u32 id) @@ -1022,7 +969,7 @@ (defn set-shape-strokes [shape-id strokes thumbnail?] ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). + ;; browser-only (bytes → Rust ImageStore). (keep (fn [image-id] (let [buffer (uuid/get-u32 image-id) cached-image? (h/call wasm/internal-module "_is_image_cached" diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1a443ab32f..cd9e2aa122 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -4,7 +4,7 @@ pub mod filters; mod fonts; pub mod gpu_state; pub mod grid_layout; -mod images; +pub mod images; mod options; pub mod pdf; pub mod raster; diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 3bcbea93cd..0284b09168 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -88,7 +88,15 @@ fn draw_image_fill( return; } - let Some(image) = get_resources().images.get(&image_fill.id()) else { + let view_scale = render_state.get_view_scale(); + let dpr = render_state.options.dpr.max(1.0); + let display_side = super::images::shape_side_px(&shape.selrect, dpr); + let needed_side = super::images::shape_side_px(&shape.selrect, view_scale); + let Some(image) = get_resources().images.get_for_draw( + &image_fill.id(), + display_side, + needed_side, + ) else { return; }; diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 79b5e5653a..556f91cdf2 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -55,8 +55,44 @@ pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill MathRect::from_xywh(source_x, source_y, source_width, source_height) } +/// Longest design-space side of a shape, in device pixels at the given scale +/// (`zoom × dpr` for needed; `dpr` alone for the 100%-zoom display tier). +pub fn shape_side_px(selrect: &MathRect, scale: f32) -> i32 { + let side = selrect.width().max(selrect.height()).max(1.0); + (side * scale.max(1e-6)).ceil().max(1.0) as i32 +} + +fn rgba_bytes(image: &Image) -> usize { + let d = image.dimensions(); + (d.width.max(0) as usize) * (d.height.max(0) as usize) * 4 +} + +fn fit_size(native: ISize, max_side: i32) -> ISize { + let longest = native.width.max(native.height).max(1); + if max_side <= 0 || longest <= max_side { + return native; + } + let scale = max_side as f32 / longest as f32; + ISize::new( + (native.width as f32 * scale).round().max(1.0) as i32, + (native.height as f32 * scale).round().max(1.0) as i32, + ) +} + +/// Encoded raster kept in RAM, with optional GPU tiers: +/// - `display`: shape @ 100% zoom (eager, small) +/// - `full`: native resolution (lazy, for deep zoom) +struct RasterStored { + raw: Vec, + native: ISize, + display: Option, + display_side: i32, + full: Option, +} + enum StoredImage { - Raw(Vec), + Raster(RasterStored), + /// Legacy / thumbnail path: a single GPU texture (often from a shared GL tex). Gpu(Image), Svg { dom: skia::svg::Dom, @@ -65,12 +101,13 @@ enum StoredImage { // exports). The shape fill path draws the DOM directly instead. raster: Option, }, + /// Encoded bytes not yet classified (decode failed at add time). + Raw(Vec), } struct StoredEntry { image: StoredImage, - /// Approximate retained cost: encoded byte length (raw/svg) or the - /// decoded RGBA size for images registered from a GL texture. + /// Approximate retained cost: encoded bytes + resident GPU RGBA. bytes: usize, /// LRU tick; `Cell` so read paths can touch it without `&mut self`. last_used: Cell, @@ -128,19 +165,36 @@ fn create_image_from_gl_texture( Ok(image) } -// Decode and upload to GPU -fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option { +fn codec_native_size(raw_data: &[u8]) -> Option { let data = unsafe { skia::Data::new_bytes(raw_data) }; let codec = Codec::from_data(&data)?; - let image = Image::from_encoded(&data)?; - let mut dimensions = codec.dimensions(); if codec.origin().swaps_width_height() { dimensions.width = codec.dimensions().height; dimensions.height = codec.dimensions().width; } + Some(dimensions) +} - let image_info = skia::ImageInfo::new_n32_premul(dimensions, None); +/// Decode `raw_data` and upload a GPU texture whose longest side is at most +/// `max_side` (or native size when `max_side` is 0 / larger than native). +fn decode_image_to_max_side( + context: &mut Box, + raw_data: &[u8], + max_side: i32, +) -> Option<(Image, ISize)> { + let data = unsafe { skia::Data::new_bytes(raw_data) }; + let codec = Codec::from_data(&data)?; + let encoded = Image::from_encoded(&data)?; + + let mut native = codec.dimensions(); + if codec.origin().swaps_width_height() { + native.width = codec.dimensions().height; + native.height = codec.dimensions().width; + } + + let dst = fit_size(native, max_side); + let image_info = skia::ImageInfo::new_n32_premul(dst, None); let mut surface = surfaces::render_target( context, @@ -154,13 +208,29 @@ fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option, raw_data: &[u8]) -> Option { + decode_image_to_max_side(context, raw_data, 0).map(|(img, _)| img) +} + +fn raster_retained_bytes(raster: &RasterStored) -> usize { + let mut bytes = raster.raw.len(); + if let Some(ref img) = raster.display { + bytes += rgba_bytes(img); + } + if let Some(ref img) = raster.full { + bytes += rgba_bytes(img); + } + bytes } // Size for SVGs without intrinsic dimensions nor a viewBox. @@ -262,6 +332,22 @@ impl ImageStore { ); } + fn recompute_entry_bytes(&mut self, key: (Uuid, bool)) { + if let Some(entry) = self.images.get_mut(&key) { + let new_bytes = match &entry.image { + StoredImage::Raster(r) => raster_retained_bytes(r), + StoredImage::Gpu(img) => rgba_bytes(img), + StoredImage::Svg { .. } => entry.bytes, // keep prior encoded estimate + StoredImage::Raw(raw) => raw.len(), + }; + self.total_bytes = self + .total_bytes + .saturating_sub(entry.bytes) + .saturating_add(new_bytes); + entry.bytes = new_bytes; + } + } + /// Evicts least-recently-used images until the store retains at most /// `max_bytes`. Meant to be called by the headless exporter *between* /// requests, so an image can never disappear under a running render; @@ -295,15 +381,22 @@ impl ImageStore { evicted } + /// Stores encoded image bytes. When `display_side` is set (shape @ 100% × + /// dpr), eagerly builds that GPU tier and keeps the full native decode + /// lazy until deep zoom asks for it. pub fn add( &mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8], + display_side: Option, ) -> crate::error::Result<()> { let key = (id, is_thumbnail); if self.images.contains_key(&key) { + if let Some(side) = display_side.filter(|s| *s > 0) { + self.ensure_display_side(key, side); + } return Ok(()); } @@ -311,10 +404,8 @@ impl ImageStore { let bytes = raw_data.len(); match self.context.as_mut() { - Some(context) => { - if let Some(gpu_image) = decode_image(context, &raw_data) { - self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes); - } else if let Some((dom, size)) = parse_svg(&raw_data) { + Some(_) => { + if let Some((dom, size)) = parse_svg(&raw_data) { self.insert_entry( key, StoredImage::Svg { @@ -324,6 +415,42 @@ impl ImageStore { }, bytes, ); + } else if let Some(native) = codec_native_size(&raw_data) { + let mut raster = RasterStored { + raw: raw_data, + native, + display: None, + display_side: 0, + full: None, + }; + + // Thumbnails are already small: decode once at native. + // Otherwise eagerly build the display tier when we know + // the shape side at 100% zoom. + let eager_side = if is_thumbnail { + native.width.max(native.height) + } else { + display_side.unwrap_or(0).max(0) + }; + + if eager_side > 0 { + let target = fit_size(native, eager_side); + let target_side = target.width.max(target.height); + if let Some(context) = self.context.as_mut() { + if let Some((img, _)) = + decode_image_to_max_side(context, &raster.raw, target_side) + { + // Always park the eager decode in `display`. + // `full` is reserved for a larger lazy promote. + raster.display = Some(img); + raster.display_side = + target_side.min(native.width.max(native.height)); + } + } + } + + let retained = raster_retained_bytes(&raster); + self.insert_entry(key, StoredImage::Raster(raster), retained); } else { // The lazy re-decode in `get_internal` only retries raster codecs, // so SVGs that fail to parse here stay raw. @@ -385,13 +512,251 @@ impl ImageStore { self.images.contains_key(&(*id, is_thumbnail)) } - pub fn get(&mut self, id: &Uuid) -> Option<&Image> { - // Try to get full image first, fallback to thumbnail - let has_full = self.images.contains_key(&(*id, false)); - if has_full { - self.get_internal(id, false) + /// Grow/create the display GPU tier so it covers at least `side` px + /// (capped to native). Used when the shape grows while zoomed out. + fn ensure_display_side(&mut self, key: (Uuid, bool), side: i32) { + let Some(entry) = self.images.get_mut(&key) else { + return; + }; + let StoredImage::Raster(raster) = &mut entry.image else { + return; + }; + let native_side = raster.native.width.max(raster.native.height); + let target = side.min(native_side).max(1); + if raster.display_side >= target { + return; + } + // If we already have full and target is native, display is redundant. + if target >= native_side { + if raster.full.is_none() && raster.display.is_none() { + let raw = raster.raw.clone(); + let Some(context) = self.context.as_mut() else { + return; + }; + if let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) { + if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(raster) = &mut entry.image { + // Native-sized tier lives in `display` so eviction + // logic never drops the only GPU image. + raster.display = Some(img); + raster.display_side = native_side; + raster.full = None; + } + } + self.recompute_entry_bytes(key); + } + } else if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(raster) = &mut entry.image { + if raster.display.is_none() { + if let Some(full) = raster.full.take() { + raster.display = Some(full); + } + } + raster.display_side = native_side; + raster.full = None; + } + self.recompute_entry_bytes(key); + } + return; + } + + let raw = match self.images.get(&key) { + Some(StoredEntry { + image: StoredImage::Raster(r), + .. + }) => r.raw.clone(), + _ => return, + }; + let Some(context) = self.context.as_mut() else { + return; + }; + let Some((img, _)) = decode_image_to_max_side(context, &raw, target) else { + return; + }; + if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(raster) = &mut entry.image { + raster.display = Some(img); + raster.display_side = target; + } + } + self.recompute_entry_bytes(key); + } + + fn ensure_full(&mut self, key: (Uuid, bool)) -> bool { + let raw = match self.images.get(&key) { + Some(StoredEntry { + image: StoredImage::Raster(r), + .. + }) => { + if r.full.is_some() { + return true; + } + r.raw.clone() + } + _ => return false, + }; + let Some(context) = self.context.as_mut() else { + return false; + }; + let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) else { + return false; + }; + if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(raster) = &mut entry.image { + raster.full = Some(img); + } + } + self.recompute_entry_bytes(key); + true + } + + fn drop_full_if_unneeded(&mut self, key: (Uuid, bool), needed_side: i32) { + let should_drop = { + let Some(entry) = self.images.get(&key) else { + return; + }; + let StoredImage::Raster(raster) = &entry.image else { + return; + }; + if raster.full.is_none() { + return; + } + // Only evict full when a real display texture still covers the + // request. If display was cleared because the tier is native-sized + // (full is the only GPU image), dropping it leaves nothing to draw. + raster.display.is_some() + && raster.display_side > 0 + && needed_side <= raster.display_side + }; + if !should_drop { + return; + } + if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(raster) = &mut entry.image { + raster.full = None; + } + } + self.recompute_entry_bytes(key); + } + + /// Picks display vs full for painting. `display_side` is shape@100%×dpr; + /// `needed_side` is shape×view_scale (zoom×dpr). + pub fn get_for_draw( + &mut self, + id: &Uuid, + display_side: i32, + needed_side: i32, + ) -> Option<&Image> { + let key = if self.images.contains_key(&(*id, false)) { + (*id, false) + } else if self.images.contains_key(&(*id, true)) { + (*id, true) } else { - self.get_internal(id, true) + return None; + }; + + // Promote Raw → Raster on first draw if needed. + if matches!( + self.images.get(&key).map(|e| &e.image), + Some(StoredImage::Raw(_)) + ) { + let raw = match self.images.remove(&key) { + Some(StoredEntry { + image: StoredImage::Raw(raw), + bytes, + last_used, + }) => { + self.total_bytes = self.total_bytes.saturating_sub(bytes); + let _ = last_used; + raw + } + Some(other) => { + self.images.insert(key, other); + return None; + } + None => return None, + }; + if let Some(native) = codec_native_size(&raw) { + let retained = raw.len(); + self.insert_entry( + key, + StoredImage::Raster(RasterStored { + raw, + native, + display: None, + display_side: 0, + full: None, + }), + retained, + ); + } else { + self.insert_entry(key, StoredImage::Raw(raw), 0); + return None; + } + } + + if display_side > 0 { + self.ensure_display_side(key, display_side); + } + + let use_full = { + let entry = self.images.get(&key)?; + match &entry.image { + StoredImage::Raster(r) => { + // Need full when display is missing or too small for needed. + let display_covers = r.display.is_some() + && r.display_side > 0 + && needed_side <= r.display_side; + !display_covers + } + _ => false, + } + }; + + if use_full { + self.ensure_full(key); + } else { + self.drop_full_if_unneeded(key, needed_side); + } + + if matches!( + self.images.get(&key).map(|e| &e.image), + Some(StoredImage::Svg { .. }) + ) { + return self.get_internal(id, key.1); + } + + let tick = self.next_tick(); + let entry = self.images.get(&key)?; + entry.last_used.set(tick); + match &entry.image { + StoredImage::Raster(r) => { + let prefer_display = r.display.is_some() + && r.display_side > 0 + && needed_side <= r.display_side; + if prefer_display { + r.display.as_ref() + } else { + // Prefer full when needed; fall back to display if full + // decode failed (e.g. GPU limit) so the shape stays visible. + r.full.as_ref().or(r.display.as_ref()) + } + } + StoredImage::Gpu(img) => Some(img), + StoredImage::Svg { .. } => None, // handled above + StoredImage::Raw(_) => None, + } + } + + pub fn get(&mut self, id: &Uuid) -> Option<&Image> { + // Legacy callers without LOD: prefer full when present, else display. + let has_full = self.images.contains_key(&(*id, false)); + let key_thumb = !has_full; + if has_full { + // Request a huge needed side so full is materialized when possible. + self.get_for_draw(id, 0, i32::MAX) + } else { + self.get_internal(id, key_thumb) } } @@ -417,6 +782,10 @@ impl ImageStore { let data = unsafe { skia::Data::new_bytes(raw_data) }; Image::from_encoded(&data) } + StoredImage::Raster(r) => { + let data = unsafe { skia::Data::new_bytes(&r.raw) }; + Image::from_encoded(&data) + } StoredImage::Gpu(img) => Some(img.clone()), StoredImage::Svg { dom, size, .. } => { // No GPU context in the headless path: rasterize on a CPU @@ -447,33 +816,67 @@ impl ImageStore { let key = (*id, is_thumbnail); let tick = self.tick.get() + 1; self.tick.set(tick); - // Use entry API to mutate the HashMap in-place if needed - if let Some(entry) = self.images.get_mut(&key) { - entry.last_used.set(tick); - let image = &mut entry.image; - match image { - StoredImage::Gpu(ref img) => Some(img), - StoredImage::Raw(raw_data) => { - let context = self.context.as_mut()?; - let gpu_image = decode_image(context, raw_data)?; - *image = StoredImage::Gpu(gpu_image); - if let StoredImage::Gpu(ref img) = image { - Some(img) - } else { - None - } - } - StoredImage::Svg { dom, size, raster } => { - if raster.is_none() { - let context = self.context.as_mut()?; - *raster = rasterize_svg(context, dom, *size); - } - raster.as_ref() + let needs_full_decode = matches!( + self.images.get(&key).map(|e| &e.image), + Some(StoredImage::Raster(r)) if r.full.is_none() && r.display.is_none() + ); + let needs_gpu_from_raw = matches!( + self.images.get(&key).map(|e| &e.image), + Some(StoredImage::Raw(_)) + ); + let needs_svg_raster = matches!( + self.images.get(&key).map(|e| &e.image), + Some(StoredImage::Svg { raster: None, .. }) + ); + + if needs_full_decode { + let raw = match self.images.get(&key) { + Some(StoredEntry { + image: StoredImage::Raster(r), + .. + }) => r.raw.clone(), + _ => return None, + }; + let context = self.context.as_mut()?; + let gpu_image = decode_image(context, &raw)?; + if let Some(entry) = self.images.get_mut(&key) { + if let StoredImage::Raster(r) = &mut entry.image { + r.full = Some(gpu_image); } } - } else { - None + self.recompute_entry_bytes(key); + } else if needs_gpu_from_raw { + let raw = match self.images.get(&key) { + Some(StoredEntry { + image: StoredImage::Raw(raw), + .. + }) => raw.clone(), + _ => return None, + }; + let context = self.context.as_mut()?; + let gpu_image = decode_image(context, &raw)?; + if let Some(entry) = self.images.get_mut(&key) { + entry.image = StoredImage::Gpu(gpu_image); + } + } else if needs_svg_raster { + let ImageStore { + context, images, .. + } = self; + let context = context.as_mut()?; + let entry = images.get_mut(&key)?; + if let StoredImage::Svg { dom, size, raster } = &mut entry.image { + *raster = rasterize_svg(context, dom, *size); + } + } + + let entry = self.images.get(&key)?; + entry.last_used.set(tick); + match &entry.image { + StoredImage::Gpu(img) => Some(img), + StoredImage::Raster(r) => r.full.as_ref().or(r.display.as_ref()), + StoredImage::Svg { raster, .. } => raster.as_ref(), + StoredImage::Raw(_) => None, } } } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index b4ac78eb05..612cc45bef 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -568,7 +568,15 @@ fn draw_image_stroke_in_container( } else { stroke }; - let Some(image) = get_resources().images.get(&image_fill.id()) else { + let view_scale = render_state.get_view_scale(); + let dpr = render_state.options.dpr.max(1.0); + let display_side = super::images::shape_side_px(&shape.selrect, dpr); + let needed_side = super::images::shape_side_px(&shape.selrect, view_scale); + let Some(image) = get_resources().images.get_for_draw( + &image_fill.id(), + display_side, + needed_side, + ) else { return Ok(()); }; diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index ce14511220..537706dd6d 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -127,10 +127,22 @@ pub extern "C" fn store_image() -> Result<()> { let image_bytes = &bytes[IMAGE_HEADER_SIZE..]; with_state!(state, { - if let Err(msg) = get_resources() - .images - .add(ids.image_id, is_thumbnail, image_bytes) - { + let display_side = if is_thumbnail { + None + } else { + state.shapes.get(&ids.shape_id).map(|shape| { + // Display tier = shape size at 100% zoom in device pixels (× dpr). + let dpr = crate::get_render_state().options.dpr.max(1.0); + crate::render::images::shape_side_px(&shape.selrect, dpr) + }) + }; + + if let Err(msg) = get_resources().images.add( + ids.image_id, + is_thumbnail, + image_bytes, + display_side, + ) { eprintln!("{}", msg); } touch_shapes_with_image(state, ids.image_id);