From 4cd27613bcde8253294185b965e0591013623b46 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 2 Oct 2026 23:13:17 +0200 Subject: [PATCH 1/3] perf(compositor): bring the background cache and the trail scissor to Windows and macOS The two Linux optimizations of #982, ported. A static background is drawn once and copied into the render target while its BackgroundKey matches (CopyResource on D3D11, a blit on Metal); the key is compared field by field and only built when a new background is kept, never for an animated one or an image that failed to load. The cursor trail's composite is scissored to the union of the quads drawn into accum (RSSetScissorRects through a scissor rasterizer state, set_scissor_rect), which the cursor draws now return. --- crates/compositor/src/compositor_macos.rs | 235 +++++++++++------ crates/compositor/src/compositor_windows.rs | 277 +++++++++++++------- 2 files changed, 348 insertions(+), 164 deletions(-) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index f659fdde0..22cfc9fdb 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -326,6 +326,9 @@ pub struct Compositor { /// La passe en cours a été ouverte sur `pipeline_add` (cf. `begin_pass`) : dit à /// `draw_layer` laquelle des deux variantes « modèles » prendre. layer_add: std::cell::Cell, + /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant + /// que la clé tient, au lieu d'être redessiné. + bg_cache: RefCell>, /// Buffer d'accumulation ISOLÉ (transparent) pour la traînée. Accumuler directement sur /// le RT reviendrait à AJOUTER du blanc à ce qui est déjà dessous : sur un fond clair, /// le curseur disparaît. Même raisonnement que côté D3D11. @@ -553,6 +556,12 @@ fn msl_source_with_models() -> String { format!("#define LAYER_MODELS 1\n{}", include_str!("shaders.metal")) } +/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit. +struct BgCache { + key: crate::frame_geometry::BackgroundKey, + tex: metal::Texture, +} + /// Un pipeline state à une seule pièce jointe couleur. fn make_pipeline( device: &metal::Device, @@ -742,6 +751,7 @@ impl Compositor { pipeline_main_models, pipeline_add_models, layer_add: std::cell::Cell::new(false), + bg_cache: RefCell::new(None), accum, trail, blur_half, @@ -800,6 +810,7 @@ impl Compositor { blur_quarter, blur_eighth, ann_copy, + bg_cache: RefCell::new(None), ..self }) } @@ -1469,6 +1480,20 @@ impl Compositor { } + /// Le fond gardé (`bg_cache`) : copie le RT dans `tex`, ou `tex` dans le RT (`into_rt`). + unsafe fn blit_background(&self, cmd: &metal::CommandBufferRef, tex: &metal::Texture, into_rt: bool) { + let (src, dst) = if into_rt { (tex, &self.rt) } else { (&self.rt, tex) }; + let blit = cmd.new_blit_command_encoder(); + blit.copy_from_texture( + src, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 }, + dst, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + blit.end_encoding(); + } + /// Fige l'image composée dans `ann_copy` (mip 0), qu'y lisent les flous (mips dérivés, /// `mips`) et le cristal de Prism Glow. unsafe fn snapshot_rt(&self, cmd: &metal::CommandBufferRef, mips: bool) { @@ -2143,21 +2168,22 @@ impl Compositor { sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], model: Option, - ) -> Result<()> { + ) -> Result> { let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; // Sans champ de distance, repli sur le sprite plat plutôt qu'aucun curseur. Parité Linux. if let Some(pose) = model { match self.cursor_sdf(sprite.path.as_str()) { Ok((sdf, shape)) => { let shape = crate::frame_geometry::model_shape(sprite, shape); - if let Some(cb) = crate::frame_geometry::cursor_model_cb( + let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, - ) { - enc.set_fragment_texture(2, Some(&tex)); - enc.set_fragment_texture(4, Some(&sdf)); - self.draw_solid(enc, &cb); - } - return Ok(()); + ) else { + return Ok(None); + }; + enc.set_fragment_texture(2, Some(&tex)); + enc.set_fragment_texture(4, Some(&sdf)); + self.draw_solid(enc, &cb); + return Ok(Some(cb.dst)); } Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), } @@ -2175,7 +2201,7 @@ impl Compositor { ); enc.set_fragment_texture(2, Some(&tex)); self.draw_solid(enc, &cb); - Ok(()) + Ok(Some(cb.dst)) } /// Curseur thématisé : le sprite de l'état courant, sinon la flèche, sinon rien. @@ -2198,14 +2224,12 @@ impl Compositor { a: f32, clip: [f32; 4], model: Option, - ) { - let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); - if let Some(sprite) = sprite { - if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model) - { - eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); - } - } + ) -> Option<[f32; 4]> { + let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow"))?; + self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model).unwrap_or_else(|e| { + eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); + None + }) } /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). @@ -2292,46 +2316,75 @@ impl Compositor { } else { None }; - let enc = self.begin_pass( - cmd_buf, - &self.rt, - Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), - &self.pipeline_main, - )?; - // Les deux plans écran restent liés par défaut : les quads de couleur ne les - // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux, - // le font. - enc.set_fragment_texture(0, Some(&sy)); - enc.set_fragment_texture(1, Some(&suv)); - - // --- fond --- (parité `compositor_windows.rs`, section « fond ») - match scene_ref.as_ref().map(|s| s.background.clone()) { - Some(SceneBackground::Color { color }) => { - let c = parse_hex(&color).unwrap_or(lp.bg_color); - self.draw_solid( - enc, - &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, - ); - } - Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => { - let a = angle_deg.to_radians(); - let (anim, mb) = - crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh); - self.draw_solid( - enc, - &LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - fx: [a.sin(), -a.cos(), anim[0], anim[1]], - mb, - ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) - }, - ); + // Le fond de la frame d'avant, flou compris, s'il est celui qu'on demande + // (`BackgroundKey`) : recopié au lieu d'être redessiné. + let bg_desc = scene_ref.as_ref().map(|s| &s.background); + let bg_blur = scene_ref.as_ref().map_or(0.0, |s| s.effects.blur); + let bg_cached = self + .bg_cache + .borrow() + .as_ref() + .is_some_and(|c| c.key.matches(bg_desc, bg_blur, lp.bg_color, [rw, rh])); + if bg_cached { + if let Some(c) = self.bg_cache.borrow().as_ref() { + self.blit_background(cmd_buf, &c.tex, true); } - Some(SceneBackground::Image { path, motion }) => { - // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait - // un chemin cassé. - if let Err(e) = self.draw_image_bg(enc, &path, rw / rh, motion, g.programme_t) { - eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}"); + } else { + let enc = self.begin_pass( + cmd_buf, + &self.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + &self.pipeline_main, + )?; + // Les deux plans écran restent liés par défaut : les quads de couleur ne les + // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux, + // le font. + enc.set_fragment_texture(0, Some(&sy)); + enc.set_fragment_texture(1, Some(&suv)); + + // --- fond --- (parité `compositor_windows.rs`, section « fond ») + // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé. + let mut bg_drawn = true; + match scene_ref.as_ref().map(|s| s.background.clone()) { + Some(SceneBackground::Color { color }) => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); + self.draw_solid( + enc, + &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, + ); + } + Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => { + let a = angle_deg.to_radians(); + let (anim, mb) = + crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh); + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + fx: [a.sin(), -a.cos(), anim[0], anim[1]], + mb, + ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) + }, + ); + } + Some(SceneBackground::Image { path, motion }) => { + // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait + // un chemin cassé. + if let Err(e) = self.draw_image_bg(enc, &path, rw / rh, motion, g.programme_t) { + eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}"); + bg_drawn = false; + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }, + ); + } + } + None => { self.draw_solid( enc, &LayerCB { @@ -2343,26 +2396,31 @@ impl Compositor { ); } } - None => { - self.draw_solid( - enc, - &LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - mode: 1.0, - color: lp.bg_color, - ..Default::default() - }, + + // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient + // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un + // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. + enc.end_encoding(); + if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) { + self.blur_bg(cmd_buf, blur)?; + } + // Pas de cache pour un fond animé (`of` rend `None`). + let key = bg_drawn + .then(|| crate::frame_geometry::BackgroundKey::of(bg_desc, bg_blur, lp.bg_color, [rw, rh])) + .flatten(); + if let Some(key) = key { + let tex = make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + self.render_w, + self.render_h, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::ShaderRead, ); + self.blit_background(cmd_buf, &tex, false); + *self.bg_cache.borrow_mut() = Some(BgCache { key, tex }); } } - - // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient - // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un - // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. - enc.end_encoding(); - if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) { - self.blur_bg(cmd_buf, blur)?; - } // --- écran : ombre puis vidéo --- let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; // Géométrie du tilt calculée UNE fois : l'ombre et l'écran doivent porter exactement @@ -2543,11 +2601,14 @@ impl Compositor { if plan.glass { e.set_fragment_texture(5, Some(&self.ann_copy)); } + // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la + // sortie) : la seule région que la recopie doit relire. + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; for k in 0..plan.taps { let f = k as f32 / (plan.taps - 1) as f32; let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps); e.set_blend_color(w, w, w, w); - self.draw_cur_themed( + let drawn = self.draw_cur_themed( e, &sprites, kind, @@ -2557,12 +2618,36 @@ impl Compositor { plan.clip, plan.model, ); + if let Some(d) = drawn { + bounds = [ + bounds[0].min(d[0]), + bounds[1].min(d[1]), + bounds[2].max(d[0] + d[2]), + bounds[3].max(d[1] + d[3]), + ]; + } } e.end_encoding(); let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?; c.set_fragment_texture(0, Some(&self.accum)); - c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie + // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p + // mesurée sur Linux, Radeon 610M). + let (w, h) = (self.render_w as f32, self.render_h as f32); + let x0 = (bounds[0] * w).floor().clamp(0.0, w) as u64; + let y0 = (bounds[1] * h).floor().clamp(0.0, h) as u64; + let x1 = (bounds[2] * w).ceil().clamp(0.0, w) as u64; + let y1 = (bounds[3] * h).ceil().clamp(0.0, h) as u64; + if x1 > x0 && y1 > y0 { + c.set_scissor_rect(metal::MTLScissorRect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + }); + c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + } c.end_encoding(); } } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 8b7b820c1..f9be386bd 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -24,6 +24,7 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::ffi::c_void; use windows::core::Interface; +use windows::Win32::Foundation::RECT; use windows::Win32::Graphics::Direct3D::{ D3D11_SRV_DIMENSION_TEXTURE2D, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, }; @@ -78,6 +79,12 @@ struct WebcamMask { /// demi-résolution de la texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture /// décodeur et remplie en UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du /// mode 8 ne change pas. +/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit. +struct BgCache { + key: crate::frame_geometry::BackgroundKey, + tex: ID3D11Texture2D, +} + struct DofPyramid { rtv: ID3D11RenderTargetView, srv: ID3D11ShaderResourceView, @@ -132,11 +139,16 @@ pub struct Compositor { accum_rtv: ID3D11RenderTargetView, accum_srv: ID3D11ShaderResourceView, blend_add: ID3D11BlendState, + /// Rastérisation avec scissor, pour la seule recopie de la traînée du curseur. + rs_scissor: ID3D11RasterizerState, /// Rendu isolé de l'écran cadré (ombre, cadre, métrage, appareil), transparent autour, que le /// mode 18 recompose le long de sa trajectoire (`FrameGeometry::screen_trail`). Distinct de /// `accum`, que le curseur et `compose_frame_mb` remplissent dans la même frame. trail_rtv: ID3D11RenderTargetView, trail_srv: ID3D11ShaderResourceView, + /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant + /// que la clé tient, au lieu d'être redessiné. + bg_cache: RefCell>, /// RefCell (pas un simple champ) pour que `set_cursor` reste `&self`, comme `set_scene` / /// `set_live_params` — nécessaire pour le rebrancher par clip dans l'export multiclip, qui /// n'a qu'une référence partagée au `Compositor`. @@ -411,6 +423,7 @@ impl Compositor { render_size: Cell::new((w, h)), // Caches dimensionnés à l'ancienne taille : recréés à la demande. resize_target: RefCell::new(None), + bg_cache: RefCell::new(None), live_readback_staging: RefCell::new(None), nv12_readback_staging: RefCell::new(None), ..self @@ -689,6 +702,17 @@ impl Compositor { }; let mut blend_add: Option = None; dev.CreateBlendState(&bla, Some(&mut blend_add))?; + // L'état par défaut (aucun lié), plus le scissor. Sans culling : le triangle plein écran + // n'a pas à dépendre de son sens de parcours. + let rsd = D3D11_RASTERIZER_DESC { + FillMode: D3D11_FILL_SOLID, + CullMode: D3D11_CULL_NONE, + ScissorEnable: true.into(), + DepthClipEnable: true.into(), + ..Default::default() + }; + let mut rs_scissor: Option = None; + dev.CreateRasterizerState(&rsd, Some(&mut rs_scissor))?; Ok(Compositor { dev, @@ -728,8 +752,10 @@ impl Compositor { accum_rtv, accum_srv, blend_add: blend_add.unwrap(), + rs_scissor: rs_scissor.unwrap(), trail_rtv, trail_srv, + bg_cache: RefCell::new(None), cursor: RefCell::new(None), cursor_t_override: RefCell::new(None), footage: std::cell::Cell::new(None), @@ -945,6 +971,19 @@ impl Compositor { self.ctx.ClearRenderTargetView(&self.rtv, &clear); } + /// Garde le fond que le RT porte à cet instant (`bg_cache`), sous `key`. + unsafe fn keep_background(&self, key: crate::frame_geometry::BackgroundKey) -> Result<()> { + let mut d = D3D11_TEXTURE2D_DESC::default(); + self.rt.GetDesc(&mut d); + d.BindFlags = 0; + let mut tex: Option = None; + self.dev.CreateTexture2D(&d, None, Some(&mut tex))?; + let tex = tex.ok_or_else(|| anyhow::anyhow!("CreateTexture2D (fond gardé)"))?; + self.ctx.CopyResource(&tex, &self.rt); + *self.bg_cache.borrow_mut() = Some(BgCache { key, tex }); + Ok(()) + } + /// Passe plein écran générique (triangle unique) : `srv` -> `rtv` via `ps`, avec `fx`. unsafe fn fs_pass( &self, @@ -1738,7 +1777,7 @@ impl Compositor { /// Curseur custom (dot+ring) centré en `center` (0..1 sortie), taille `size_px`, opacité `a`. /// `clip` = rect "Clip to canvas" en espace sortie [x,y,w,h] ; passer un rect englobant tout /// (ex. [-1,-1,3,3]) pour désactiver l'effet. - unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) { + unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) -> [f32; 4] { let w = size_px / self.rw(); let h = size_px / self.rh(); let dst = [center[0] - w * 0.5, center[1] - h * 0.5, w, h]; @@ -1750,6 +1789,7 @@ impl Compositor { fx: clip, ..Default::default() }); + dst } /// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de @@ -1767,7 +1807,7 @@ impl Compositor { sprite: &SceneCursorSprite, clip: [f32; 4], model: Option, - ) -> Result<()> { + ) -> Result> { let path = sprite.path.as_str(); let (srv, iw, ih) = self.cached_image(path)?; // Sans champ de distance, repli sur le sprite plat plutôt que sur le curseur math. @@ -1776,15 +1816,16 @@ impl Compositor { match self.cursor_sdf(path) { Ok((sdf, shape)) => { let shape = crate::frame_geometry::model_shape(sprite, shape); - if let Some(cb) = crate::frame_geometry::cursor_model_cb( + let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, - ) { - self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); - self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); - self.draw_layer(&cb); - self.ctx.PSSetShaderResources(4, Some(&[None])); - } - return Ok(()); + ) else { + return Ok(None); + }; + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); + self.draw_layer(&cb); + self.ctx.PSSetShaderResources(4, Some(&[None])); + return Ok(Some(cb.dst)); } Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), } @@ -1801,7 +1842,7 @@ impl Compositor { ); self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); self.draw_layer(&cb); - Ok(()) + Ok(Some(cb.dst)) } /// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche, @@ -1821,17 +1862,17 @@ impl Compositor { a: f32, clip: [f32; 4], model: Option, - ) { + ) -> Option<[f32; 4]> { let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); if let Some(sprite) = sprite { - if self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model).is_ok() { - return; + if let Ok(dst) = self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model) { + return dst; } } // Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître // maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour // un cas de secours ne se justifie pas. - self.draw_cursor(placement.upright_center(), size_px, a, clip); + Some(self.draw_cursor(placement.upright_center(), size_px, a, clip)) } /// Ombre portée (§7 E4) sous un quad `dst` (normalisé) de taille `size_px`. @@ -1996,8 +2037,6 @@ impl Compositor { let dof = g.depth_of_field_on(self.cpu_backend); let dof_srv = if dof { Some(self.fill_dof_pyramid(&sy, &suv, stw, sth)?) } else { None }; - self.begin([0.0, 0.0, 0.0, 1.0]); - // --- fond --- // Parité web (frameRenderer.blurredBackgroundLayer) : le fond est le WALLPAPER sélectionné // (image/couleur/gradient) et « Blur BG » floute CE wallpaper, PAS la vidéo. Le natif @@ -2006,86 +2045,120 @@ impl Compositor { // ensuite ; pour une couleur plate le flou est un no-op visuel). Côté fixture/bench // (pas de scène) on garde le fond screen-flouté, dont le coût est mesuré (C4). let scene_bg = self.scene.borrow().as_ref().map(|s| (s.background.clone(), s.effects.blur)); - if let Some((bg, blur_wallpaper)) = scene_bg { - match bg { - SceneBackground::Color { color } => { - let c = parse_hex(&color).unwrap_or(lp.bg_color); - self.draw_solid(&LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - mode: 1.0, - color: c, - ..Default::default() - }); - } - SceneBackground::Gradient { angle_deg, stops, offsets, motion } => { - // angle CSS → direction unitaire (espace sortie, y vers le bas) : - // 0° = vers le haut, 90° = vers la droite. - let a = angle_deg.to_radians(); - let dir = [a.sin(), -a.cos()]; - let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots( - motion, - g.programme_t, - self.rw() / self.rh(), - ); - self.draw_solid(&LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - fx: [dir[0], dir[1], anim[0], anim[1]], - mb, - ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) - }); - } - SceneBackground::Image { path, motion } => { - // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue - // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic - // borrow qu'on a déjà eu : toute panne doit être visible/traçable). - let aspect = self.rw() / self.rh(); - if let Err(e) = self.draw_image_bg(&path, aspect, motion, g.programme_t) { - eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e); + // Le fond de la frame d'avant, s'il est celui qu'on demande (`BackgroundKey`), est recopié + // au lieu d'être redessiné. Côté scène seulement : sans scène, le fond est la vidéo floutée. + let bg_size = [self.rw(), self.rh()]; + let bg_cached = scene_ref.as_ref().is_some_and(|s| { + self.bg_cache + .borrow() + .as_ref() + .is_some_and(|c| { + c.key.matches(Some(&s.background), s.effects.blur, lp.bg_color, bg_size) + }) + }); + if bg_cached { + self.bind_compose_state(); + if let Some(c) = self.bg_cache.borrow().as_ref() { + self.ctx.CopyResource(&self.rt, &c.tex); + } + } else { + self.begin([0.0, 0.0, 0.0, 1.0]); + // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé. + let mut bg_drawn = true; + if let Some((bg, blur_wallpaper)) = scene_bg { + match bg { + SceneBackground::Color { color } => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); self.draw_solid(&LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, - color: lp.bg_color, + color: c, ..Default::default() }); } + SceneBackground::Gradient { angle_deg, stops, offsets, motion } => { + // angle CSS → direction unitaire (espace sortie, y vers le bas) : + // 0° = vers le haut, 90° = vers la droite. + let a = angle_deg.to_radians(); + let dir = [a.sin(), -a.cos()]; + let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots( + motion, + g.programme_t, + self.rw() / self.rh(), + ); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + fx: [dir[0], dir[1], anim[0], anim[1]], + mb, + ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) + }); + } + SceneBackground::Image { path, motion } => { + // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue + // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic + // borrow qu'on a déjà eu : toute panne doit être visible/traçable). + let aspect = self.rw() / self.rh(); + if let Err(e) = self.draw_image_bg(&path, aspect, motion, g.programme_t) { + eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e); + bg_drawn = false; + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + } } - } - // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient - // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op - // visuel sur une couleur plate, effet réel sur gradient/image. - if blur_wallpaper > 0.0 { - self.blur_bg(blur_wallpaper); + // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient + // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op + // visuel sur une couleur plate, effet réel sur gradient/image. + if blur_wallpaper > 0.0 { + self.blur_bg(blur_wallpaper); + self.bind_compose_state(); + } + } else if cfg.bg_blur > 0.0 { + let over = 0.06; + self.draw_video( + &LayerCB { + dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over], + src: [0.0, 0.0, u_max, v_max], + quad_px: [self.rw(), self.rh()], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + ..Default::default() + }, + &sy, + &suv, + ); + self.blur_bg(cfg.bg_blur); self.bind_compose_state(); - } - } else if cfg.bg_blur > 0.0 { - let over = 0.06; - self.draw_video( - &LayerCB { - dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over], - src: [0.0, 0.0, u_max, v_max], - quad_px: [self.rw(), self.rh()], - mode: 0.0, - color: [1.0, 1.0, 1.0, 1.0], + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: [0.0, 0.0, 0.0, 0.35], ..Default::default() - }, - &sy, - &suv, - ); - self.blur_bg(cfg.bg_blur); - self.bind_compose_state(); - self.draw_solid(&LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - mode: 1.0, - color: [0.0, 0.0, 0.0, 0.35], - ..Default::default() - }); - } else { - self.draw_solid(&LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - mode: 1.0, - color: lp.bg_color, - ..Default::default() + }); + } else { + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + // Pas de cache pour un fond animé (`of` rend `None`). + let key = scene_ref.as_ref().filter(|_| bg_drawn).and_then(|s| { + crate::frame_geometry::BackgroundKey::of( + Some(&s.background), + s.effects.blur, + lp.bg_color, + bg_size, + ) }); + if let Some(key) = key { + self.keep_background(key)?; + } } // --- screen : crop du clip actif, puis zoom appliqué dans ce rect source (§8) --- @@ -2273,11 +2346,14 @@ impl Compositor { // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la + // sortie) : la seule région que la recopie doit relire. + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; for k in 0..plan.taps { let f = k as f32 / (plan.taps - 1) as f32; let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps); self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); - self.draw_cur_themed( + let drawn = self.draw_cur_themed( &cursor_sprites, cursor_type, plan.prev_placement.lerp(plan.placement, f), @@ -2286,6 +2362,14 @@ impl Compositor { plan.clip, plan.model, ); + if let Some(d) = drawn { + bounds = [ + bounds[0].min(d[0]), + bounds[1].min(d[1]), + bounds[2].max(d[0] + d[2]), + bounds[3].max(d[1] + d[3]), + ]; + } } // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); @@ -2303,7 +2387,22 @@ impl Compositor { }; self.ctx.RSSetViewports(Some(&[vp])); self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); - self.ctx.Draw(3, 0); + // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie + // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p + // mesurée sur Linux, Radeon 610M). + let (rw, rh) = (self.rw(), self.rh()); + let rect = RECT { + left: (bounds[0] * rw).floor().clamp(0.0, rw) as i32, + top: (bounds[1] * rh).floor().clamp(0.0, rh) as i32, + right: (bounds[2] * rw).ceil().clamp(0.0, rw) as i32, + bottom: (bounds[3] * rh).ceil().clamp(0.0, rh) as i32, + }; + if rect.right > rect.left && rect.bottom > rect.top { + self.ctx.RSSetState(&self.rs_scissor); + self.ctx.RSSetScissorRects(Some(&[rect])); + self.ctx.Draw(3, 0); + self.ctx.RSSetState(None); + } self.ctx.PSSetShaderResources(0, Some(&[None])); // restaure l'état de composition standard (VS/PS/topologie quad-strip) pour // le dessin de la webcam qui suit juste après. From 6b29eb7e97ad75c25668e0fd78ca17886cac3720 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 2 Oct 2026 23:13:17 +0200 Subject: [PATCH 2/3] docs(perf): note the background cache and trail scissor on Windows and macOS --- technical-documentation/engineering/rendering-performance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 77db05db4..23f99a90c 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -821,7 +821,7 @@ the bench runs on the reference machine. ## Known gaps -- **The D3D11 and Metal layer shaders were split like the WGSL one without a measurement on either.** Whether an AMD or Intel iGPU under Windows paid the same occupancy for the models is unknown, and so is what the split buys there; the M1 showed no regression on 2.0, which says nothing about fxc's register allocation. [The Linux section](#the-linux-export-path--2026-10-02) has the method — the register counts first, then the export. The static-background cache and the scissored cursor-trail composite are Linux-only: the other two backends still redraw the wallpaper every frame and composite the trail over the whole output. +- **The D3D11 and Metal layer shaders were split like the WGSL one without a measurement on either.** Whether an AMD or Intel iGPU under Windows paid the same occupancy for the models is unknown, and so is what the split buys there; the M1 showed no regression on 2.0, which says nothing about fxc's register allocation. [The Linux section](#the-linux-export-path--2026-10-02) has the method — the register counts first, then the export. The static-background cache (keyed by the shared `BackgroundKey`) and the scissored cursor-trail composite were ported to both, unmeasured as well. - **macOS export startup can cost 4 s, and nobody has reproduced it on demand.** Measured repeatedly at 4208–4502 ms between the CLI's `started` event and the first composed frame — 18 % of a 60 s export, 71 % of a 5 s one — then gone, on the same shipped binary, hours later (481 ms). It is not the compositor (init is 2.4 ms, runtime MSL compilation included), not the `