perf(raster): avoid needless image resampling

This commit is contained in:
Hakan Seven committed 2026-08-16 21:22:58 +03:00
1 parent 32c3844526
commit 36e0e40b36
2 files changed
+293 -151

No files matched your search

+17 -30
View File
@@ -360,8 +360,10 @@ pub struct DecodedImage {
pub height: u32,
}
fn image_cache() -> &'static Mutex<HashMap<String, Option<DecodedImage>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<DecodedImage>>>> = OnceLock::new();
type ImageCacheEntry = Arc<OnceLock<Option<DecodedImage>>>;
fn image_cache() -> &'static Mutex<HashMap<String, ImageCacheEntry>> {
static CACHE: OnceLock<Mutex<HashMap<String, ImageCacheEntry>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
@@ -385,22 +387,20 @@ pub fn resolve_image(path: &str) -> Option<DecodedImage> {
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<DecodedImage> {
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<DecodedImage> {
} 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()),
+276 -121
View File
@@ -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<wgpu::Buffer>,
/// 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<wgpu::Sampler>,
_params_buf: Arc<wgpu::Buffer>,
}
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<Self> {
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<wgpu::Sampler>,
) -> Vec<Self> {
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(&params),
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<ImageVertex> = 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(&params),
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(&params_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<TileRange> {
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<ImageVertex> {
let source: Vec<ImageVertex> = 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<ImageVertex> {
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 &current in input {
let current_inside = inside(&current);
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,
]
}