diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 242421cd..143cc234 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -51,6 +51,15 @@ use crate::scene::{Scene, SceneBackground}; const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl"); const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); +/// Budget du cache de textures image (`img_cache`), en octets. +/// +/// Doit tenir le JEU ACTIF d'une frame -- au pire un wallpaper d'ecran ET un +/// fond de camera, que rien n'empeche d'etre deux 7680x7680 a 225 Mo piece. +/// Sous ce seuil l'eviction ne peut plus rendre de memoire sans toucher au jeu +/// actif, ce qu'elle refuse de faire. 512 Mo borne la fuite (1 774 Mo mesures +/// en parcourant les 18 wallpapers livres) en laissant le jeu actif resident. +const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; + /// `&LayerCB` -> `&[u8; 128]`. `LayerCB` est `#[repr(C, align(16))]`, son layout /// EST le buffer uniforme WGSL (16 vec4 + 1 vec2 + 2 f32 = 128 octets). fn layer_bytes(cb: &LayerCB) -> &[u8] { @@ -224,8 +233,20 @@ pub struct Compositor { text_raster: Option, /// Cache des sprites curseur (PNG RGBA -> texture wgpu), par chemin. Meme - /// role que `img_cache` cote macOS : un sprite chargé une fois par session. - img_cache: RefCell>, + /// role que `img_cache` cote macOS. Charge une fois, PAS pour la session : l'entree + /// est evincable des qu'elle sort du jeu actif d'une frame, et un retour dessus la + /// rechargera -- cf. `cached_image`. + /// Le quatrieme champ du tuple est le tick d'usage, qui donne l'ordre LRU + /// -- cf. `cached_image`. + img_cache: RefCell>, + /// Compteur d'acces de `img_cache`, pour l'ordre LRU. Un compteur plutot + /// que l'index de frame : une frame touche plusieurs entrees, et il faut + /// pouvoir les ordonner entre elles. + img_tick: std::cell::Cell, + /// Valeur de `img_tick` au debut de la frame en cours. Tout ce qui a ete + /// touche depuis appartient au jeu actif et ne peut pas etre evince -- voir + /// `cached_image`. + img_frame_start: std::cell::Cell, /// Copie mipmappee de la frame composee, lue par les annotations « flou » /// (mode 10). `ann_copy` garde la texture en vie, `ann_copy_view` porte tous @@ -565,6 +586,8 @@ impl Compositor { timeline_time: RefCell::new(None), text_raster: crate::text::TextRasterizer::new().ok(), img_cache: RefCell::new(std::collections::HashMap::new()), + img_tick: std::cell::Cell::new(0), + img_frame_start: std::cell::Cell::new(0), ann_copy, ann_copy_view, ann_copy_mips, @@ -1119,6 +1142,56 @@ impl Compositor { Ok((tex, w, h)) } + /// Ouvre une frame du point de vue d'`img_cache` : tout ce qui sera touche + /// apres cet appel est le jeu actif, et devient inevincable jusqu'a la + /// frame suivante. + fn begin_image_frame(&self) { + // `+ 1` : la premiere entrée de cette frame recevra `img_tick + 1`, et la protection + // porte sur `tick >= img_frame_start`. Sans le decalage on protégerait aussi la + // DERNIERE entrée de la frame precedente, qui n'appartient plus au jeu actif — le + // résident pourrait alors dépasser le budget d'une texture entière. + self.img_frame_start.set(self.img_tick.get() + 1); + } + + /// Texture d'un fichier image, decodee une seule fois puis reutilisee. + /// + /// Le cache etait NON BORNE, et c'est un vrai cout : les wallpapers livres + /// pesent 23,7 Mo sur disque mais 1 774 Mo une fois decodes en RGBA8 -- + /// `wallpaper8.jpg` fait 7680x7680, soit 225 Mo a lui seul. Parcourir le + /// selecteur les chargeait tous et n'en liberait aucun. + /// + /// L'eviction est LRU sous un budget en octets, et ne touche jamais une + /// texture que la frame EN COURS a deja servie : sans ca, un fond d'ecran + /// et un fond de camera un peu gros se chasseraient l'un l'autre a chaque + /// frame, et un decodage coute 129 ms contre les ~3,5 ms d'une frame. Si le + /// jeu actif depasse a lui seul le budget, on depasse le budget. + fn cached_image(&self, path: &str) -> Result<(wgpu::Texture, u32, u32)> { + let tick = self.img_tick.get() + 1; + self.img_tick.set(tick); + // Recherche isolee dans un `let` pour que l'emprunt immuable soit + // relache AVANT le `borrow_mut` (piege du double emprunt 1re frame). + let hit = self.img_cache.borrow().get(path).cloned(); + if let Some((tex, w, h, _)) = hit { + self.img_cache.borrow_mut().insert(path.to_string(), (tex.clone(), w, h, tick)); + return Ok((tex, w, h)); + } + let (tex, w, h) = self.load_image_texture(path)?; + let mut cache = self.img_cache.borrow_mut(); + cache.insert(path.to_string(), (tex.clone(), w, h, tick)); + // La politique vit dans `frame_geometry` : les trois backends la + // partagent, comme la geometrie, plutot que d'entretenir trois copies + // qui finiraient par diverger. + let entries: Vec<(String, u64, u64)> = + cache.iter().map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3)).collect(); + let protect_from = self.img_frame_start.get(); + for key in + crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from) + { + cache.remove(&key); + } + Ok((tex, w, h)) + } + /// Calque image (mode 6) couvrant `dst`, en cover-fit contre `aspect` -- le /// ratio du RECT vise, et non celui de la sortie : le rognage se calcule /// contre la zone qu'on remplit, ce qui permet a la bulle webcam d'emprunter @@ -1135,17 +1208,7 @@ impl Compositor { aspect: f32, dummy: &wgpu::TextureView, ) -> Result { - // Charge (ou recupere du cache) l'image. Emprunt isole AVANT le - // borrow_mut (piege du double emprunt 1re frame, cf. macOS). - let cached = self.img_cache.borrow().get(path).cloned(); - let (tex, iw, ih) = match cached { - Some(v) => v, - None => { - let v = self.load_image_texture(path)?; - self.img_cache.borrow_mut().insert(path.to_string(), v.clone()); - v - } - }; + let (tex, iw, ih) = self.cached_image(path)?; // Cover-fit : l'image remplit tout le rect, on rogne l'axe long. let ai = iw as f32 / ih.max(1) as f32; let src = if ai > aspect { @@ -1679,6 +1742,7 @@ impl Compositor { frame: f32, cfg: &Cfg, ) -> Result<()> { + self.begin_image_frame(); if Self::pixel_buffer_of(screen).is_none() { return self.clear_rt(); } @@ -2413,21 +2477,13 @@ impl Compositor { .as_deref() .and_then(|t| sprites.get(t)) .or_else(|| sprites.get("arrow"))?; - // Charge (ou recupere du cache) le sprite. Emprunt isole AVANT le - // borrow_mut, comme cote macOS (piege du double emprunt 1re frame). - let cached = self.img_cache.borrow().get(sprite.path.as_str()).cloned(); - let (tex, iw, ih) = match cached { - Some(v) => v, - None => match self.load_image_texture(&sprite.path) { - Ok(v) => { - self.img_cache.borrow_mut().insert(sprite.path.clone(), v.clone()); - v - } - Err(e) => { - eprintln!("[curseur] sprite \"{}\" : {e:#}", sprite.path); - return None; - } - }, + // Charge (ou recupere du cache) le sprite. + let (tex, iw, ih) = match self.cached_image(&sprite.path) { + Ok(v) => v, + Err(e) => { + eprintln!("[curseur] sprite \"{}\" : {e:#}", sprite.path); + return None; + } }; // Ratio preserve : le sprite tient dans un carre de `size_px` de cote. let ar = iw as f32 / ih.max(1) as f32; diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 4507586b..8ea220ec 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -42,6 +42,15 @@ use anyhow::{anyhow, Result}; use metal::foreign_types::ForeignType; use std::cell::RefCell; +/// Budget du cache de textures image (`img_cache`), en octets. Même valeur et même raison que +/// `compositor_windows::IMG_CACHE_BUDGET_BYTES`. +/// +/// Doit tenir le JEU ACTIF d'une frame — au pire un wallpaper d'écran ET un fond de caméra, que +/// rien n'empêche d'être deux 7680x7680 à 225 Mo pièce. Sous ce seuil l'éviction ne peut plus +/// rendre de mémoire sans toucher au jeu actif, ce qu'elle refuse de faire. 512 Mo borne la fuite +/// (1 774 Mo mesurés en parcourant les 18 wallpapers livrés) en laissant le jeu actif résident. +const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; + // --------------------------------------------------------------------------- // CVMetalTextureCache — le pont CVPixelBuffer → MTLTexture // --------------------------------------------------------------------------- @@ -262,8 +271,15 @@ pub struct Compositor { last_cmd: RefCell>, /// Wallpapers décodés, indexés par chemin (ou par data-URI pour les annotations image). /// Le décode + upload coûte des millisecondes ; le faire à chaque frame ferait chuter la - /// preview sur un fond image. - img_cache: RefCell>, + /// preview sur un fond image. L'entrée reste néanmoins évinçable dès qu'elle sort du jeu + /// actif d'une frame — cf. `cached_image`. + img_cache: RefCell>, + /// Compteur d'accès de `img_cache`, pour l'ordre LRU. Un compteur plutôt que l'index de + /// frame : une frame touche plusieurs entrées, et il faut pouvoir les ordonner entre elles. + img_tick: std::cell::Cell, + /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis + /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. + img_frame_start: std::cell::Cell, // --- Engine : render targets --- /// Render target principal RGBA8. Cible de `compose_frame`. `Private` : c'est une @@ -599,6 +615,8 @@ impl Compositor { metal_texture_cache: cache, last_cmd: RefCell::new(None), img_cache: RefCell::new(std::collections::HashMap::new()), + img_tick: std::cell::Cell::new(0), + img_frame_start: std::cell::Cell::new(0), rt, rt_read, nv12_y, @@ -852,6 +870,54 @@ impl Compositor { Ok((tex, w, h)) } + /// Ouvre une frame du point de vue de `img_cache` : tout ce qui sera touché après cet appel + /// est le jeu actif, et devient inévinçable jusqu'à la frame suivante. + fn begin_image_frame(&self) { + // `+ 1` : la première entrée de cette frame recevra `img_tick + 1`, et la protection + // porte sur `tick >= img_frame_start`. Sans le décalage on protégerait aussi la + // DERNIÈRE entrée de la frame précédente, qui n'appartient plus au jeu actif — le + // résident pourrait alors dépasser le budget d'une texture entière. + self.img_frame_start.set(self.img_tick.get() + 1); + } + + /// Texture d'un fichier image, décodée une seule fois puis réutilisée. + /// + /// Le cache était NON BORNÉ, et c'est un vrai coût : les wallpapers livrés pèsent 23,7 Mo sur + /// disque mais 1 774 Mo une fois décodés en RGBA8 — `wallpaper8.jpg` fait 7680x7680, soit + /// 225 Mo à lui seul. Parcourir le sélecteur les chargeait tous et n'en libérait aucun. + /// + /// L'éviction est LRU sous un budget en octets, et ne touche jamais une texture que la frame + /// EN COURS a déjà servie : sans ça, un fond d'écran et un fond de caméra un peu gros se + /// chasseraient l'un l'autre à chaque frame, et un décodage coûte 129 ms contre les ~3,5 ms + /// d'une frame. Si le jeu actif dépasse à lui seul le budget, on dépasse le budget. + fn cached_image(&self, path: &str) -> Result<(metal::Texture, u32, u32)> { + let tick = self.img_tick.get() + 1; + self.img_tick.set(tick); + // Emprunt isolé dans un `let` pour qu'il soit relâché AVANT le `borrow_mut` — + // même piège que côté Windows (double emprunt RefCell à la première frame image). + let hit = self.img_cache.borrow().get(path).cloned(); + if let Some((tex, w, h, _)) = hit { + self.img_cache.borrow_mut().insert(path.to_string(), (tex.clone(), w, h, tick)); + return Ok((tex, w, h)); + } + let (tex, w, h) = self.load_image_texture(path)?; + let mut cache = self.img_cache.borrow_mut(); + cache.insert(path.to_string(), (tex.clone(), w, h, tick)); + // La politique vit dans `frame_geometry` : les trois backends la partagent, comme la + // géométrie, plutôt que d'entretenir trois copies qui finiraient par diverger. + let entries: Vec<(String, u64, u64)> = cache + .iter() + .map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3)) + .collect(); + let protect_from = self.img_frame_start.get(); + for key in + crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from) + { + cache.remove(&key); + } + Ok((tex, w, h)) + } + /// Fond wallpaper image, cover-fit sur le ratio de SORTIE (mode 6). /// /// Le crop de recouvrement se calcule contre le vrai ratio de sortie, pas contre celui @@ -878,17 +944,7 @@ impl Compositor { radius_px: f32, output_aspect: f32, ) -> Result<()> { - // Emprunt isolé dans un `let` pour qu'il soit relâché AVANT le `borrow_mut` — - // même piège que côté Windows (double emprunt RefCell à la première frame image). - let cached = self.img_cache.borrow().get(path).cloned(); - let (tex, iw, ih) = match cached { - Some(v) => v, - None => { - let loaded = self.load_image_texture(path)?; - self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); - loaded - } - }; + let (tex, iw, ih) = self.cached_image(path)?; let ai = iw as f32 / ih.max(1) as f32; let ao = output_aspect; let (u0, v0, u1, v1) = if ai > ao { @@ -1773,15 +1829,7 @@ impl Compositor { sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], ) -> Result<()> { - let cached = self.img_cache.borrow().get(sprite.path.as_str()).cloned(); - let (tex, iw, ih) = match cached { - Some(v) => v, - None => { - let loaded = self.load_image_texture(&sprite.path)?; - self.img_cache.borrow_mut().insert(sprite.path.clone(), loaded.clone()); - loaded - } - }; + let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; let (rw, rh) = (self.render_w as f32, self.render_h as f32); let ar = iw as f32 / ih.max(1) as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; @@ -1882,6 +1930,7 @@ impl Compositor { frame: f32, cfg: &Cfg, ) -> Result<()> { + self.begin_image_frame(); if Self::pixel_buffer_of(screen).is_none() { return self.clear_rt(); } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index af5b65d1..a1a2c13f 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -31,6 +31,14 @@ use windows::Win32::Graphics::Direct3D::{ use windows::Win32::Graphics::Direct3D11::*; use windows::Win32::Graphics::Dxgi::Common::*; +/// Budget du cache de textures image (`img_cache`), en octets. +/// +/// Doit tenir le JEU ACTIF d'une frame — au pire un wallpaper d'écran ET un fond de caméra, que +/// rien n'empêche d'être deux 7680x7680 à 225 Mo pièce. Sous ce seuil l'éviction ne peut plus +/// rendre de mémoire sans toucher au jeu actif, ce qu'elle refuse de faire. 512 Mo borne la fuite +/// (1 774 Mo mesurés en parcourant les 18 wallpapers livrés) en laissant le jeu actif résident. +const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; + @@ -143,9 +151,18 @@ pub struct Compositor { /// de la source). Séparé de `img_cache` : les wallpapers sont des chemins disque, ces images /// des data URL de plusieurs Mo qu'on ne veut pas utiliser comme clés de hachage. ann_img_cache: RefCell>, - /// Cache des textures wallpaper image (clé = chemin absolu) : décodage/upload une seule - /// fois, puis réutilisées par frame. (SRV, largeur, hauteur). - img_cache: RefCell>, + /// Cache des textures wallpaper image (clé = chemin absolu) : décodé et uploadé une fois, + /// puis réutilisé par frame. (SRV, largeur, hauteur, tick d'usage). + /// + /// « Une fois » et non « une fois pour la session » : l'entrée est évinçable dès qu'elle + /// sort du jeu actif d'une frame, et un retour dessus la rechargera — cf. `cached_image`. + img_cache: RefCell>, + /// Compteur d'accès de `img_cache`, pour l'ordre LRU. Un compteur plutôt que l'index de + /// frame : une frame touche plusieurs entrées, et il faut pouvoir les ordonner entre elles. + img_tick: std::cell::Cell, + /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis + /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. + img_frame_start: std::cell::Cell, /// Masque de segmentation du sujet webcam, R8 à la résolution du modèle. Écrit par /// `set_webcam_mask` depuis le thread d'inférence, lu au moment de dessiner la webcam. /// `None` tant qu'aucune frame n'a été segmentée — l'effet reste alors éteint plutôt que @@ -602,6 +619,8 @@ impl Compositor { text_cache: RefCell::new(HashMap::new()), ann_img_cache: RefCell::new(HashMap::new()), img_cache: RefCell::new(HashMap::new()), + img_tick: std::cell::Cell::new(0), + img_frame_start: std::cell::Cell::new(0), webcam_mask: RefCell::new(None), render_size: Cell::new((out_w, out_h)), resize_target: RefCell::new(None), @@ -822,6 +841,54 @@ impl Compositor { /// Fond wallpaper image (cover-fit). `path` = chemin absolu (résolu côté app). Décodé et /// uploadé une fois (cache), puis échantillonné en mode 6. Err → l'appelant retombe sur une /// couleur plate. Le rect uv `src` recouvre toute la sortie en rognant le débordement. + /// Ouvre une frame du point de vue de `img_cache` : tout ce qui sera touché après cet appel + /// est le jeu actif, et devient inévinçable jusqu'à la frame suivante. + fn begin_image_frame(&self) { + // `+ 1` : la première entrée de cette frame recevra `img_tick + 1`, et la protection + // porte sur `tick >= img_frame_start`. Sans le décalage on protégerait aussi la + // DERNIÈRE entrée de la frame précédente, qui n'appartient plus au jeu actif — le + // résident pourrait alors dépasser le budget d'une texture entière. + self.img_frame_start.set(self.img_tick.get() + 1); + } + + /// Texture d'un fichier image, décodée une seule fois puis réutilisée. + /// + /// Le cache était NON BORNÉ, et c'est un vrai coût : les wallpapers livrés pèsent 23,7 Mo sur + /// disque mais 1 774 Mo une fois décodés en RGBA8 — `wallpaper8.jpg` fait 7680x7680, soit + /// 225 Mo à lui seul. Parcourir le sélecteur les chargeait tous et n'en libérait aucun. + /// + /// L'éviction est LRU sous un budget en octets, et ne touche jamais une texture que la frame + /// EN COURS a déjà servie : sans ça, un fond d'écran et un fond de caméra un peu gros se + /// chasseraient l'un l'autre à chaque frame, et un décodage coûte 129 ms contre les ~3,5 ms + /// d'une frame. Si le jeu actif dépasse à lui seul le budget, on dépasse le budget. + unsafe fn cached_image(&self, path: &str) -> Result<(ID3D11ShaderResourceView, u32, u32)> { + let tick = self.img_tick.get() + 1; + self.img_tick.set(tick); + // La recherche est isolée dans un `let` pour que l'emprunt immuable soit relâché AVANT le + // `borrow_mut()` (sinon double-emprunt RefCell → panic sur la 1re frame image). + let hit = self.img_cache.borrow().get(path).cloned(); + if let Some((srv, w, h, _)) = hit { + self.img_cache.borrow_mut().insert(path.to_string(), (srv.clone(), w, h, tick)); + return Ok((srv, w, h)); + } + let (srv, w, h) = self.load_image_srv(path)?; + let mut cache = self.img_cache.borrow_mut(); + cache.insert(path.to_string(), (srv.clone(), w, h, tick)); + // La politique vit dans `frame_geometry` : les trois backends la partagent, comme la + // géométrie, plutôt que d'entretenir trois copies qui finiraient par diverger. + let entries: Vec<(String, u64, u64)> = cache + .iter() + .map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3)) + .collect(); + let protect_from = self.img_frame_start.get(); + for key in + crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from) + { + cache.remove(&key); + } + Ok((srv, w, h)) + } + unsafe fn draw_image_bg(&self, path: &str, output_aspect: f32) -> Result<()> { self.draw_image_in(path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect) } @@ -837,17 +904,7 @@ impl Compositor { radius_px: f32, output_aspect: f32, ) -> Result<()> { - // NB : la recherche est isolée dans un `let` pour que l'emprunt immuable soit relâché - // AVANT le `borrow_mut()` (sinon double-emprunt RefCell → panic sur la 1re frame image). - let cached = self.img_cache.borrow().get(path).cloned(); - let (srv, iw, ih) = match cached { - Some(v) => v, - None => { - let loaded = self.load_image_srv(path)?; - self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); - loaded - } - }; + let (srv, iw, ih) = self.cached_image(path)?; let ai = iw as f32 / ih as f32; // Le fond remplit TOUJOURS le cadre (dst=[0,0,1,1], jamais rétréci par `undistort`), // mais le canvas interne est un 16:9 fixe étiré ensuite vers le VRAI ratio de sortie @@ -1369,15 +1426,7 @@ impl Compositor { clip: [f32; 4], ) -> Result<()> { let path = sprite.path.as_str(); - let cached = self.img_cache.borrow().get(path).cloned(); - let (srv, iw, ih) = match cached { - Some(v) => v, - None => { - let loaded = self.load_image_srv(path)?; - self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); - loaded - } - }; + let (srv, iw, ih) = self.cached_image(path)?; let ar = iw as f32 / ih as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; @@ -1561,6 +1610,7 @@ impl Compositor { frame: f32, cfg: &Cfg, ) -> Result<()> { + self.begin_image_frame(); let (sy, suv) = self.nv12_srvs(screen)?; let (wy, wuv) = self.nv12_srvs(webcam)?; let (stw, sth) = self.tex_dims(screen); @@ -2918,6 +2968,86 @@ impl Compositor { mod tests { use super::*; + /// Preuve de bout en bout que `img_cache` est borné : charge TOUS les wallpapers livrés, + /// une frame par wallpaper — ce que fait le sélecteur quand on le parcourt — et vérifie que + /// le total reste sous le budget. + /// + /// Opt-in : il crée un vrai device D3D11, ce qu'aucun autre test de ce fichier ne fait (celui + /// juste en dessous s'en passe volontairement) et qu'un runner sans adaptateur ne peut pas + /// fournir. Même convention que le harnais visuel de la segmentation : + /// + /// set OPENSCREEN_CACHE_DEMO=1 && cargo test -p openscreen-compositor --release + /// img_cache_stays_under_budget -- --nocapture + /// + /// Les tests de `lru_evictions` couvrent la POLITIQUE ; celui-ci couvre le CÂBLAGE — que le + /// backend l'appelle vraiment, sur les bonnes tailles, et que le budget morde sur nos assets. + #[test] + fn img_cache_stays_under_budget() { + if std::env::var_os("OPENSCREEN_CACHE_DEMO").is_none() { + eprintln!("OPENSCREEN_CACHE_DEMO absent — saute (ce test demande un device D3D11)"); + return; + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("racine du dépôt") + .join("public/wallpapers"); + let mut papers: Vec<_> = std::fs::read_dir(&root) + .expect("public/wallpapers") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + matches!(p.extension().and_then(|e| e.to_str()), Some("jpg" | "jpeg" | "png")) + }) + .collect(); + papers.sort(); + assert!(papers.len() >= 10, "il faut plusieurs wallpapers pour que le budget morde"); + + let gpu = crate::d3d::Gpu::create_backend(crate::d3d::Backend::Hardware, false) + .expect("device D3D11"); + let comp = Compositor::new(&gpu).expect("compositeur"); + + let mut cumule = 0u64; + let mut pic = 0u64; + for path in &papers { + // Une frame par wallpaper : c'est le rythme du sélecteur, et c'est ce qui rend + // l'entrée précédente évinçable. Dans une même frame elle ne le serait pas. + comp.begin_image_frame(); + let p = path.to_string_lossy().to_string(); + let (_, w, h) = unsafe { comp.cached_image(&p) }.expect("chargement"); + cumule += w as u64 * h as u64 * 4; + let cache = comp.img_cache.borrow(); + let total: u64 = cache.values().map(|e| e.1 as u64 * e.2 as u64 * 4).sum(); + pic = pic.max(total); + eprintln!( + " {:<20} {:>5}x{:<5} | cache {:>2} entrées {:>4} Mo | cumulé sans éviction {:>5} Mo", + path.file_name().unwrap().to_string_lossy(), + w, + h, + cache.len(), + total / 1048576, + cumule / 1048576, + ); + } + eprintln!( + " + budget {} Mo | pic observé {} Mo | cumulé si rien n'était évincé {} Mo", + IMG_CACHE_BUDGET_BYTES / 1048576, + pic / 1048576, + cumule / 1048576, + ); + assert!( + pic <= IMG_CACHE_BUDGET_BYTES, + "le cache a dépassé son budget : {} Mo > {} Mo", + pic / 1048576, + IMG_CACHE_BUDGET_BYTES / 1048576 + ); + assert!( + cumule > IMG_CACHE_BUDGET_BYTES, + "sans éviction le total ({} Mo) doit dépasser le budget, sinon le test ne prouve rien", + cumule / 1048576 + ); + } + /// Le HLSL est compilé au démarrage du compositeur : jusqu'ici une faute dedans ne se voyait /// qu'à l'exécution, donc après un rebuild du natif ET un relancement de l'app. `D3DCompile` diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 655b746b..9d5aad8a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1302,8 +1302,83 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Vec { + let mut total: u64 = entries.iter().map(|(_, bytes, _)| *bytes).sum(); + if total <= budget { + return Vec::new(); + } + let mut candidates: Vec<&(String, u64, u64)> = + entries.iter().filter(|(_, _, tick)| *tick < protect_from).collect(); + candidates.sort_by_key(|(_, _, tick)| *tick); + let mut out = Vec::new(); + for (key, bytes, _) in candidates { + if total <= budget { + break; + } + total -= bytes; + out.push(key.clone()); + } + out +} + #[cfg(test)] mod tests { + use super::lru_evictions; + + /// `(clé, octets, tick)` — le tick croît avec l'usage, donc le plus petit est le plus ancien. + fn e(key: &str, mb: u64, tick: u64) -> (String, u64, u64) { + (key.to_string(), mb * 1024 * 1024, tick) + } + + const BUDGET: u64 = 512 * 1024 * 1024; + + #[test] + fn evicts_nothing_while_under_budget() { + assert!(lru_evictions(&[e("a", 100, 1), e("b", 100, 2)], BUDGET, 2).is_empty()); + } + + /// La plus ancienne part d'abord, et on s'arrête DÈS qu'on repasse sous le budget : évincer + /// au-delà ne rendrait que des rechargements. + #[test] + fn evicts_oldest_first_and_stops_at_the_budget() { + let entries = [e("vieux", 100, 1), e("moyen", 100, 2), e("neuf", 100, 9)]; + assert_eq!(lru_evictions(&entries, 250 * 1024 * 1024, 9), vec!["vieux".to_string()]); + } + + /// TOUT le jeu actif de la frame est protégé, pas seulement la dernière entrée posée. Une + /// frame qui échantillonne un fond d'écran ET un fond de caméra ne doit pas voir le premier + /// évincé parce que le second vient d'arriver — sinon les deux se chassent l'un l'autre à + /// chaque frame. + #[test] + fn protects_every_texture_used_this_frame() { + // frame commencée au tick 5 : `ecran` et `camera` servent tous deux maintenant. + let entries = [e("vieux", 100, 2), e("ecran", 400, 5), e("camera", 400, 6)]; + assert_eq!(lru_evictions(&entries, BUDGET, 5), vec!["vieux".to_string()]); + } + + /// Jeu actif plus gros que le budget : on rend ce qu'on peut et on reste au-dessus, plutôt que + /// de faire disparaître des textures dont cette frame a besoin. + #[test] + fn gives_up_rather_than_evicting_the_active_set() { + let entries = [e("a", 100, 1), e("actif", 900, 5)]; + assert_eq!(lru_evictions(&entries, 256 * 1024 * 1024, 5), vec!["a".to_string()]); + } + use super::*; /// La scène de référence du golden : un cas qui exerce le padding, le crop, le zoom,