use crate::core::tasks::{BackgroundExecutor, ForegroundSender};
use crate::gpu::atlas::AtlasTile;
use crate::gpu::text_paint::{GlyphTarget, premultiply_rgba};
use resvg::{tiny_skia, usvg};
use rustc_hash::{FxHashMap, FxHashSet};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, OnceLock};
const ASYNC_DECODE_THRESHOLD: usize = 32 * 1024;
type DecodedImage = (u64, Option<(u32, u32, Vec<u8>)>);
#[derive(Clone)]
pub struct ImageSource {
pub id: u64,
pub bytes: Arc<[u8]>,
}
pub struct SpriteCache {
icons: FxHashMap<(&'static str, u32), Option<AtlasTile>>,
images: FxHashMap<u64, Option<AtlasTile>>,
in_flight: FxHashSet<u64>,
tx: Sender<DecodedImage>,
rx: Receiver<DecodedImage>,
worker: Option<(BackgroundExecutor, ForegroundSender)>,
}
impl Default for SpriteCache {
fn default() -> Self {
let (tx, rx) = mpsc::channel();
Self {
icons: FxHashMap::default(),
images: FxHashMap::default(),
in_flight: FxHashSet::default(),
tx,
rx,
worker: None,
}
}
}
impl SpriteCache {
pub fn set_async_worker(&mut self, executor: BackgroundExecutor, sender: ForegroundSender) {
self.worker = Some((executor, sender));
}
fn flush_decoded(&mut self, target: &mut GlyphTarget) {
while let Ok((id, decoded)) = self.rx.try_recv() {
if !self.in_flight.remove(&id) {
continue;
}
let tile = decoded.and_then(|(width, height, premul)| {
target.polychrome_atlas.insert(target.queue, width, height, &premul)
});
self.images.insert(id, tile);
}
}
pub fn icon(
&mut self,
name: &'static str,
svg: &[u8],
size_px: u32,
target: &mut GlyphTarget,
) -> Option<AtlasTile> {
if let Some(cached) = self.icons.get(&(name, size_px)) {
return *cached;
}
let tile = rasterize_svg(svg, size_px)
.and_then(|(width, height, mask)| target.monochrome_atlas.insert(target.queue, width, height, &mask));
self.icons.insert((name, size_px), tile);
tile
}
pub fn image(&mut self, source: &ImageSource, target: &mut GlyphTarget) -> Option<AtlasTile> {
self.flush_decoded(target);
if let Some(cached) = self.images.get(&source.id) {
return *cached;
}
if self.in_flight.contains(&source.id) {
return None;
}
if source.bytes.len() >= ASYNC_DECODE_THRESHOLD
&& let Some((executor, sender)) = &self.worker
{
self.in_flight.insert(source.id);
let id = source.id;
let bytes = source.bytes.clone();
let tx = self.tx.clone();
let sender = sender.clone();
executor.spawn(move || {
let decoded = decode_image(&bytes).map(|(w, h, rgba)| (w, h, premultiply_rgba(&rgba)));
let _ = tx.send((id, decoded));
sender.send(|entities| {
entities.timers.request_animation_frame();
});
});
return None;
}
let tile = decode_image(&source.bytes).and_then(|(width, height, rgba)| {
target.polychrome_atlas.insert(target.queue, width, height, &premultiply_rgba(&rgba))
});
self.images.insert(source.id, tile);
tile
}
pub fn forget_image(&mut self, id: u64, target: &mut GlyphTarget) {
self.in_flight.remove(&id);
if let Some(Some(tile)) = self.images.remove(&id) {
target.polychrome_atlas.remove(&tile);
}
}
pub fn clear(&mut self) {
self.icons.clear();
self.images.clear();
self.in_flight.clear();
}
}
fn parse_svg(svg: &[u8]) -> Option<usvg::Tree> {
static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
let text = svg.windows(5).any(|window| window == b"<text");
let options = usvg::Options {
fontdb: if text {
FONTS
.get_or_init(|| {
let mut database = usvg::fontdb::Database::new();
database.load_system_fonts();
Arc::new(database)
})
.clone()
} else {
Arc::default()
},
..usvg::Options::default()
};
usvg::Tree::from_data(svg, &options).map_err(|error| eprintln!("SVG inválido: {error}")).ok()
}
fn is_svg(bytes: &[u8]) -> bool {
let start = &bytes[..bytes.len().min(256)];
start.windows(4).any(|window| window == b"<svg")
}
pub fn image_size(bytes: &[u8]) -> Option<(f32, f32)> {
if is_svg(bytes) {
let size = parse_svg(bytes)?.size();
return Some((size.width(), size.height()));
}
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
reader.into_dimensions().ok().map(|(width, height)| (width as f32, height as f32))
}
fn rasterize_svg(svg: &[u8], size_px: u32) -> Option<(u32, u32, Vec<u8>)> {
let tree = parse_svg(svg)?;
let size = tree.size();
let scale = size_px as f32 / size.width().max(size.height()).max(1.0);
let mut pixmap = tiny_skia::Pixmap::new(size_px.max(1), size_px.max(1))?;
resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
let mask = pixmap.data().as_chunks::<4>().0.iter().map(|pixel| pixel[3]).collect();
Some((pixmap.width(), pixmap.height(), mask))
}
const MAX_IMAGE_SIDE: u32 = 1024;
const MAX_SVG_SIDE: f32 = 2046.0;
const MAX_SVG_AREA: f32 = 1_000_000.0;
fn rasterize_svg_color(svg: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
let tree = parse_svg(svg)?;
let size = tree.size();
let (side, area) = (size.width().max(size.height()).max(1.0), (size.width() * size.height()).max(1.0));
let scale = (MAX_SVG_SIDE / side).min((MAX_SVG_AREA / area).sqrt()).min(2.0);
let (width, height) = ((size.width() * scale).ceil() as u32, (size.height() * scale).ceil() as u32);
let mut pixmap = tiny_skia::Pixmap::new(width.max(1), height.max(1))?;
resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
let rgba = pixmap
.pixels()
.iter()
.flat_map(|pixel| {
let color = pixel.demultiply();
[color.red(), color.green(), color.blue(), color.alpha()]
})
.collect();
Some((pixmap.width(), pixmap.height(), rgba))
}
fn decode_image(bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
if is_svg(bytes) {
return rasterize_svg_color(bytes);
}
let image = image::load_from_memory(bytes).map_err(|error| eprintln!("imagen inválida: {error}")).ok()?.to_rgba8();
let (width, height) = image.dimensions();
if width.max(height) > MAX_IMAGE_SIDE {
let scale = MAX_IMAGE_SIDE as f32 / width.max(height) as f32;
let resized = image::imageops::thumbnail(
&image,
(width as f32 * scale).max(1.0) as u32,
(height as f32 * scale).max(1.0) as u32,
);
return Some((resized.width(), resized.height(), resized.into_raw()));
}
Some((width, height, image.into_raw()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screenshot_fits_in_color_atlas() {
let image = image::RgbaImage::from_pixel(1920, 1080, image::Rgba([80, 120, 180, 255]));
let mut encoded = std::io::Cursor::new(Vec::new());
image.write_to(&mut encoded, image::ImageFormat::Png).unwrap();
let source = ImageSource { id: 1, bytes: encoded.into_inner().into() };
let mut mono = crate::gpu::atlas::TextureAtlas::cpu_monochrome();
let mut color = crate::gpu::atlas::TextureAtlas::cpu_polychrome();
let mut target = GlyphTarget { queue: None, monochrome_atlas: &mut mono, polychrome_atlas: &mut color };
let mut cache = SpriteCache::default();
let tile = cache.image(&source, &mut target).expect("la captura debe caber en el atlas");
assert_eq!((tile.width, tile.height), (1024, 576));
assert!(cache.image(&source, &mut target).is_some(), "la caché conserva la miniatura");
assert!(!color.take_overflow());
}
#[test]
fn svg_images_keep_color_and_report_their_size() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#ff0000"/></svg>"##;
assert_eq!(image_size(svg), Some((40.0, 20.0)));
let (width, height, rgba) = decode_image(svg).expect("SVG válido");
assert_eq!((width, height), (80, 40));
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
}
#[test]
fn rasterizes_svg_into_an_alpha_mask() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="5"/></svg>"#;
let (width, height, mask) = rasterize_svg(svg, 20).expect("SVG válido");
assert_eq!((width, height), (20, 20));
assert_eq!(mask[0], 255, "la mitad superior está cubierta");
assert_eq!(mask[mask.len() - 1], 0, "la mitad inferior está vacía");
}
}