From 36e0e40b36e8209f4c7e750262d38e19fae641f6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 16 Aug 2026 21:22:40 +0300 Subject: [PATCH] perf(raster): avoid needless image resampling --- src/scene/model/image_model.rs | 47 ++-- src/scene/pipeline/image_gpu.rs | 397 ++++++++++++++++++++++---------- 2 files changed, 293 insertions(+), 151 deletions(-) diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 17c8c0f67..a5dedaedb 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -360,8 +360,10 @@ pub struct DecodedImage { pub height: u32, } -fn image_cache() -> &'static Mutex>> { - static CACHE: OnceLock>>> = OnceLock::new(); +type ImageCacheEntry = Arc>>; + +fn image_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } @@ -385,22 +387,20 @@ pub fn resolve_image(path: &str) -> Option { if path.is_empty() { return None; } - // Cache probe in its own scope so the lock is released before any fetch. - { - if let Ok(cache) = image_cache().lock() { - if let Some(cached) = cache.get(path) { - return cached.clone(); - } - } - } - let decoded = decode_reference(path); - if let Ok(mut cache) = image_cache().lock() { - cache.insert(path.to_string(), decoded.clone()); - } - decoded + let entry = { + let mut cache = image_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()); + Arc::clone( + cache + .entry(path.to_string()) + .or_insert_with(|| Arc::new(OnceLock::new())), + ) + }; + entry.get_or_init(|| decode_reference(path)).clone() } -/// Decode a reference (local path or remote URL) to downscaled RGBA pixels. +/// Decode a reference (local path or remote URL) to RGBA pixels. fn decode_reference(path: &str) -> Option { let lower = path.to_ascii_lowercase(); let img = if lower.starts_with("http://") || lower.starts_with("https://") { @@ -409,20 +409,7 @@ fn decode_reference(path: &str) -> Option { } else { image::open(Path::new(path)).ok()? }; - // GPUs cap 2-D texture dimensions (8192 with wgpu's default limits). - // Downscale oversized images to fit, preserving aspect ratio, so texture - // creation can't fail — they're displayed scaled-down anyway. - const MAX_DIM: u32 = 8192; - let img = if img.width() > MAX_DIM || img.height() > MAX_DIM { - let longest = img.width().max(img.height()) as f32; - let scale = MAX_DIM as f32 / longest; - let nw = ((img.width() as f32 * scale) as u32).clamp(1, MAX_DIM); - let nh = ((img.height() as f32 * scale) as u32).clamp(1, MAX_DIM); - img.resize(nw, nh, image::imageops::FilterType::Triangle) - } else { - img - }; - let rgba = img.to_rgba8(); + let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); Some(DecodedImage { pixels: Arc::new(rgba.into_raw()), diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 24528f839..3009d7c99 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -8,6 +8,7 @@ use crate::scene::model::image_model::ImageModel; use iced::wgpu; use iced::wgpu::util::DeviceExt; +use std::sync::Arc; // ── Vertex ──────────────────────────────────────────────────────────────── @@ -86,15 +87,15 @@ struct ImageParams { pub struct ImageGpu { pub vertex_buffer: wgpu::Buffer, - pub instance_buffer: wgpu::Buffer, + pub instance_buffer: Arc, /// Number of triangle vertices in `vertex_buffer` — 6 for a plain quad, or /// more when the raster is clipped to a triangulated polygon. pub vertex_count: u32, pub instance_count: u32, pub bind_group: wgpu::BindGroup, _texture: wgpu::Texture, - _sampler: wgpu::Sampler, - _params_buf: wgpu::Buffer, + _sampler: Arc, + _params_buf: Arc, } impl ImageGpu { @@ -118,57 +119,7 @@ impl ImageGpu { }); groups[slot].push(model); } - groups - .into_iter() - .filter_map(|group| Self::new(device, queue, &group, bgl1)) - .collect() - } - - fn new( - device: &wgpu::Device, - queue: &wgpu::Queue, - models: &[&ImageModel], - bgl1: &wgpu::BindGroupLayout, - ) -> Option { - let &model = models.first()?; - if model.pixels.is_empty() || model.width == 0 || model.height == 0 { - return None; - } - - // ── Upload texture ──────────────────────────────────────────────── - let tex_label = format!("image.texture:{}", model.file_path); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some(&tex_label), - size: wgpu::Extent3d { - width: model.width, - height: model.height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - texture.as_image_copy(), - &model.pixels[..], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * model.width), - rows_per_image: Some(model.height), - }, - wgpu::Extent3d { - width: model.width, - height: model.height, - depth_or_array_layers: 1, - }, - ); - let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - // ── Sampler ─────────────────────────────────────────────────────── - let _sampler = device.create_sampler(&wgpu::SamplerDescriptor { + let sampler = Arc::new(device.create_sampler(&wgpu::SamplerDescriptor { label: Some("image.sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, @@ -177,61 +128,30 @@ impl ImageGpu { min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() - }); + })); + groups + .into_iter() + .flat_map(|group| Self::new(device, queue, &group, bgl1, &sampler)) + .collect() + } - // ── Opacity uniform ─────────────────────────────────────────────── - let params = ImageParams { - opacity: model.opacity.clamp(0.0, 1.0), - draw_depth: 0.0, - _pad: [0.0; 2], + fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + models: &[&ImageModel], + bgl1: &wgpu::BindGroupLayout, + sampler: &Arc, + ) -> Vec { + let Some(&model) = models.first() else { + return Vec::new(); }; - let _params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("image.params"), - contents: bytemuck::bytes_of(¶ms), - usage: wgpu::BufferUsages::UNIFORM, - }); - - // ── Bind group ──────────────────────────────────────────────────── - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("image.bind_group1"), - layout: bgl1, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&tex_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&_sampler), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: _params_buf.as_entire_binding(), - }, - ], - }); - - // ── Vertex buffer — the image's visible triangles ───────────────── - // `model.verts` already holds either the full quad or the triangulated - // clip polygon, each vertex carrying an RTE-split position and its UV. - let verts: Vec = model - .verts - .iter() - .map(|v| ImageVertex { - pos: v.pos, - uv: v.uv, - pos_low: v.pos_low, - }) - .collect(); - if verts.is_empty() { - return None; + if model.pixels.is_empty() || model.width == 0 || model.height == 0 { + return Vec::new(); } - let vertex_count = verts.len() as u32; - let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("image.vbuf"), - contents: bytemuck::cast_slice(&verts), - usage: wgpu::BufferUsages::VERTEX, - }); + + let limit = device.limits().max_texture_dimension_2d.max(1); + let x_tiles = tile_ranges(model.width, limit); + let y_tiles = tile_ranges(model.height, limit); let base = model .render_instance .map_or([0.0; 3], |instance| instance.translation); @@ -259,21 +179,256 @@ impl ImageGpu { } }) .collect(); - let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("image.instances"), - contents: bytemuck::cast_slice(&instances), - usage: wgpu::BufferUsages::VERTEX, - }); + let instance_buffer = Arc::new(device.create_buffer_init( + &wgpu::util::BufferInitDescriptor { + label: Some("image.instances"), + contents: bytemuck::cast_slice(&instances), + usage: wgpu::BufferUsages::VERTEX, + }, + )); + let params = ImageParams { + opacity: model.opacity.clamp(0.0, 1.0), + draw_depth: 0.0, + _pad: [0.0; 2], + }; + let params_buf = Arc::new(device.create_buffer_init( + &wgpu::util::BufferInitDescriptor { + label: Some("image.params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }, + )); - Some(Self { - vertex_buffer, - instance_buffer, - vertex_count, - instance_count: instances.len() as u32, - bind_group, - _texture: texture, - _sampler, - _params_buf, - }) + let mut output = Vec::with_capacity(x_tiles.len() * y_tiles.len()); + for y in &y_tiles { + for x in &x_tiles { + let verts = tile_vertices(model, *x, *y); + if verts.is_empty() { + continue; + } + + let width = x.data_end - x.data_start; + let height = y.data_end - y.data_start; + let tex_label = format!( + "image.texture:{}:{}:{}", + model.file_path, x.content_start, y.content_start + ); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(&tex_label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + &model.pixels[((y.data_start as usize * model.width as usize + + x.data_start as usize) + * 4)..], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * model.width), + rows_per_image: Some(model.height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let tex_view = + texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("image.bind_group1"), + layout: bgl1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&tex_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: params_buf.as_entire_binding(), + }, + ], + }); + let vertex_buffer = + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("image.vbuf"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + output.push(Self { + vertex_buffer, + instance_buffer: Arc::clone(&instance_buffer), + vertex_count: verts.len() as u32, + instance_count: instances.len() as u32, + bind_group, + _texture: texture, + _sampler: Arc::clone(sampler), + _params_buf: Arc::clone(¶ms_buf), + }); + } + } + output } } + +#[derive(Clone, Copy)] +struct TileRange { + content_start: u32, + content_end: u32, + data_start: u32, + data_end: u32, +} + +fn tile_ranges(size: u32, limit: u32) -> Vec { + if size <= limit { + return vec![TileRange { + content_start: 0, + content_end: size, + data_start: 0, + data_end: size, + }]; + } + let gutter = u32::from(limit > 2); + let content_size = (limit - gutter * 2).max(1); + let mut ranges = Vec::new(); + let mut start = 0; + while start < size { + let end = start.saturating_add(content_size).min(size); + ranges.push(TileRange { + content_start: start, + content_end: end, + data_start: start.saturating_sub(gutter), + data_end: end.saturating_add(gutter).min(size), + }); + start = end; + } + ranges +} + +fn tile_vertices(model: &ImageModel, x: TileRange, y: TileRange) -> Vec { + let source: Vec = model + .verts + .iter() + .map(|vertex| ImageVertex { + pos: vertex.pos, + uv: vertex.uv, + pos_low: vertex.pos_low, + }) + .collect(); + if x.content_start == 0 + && x.content_end == model.width + && y.content_start == 0 + && y.content_end == model.height + { + return source; + } + + let bounds = [ + (0, x.content_start as f32 / model.width as f32, true), + (0, x.content_end as f32 / model.width as f32, false), + (1, y.content_start as f32 / model.height as f32, true), + (1, y.content_end as f32 / model.height as f32, false), + ]; + let mut output = Vec::new(); + for triangle in source.chunks_exact(3) { + let mut polygon = triangle.to_vec(); + for (axis, boundary, keep_greater) in bounds { + polygon = clip_vertices(&polygon, axis, boundary, keep_greater); + } + for index in 1..polygon.len().saturating_sub(1) { + output.push(polygon[0]); + output.push(polygon[index]); + output.push(polygon[index + 1]); + } + } + + let data_width = (x.data_end - x.data_start) as f32; + let data_height = (y.data_end - y.data_start) as f32; + for vertex in &mut output { + vertex.uv = [ + ((vertex.uv[0] * model.width as f32 - x.data_start as f32) / data_width) + .clamp(0.0, 1.0), + ((vertex.uv[1] * model.height as f32 - y.data_start as f32) / data_height) + .clamp(0.0, 1.0), + ]; + } + output +} + +fn clip_vertices( + input: &[ImageVertex], + axis: usize, + boundary: f32, + keep_greater: bool, +) -> Vec { + if input.is_empty() { + return Vec::new(); + } + let inside = |vertex: &ImageVertex| { + if keep_greater { + vertex.uv[axis] >= boundary + } else { + vertex.uv[axis] <= boundary + } + }; + let mut output = Vec::new(); + let mut previous = *input.last().unwrap(); + let mut previous_inside = inside(&previous); + for ¤t in input { + let current_inside = inside(¤t); + if current_inside != previous_inside { + output.push(intersect_vertex(previous, current, axis, boundary)); + } + if current_inside { + output.push(current); + } + previous = current; + previous_inside = current_inside; + } + output +} + +fn intersect_vertex( + start: ImageVertex, + end: ImageVertex, + axis: usize, + boundary: f32, +) -> ImageVertex { + let distance = end.uv[axis] - start.uv[axis]; + let t = if distance.abs() > f32::EPSILON { + ((boundary - start.uv[axis]) / distance).clamp(0.0, 1.0) + } else { + 0.0 + }; + ImageVertex { + pos: interpolate3(start.pos, end.pos, t), + uv: [ + start.uv[0] + (end.uv[0] - start.uv[0]) * t, + start.uv[1] + (end.uv[1] - start.uv[1]) * t, + ], + pos_low: interpolate3(start.pos_low, end.pos_low, t), + } +} + +fn interpolate3(start: [f32; 3], end: [f32; 3], t: f32) -> [f32; 3] { + [ + start[0] + (end[0] - start[0]) * t, + start[1] + (end[1] - start[1]) * t, + start[2] + (end[2] - start[2]) * t, + ] +}