From 7f64a21dc2bf1da8436ed0ae859c02f4d88040ed Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 30 Aug 2026 14:52:04 +0200 Subject: [PATCH 1/2] fix(compositor): bound the image texture cache, which never freed anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `img_cache` holds every wallpaper and cursor sprite ever decoded, keyed by path, and had no eviction of any kind in any of the three back-ends. The exposure is not theoretical. The 18 shipped wallpapers are 23.7 MB on disk and **1774 MB decoded to RGBA8** — `wallpaper8.jpg` is 7680x7680, or 225 MB on its own. Browsing the picker loads them all and frees none, and the camera background that just landed doubles the ways in: a scene can now hold a screen wallpaper AND a camera background, with a second picker to browse. LRU eviction under a byte budget. The policy lives in `frame_geometry::lru_evictions`, shared by all three back-ends for the reason that module's own header gives: three copies would drift apart with nothing to say so. **What is protected is the frame's whole active set, not merely the entry just inserted.** A frame samples several textures — screen background, camera background, cursor sprites — and evicting one because another just arrived would make them chase each other every frame. That trades 129 ms of decode (measured, release) against the ~3.5 ms a frame costs, which no memory budget justifies. `begin_image_frame()` opens the frame in `compose_frame`; anything touched after it is untouchable until the next one. If the active set alone exceeds the budget, the policy stays above budget rather than touching it. 512 MB is therefore a floor, not a comfort setting: it has to hold the worst realistic active set, two 225 MB wallpapers, while still bounding the leak. `img_cache_stays_under_budget` proves the wiring end to end, opt-in behind OPENSCREEN_CACHE_DEMO because it needs a real D3D11 device. Loading all 18 wallpapers one frame apart — the picker's own rhythm — the cache grows to 435 MB over 6 entries, first evicts on wallpaper15, and then holds between 392 and 501 MB. Unevicted, the same sequence is 1774 MB. It also asserts the unevicted total EXCEEDS the budget, or it would pass vacuously the day someone ships smaller wallpapers. Also folds the two byte-identical lookup/insert sites into one helper, and corrects three doc comments that still claimed an entry was loaded "once per session" — no longer true once it can be evicted. No decode, upload or pixel behaviour changes. Capping the uploaded resolution would cut per-entry cost roughly eightfold, but it changes output pixels and needs its own justification and measurement. Verified on Windows: 151 Rust tests, tsc clean. Metal and wgpu are reviewed but not compiled here; CI's macOS and Linux `cargo test` are what confirm them. --- crates/compositor/src/compositor_linux.rs | 108 ++++++++---- crates/compositor/src/compositor_macos.rs | 89 +++++++--- crates/compositor/src/compositor_windows.rs | 172 +++++++++++++++++--- crates/compositor/src/frame_geometry.rs | 75 +++++++++ 4 files changed, 371 insertions(+), 73 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 242421cd8..9019d8994 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,52 @@ 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) { + self.img_frame_start.set(self.img_tick.get()); + } + + /// 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 +1204,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 +1738,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 +2473,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 4507586b8..349e86fef 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,50 @@ 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) { + self.img_frame_start.set(self.img_tick.get()); + } + + /// 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 +940,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 +1825,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 +1926,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 af5b65d1b..888a5fc66 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,50 @@ 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) { + self.img_frame_start.set(self.img_tick.get()); + } + + /// 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 +900,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 +1422,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 +1606,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 +2964,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 655b746b7..9d5aad8a3 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, From 2adc423d6b95a9fc5ff746b3253316a9b730f061 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 30 Aug 2026 17:41:32 +0200 Subject: [PATCH 2/2] fix(compositor): the frame marker protected one entry too many MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `begin_image_frame` set `img_frame_start` to the current `img_tick`, but the frame's first access receives `img_tick + 1`, and protection covers `tick >= img_frame_start`. The entry it protected at exactly `img_frame_start` is therefore the LAST one of the previous frame, which is no longer in the active set — so the resident set could sit one whole texture above the budget, up to 225 MB with the wallpapers we ship. Off by one, and the fix is the `+ 1`, on all three back-ends. Caught in review; the demonstration did not catch it because that run never depended on the extra entry being evictable. --- crates/compositor/src/compositor_linux.rs | 6 +++++- crates/compositor/src/compositor_macos.rs | 6 +++++- crates/compositor/src/compositor_windows.rs | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 9019d8994..143cc2346 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1146,7 +1146,11 @@ impl Compositor { /// apres cet appel est le jeu actif, et devient inevincable jusqu'a la /// frame suivante. fn begin_image_frame(&self) { - self.img_frame_start.set(self.img_tick.get()); + // `+ 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. diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 349e86fef..8ea220ec1 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -873,7 +873,11 @@ impl Compositor { /// 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) { - self.img_frame_start.set(self.img_tick.get()); + // `+ 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. diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 888a5fc66..a1a2c13f9 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -844,7 +844,11 @@ impl Compositor { /// 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) { - self.img_frame_start.set(self.img_tick.get()); + // `+ 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.