From ba04ab70a0274c022c5139dd08b554f83f387235 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 17:47:39 +0300 Subject: [PATCH 01/18] 3D text and shapes rasterise only the part the output shows (#1388) A 3D text or shape layer close to the camera was rasterised whole at its on-screen scale, up to ~16 million pixels (a quarter gigabyte as f32) per frame worker, though the frame showed a small part of it; behind the camera it was rasterised and not drawn. With 16 preview workers one such layer took the app to 8-14 GB. Renderer::raster_clip projects the output frame back onto the layer's plane (as the Classic 3D compositor draws it) and rasterises only those cells of an 8 x 8 grid over the content, in a window of the whole raster's pixel grid; nothing when the content is behind the camera. Cells keep a moving camera on one cached raster. Layers whose off-frame pixels can matter (effects, styles, masks, motion blur, depth of field, lights, Advanced 3D, per-character 3D, collapsed 2D transforms) are rasterised whole as before. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 89 +++++++- crates/render/src/shapes.rs | 10 +- crates/render/src/tests_raster_clip.rs | 281 +++++++++++++++++++++++++ crates/render/src/text.rs | 42 +++- crates/render/src/three_d/compose.rs | 46 ++++ 5 files changed, 454 insertions(+), 14 deletions(-) create mode 100644 crates/render/src/tests_raster_clip.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..47e301666 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -620,6 +620,9 @@ pub struct Renderer<'a> { through: [Option>; MAX_THROUGH], /// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred. through_lost: bool, + /// Rasterise 3D text and shapes whole, never only the part the output shows + /// ([`Renderer::raster_clip`]; tests compare against it). + pub(crate) whole_raster: bool, } /// One collapsed precomp layer a nested comp is drawn through. @@ -706,6 +709,7 @@ impl<'a> Renderer<'a> { top: true, through: [None; MAX_THROUGH], through_lost: false, + whole_raster: false, } } @@ -887,8 +891,9 @@ impl<'a> Renderer<'a> { } let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); let prev = ctx.time - ctx.comp.frame_rate.frame_duration(); - let now = cache::layer_key(ctx, layer, scale, draft, blur); - now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur) + let before = ctx.at(prev); + let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, layer, scale, draft, blur)) } /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, @@ -921,14 +926,73 @@ impl<'a> Renderer<'a> { // a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390: // rounded up to 2^¼, unscaled text was drawn larger and resampled, softer). let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); - // Keep the buffer within about 16 million pixels. + // Keep the buffer within about 16 million pixels (of the part the output shows, when + // only that is rasterised: see `raster_clip`). if let Some(b) = content_bounds(ctx, layer) { + let b = match self.raster_clip(ctx, layer) { + Some(c) => { + let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c); + [r.x0, r.y0, r.x1, r.y1] + } + None => b, + }; let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; k = k.min((16.0e6 / area).sqrt().max(1.0)); } s * k } + /// The layer-space part of a 3D text or shape layer the output shows, when only that part + /// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16 + /// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction + /// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole: + /// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur, + /// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a + /// collapsed 2D transform — or the visible part can't be bounded (see + /// [`three_d::compose::visible_clip`]). + pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() { + return None; + } + if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty())) + || layer.masks().is_some_and(|m| !m.children.is_empty()) + || styles::active(ctx, layer) + || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) + || self.is_adv3d(ctx) + || (matches!(layer.source, LayerSource::Text) && text::per_char_3d(ctx, layer)) + { + return None; + } + let v = three_d::compose::visible_clip(self, ctx, layer)?; + if v.area() <= 0.0 { + // Behind the camera. + return Some(v); + } + let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?; + if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 { + return None; + } + // Whole cells of a layer-space grid whose cell is about a quarter of the part that shows, + // rounded down to a power of two: a moving camera keeps one raster (and its cache entry) + // while that part stays within the same cells, and a dolly changes the grid only at each + // doubling. The cells cover at most about 2.25 times the part that shows. Worth it only + // when it leaves out at least half of the content. + let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor()); + let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c }; + // (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.) + let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true)); + (cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells) + } + + /// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]). + fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + Some(match self.raster_clip(ctx, layer) { + Some(c) => [c.x0, c.y0, c.x1, c.y1].iter().fold(cache::derive(k, 0xc11b), |d, v| cache::derive(d, (v * 16.0).round() as i64 as u64)), + None => k, + }) + } + /// Draw a Wireframe-quality layer: its bounds as a one-pixel outline. fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) { @@ -1341,8 +1405,11 @@ impl<'a> Renderer<'a> { let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } - LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), - LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), + LayerSource::Text => Some(self.authored(text::render_in(ctx, layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))), + LayerSource::Shape => layer + .props + .sub("contents") + .map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))), _ => None, } } @@ -1516,7 +1583,7 @@ impl<'a> Renderer<'a> { fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); - let key = self.content_key(ctx, layer, key); + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1539,7 +1606,7 @@ impl<'a> Renderer<'a> { /// so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); - let key = self.content_key(ctx, layer, key); + let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -2206,7 +2273,11 @@ impl<'a> Renderer<'a> { /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. - let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); + let key = self.clipped_key( + ctx, + layer, + self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))), + ); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -2424,4 +2495,6 @@ mod tests_nested; #[cfg(test)] mod tests_paint; #[cfg(test)] +mod tests_raster_clip; +#[cfg(test)] mod tests_time; diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..2808f283b 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -592,6 +592,12 @@ const BATCH_PIXELS: usize = 8 << 20; /// Each draw rasterises and composites only its own pixel rectangle; coverage masks of /// consecutive draws are rasterised in parallel, then composited in stacking order. pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf { + render_in(ctx, layer, contents, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64, clip: Option) -> Buf { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); // Bottom-most draw first. @@ -608,9 +614,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; // Keep buffers sane for runaway geometry. let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = crate::text::raster_window(b, s, clip) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); let px_rect = |r: kurbo::Rect| -> Option { let x0 = (r.x0 * s + offset[0]).floor().max(0.0); diff --git a/crates/render/src/tests_raster_clip.rs b/crates/render/src/tests_raster_clip.rs new file mode 100644 index 000000000..74da0e616 --- /dev/null +++ b/crates/render/src/tests_raster_clip.rs @@ -0,0 +1,281 @@ +//! #1388: 3D text and shape layers rasterise only the part of the layer the output shows. Where +//! the whole raster fits its pixel budget the frame is identical to rasterising the whole layer; +//! close to the camera the raster stays small instead of growing to ~16 million pixels. + +use effectcraft_color::Label; +use effectcraft_keyframe::{TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// The default camera's distance to the z = 0 plane. +fn zoom() -> f64 { + W as f64 * 50.0 / 36.0 +} + +fn setup() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +/// A 3D text layer at `pos`, turned `rot_y` degrees about Y. +fn text3(p: &mut Project, comp: &Comp, pos: [f64; 3], rot_y: f64) -> Layer { + let mut l = build::layer(p, comp, "Text", LayerSource::Text, (W, H), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA TEXT".into(), size: 60.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +/// A 3D shape layer (a 300×120 rectangle with a stroke) at `pos`. +fn shape3(p: &mut Project, comp: &Comp, pos: [f64; 3]) -> Layer { + let mut l = build::layer(p, comp, "Shape", LayerSource::Shape, (W, H), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let r = build::shape_rect(&mut ids, [300.0, 120.0], [0.0, 0.0], 0.0); + let f = build::shape_fill(&mut ids, [0.9, 0.4, 0.1, 1.0]); + let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 6.0); + let g = build::shape_group(&mut ids, "Box", vec![r, f, s]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + p.next_id = next; + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + l +} + +fn scene(l: impl FnOnce(&mut Project, &Comp) -> Layer) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let layer = l(&mut p, &comp); + p.comp_mut(cid).unwrap().layers.push(layer); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, whole: bool) -> effectcraft_raster::Image { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + r.comp_frame(cid, Tick::ZERO) +} + +/// Pixels of the layer's processed buffer. +fn raster_px(p: &Project, cid: ItemId, whole: bool) -> u64 { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.whole_raster = whole; + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let b = r.layer_buf(&ctx, &c.layers[0]).unwrap(); + b.img.width as u64 * b.img.height as u64 +} + +#[test] +fn the_visible_part_renders_the_same_frame_as_the_whole_layer() { + let cx = W as f64 / 2.0; + let cy = H as f64 / 2.0; + let cases: Vec<(&str, (Project, ItemId))> = vec![ + ("far", scene(|p, c| text3(p, c, [cx, cy, 300.0], 0.0))), + ("in the frame plane", scene(|p, c| text3(p, c, [cx, cy, 0.0], 0.0))), + ("past the frame edge", scene(|p, c| text3(p, c, [60.0, cy, -200.0], 0.0))), + ("turned", scene(|p, c| text3(p, c, [cx, cy, -150.0], 55.0))), + ("close to the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 40.0], 0.0))), + ("partly behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 30.0], 80.0))), + ("behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() - 200.0], 0.0))), + ("shape", scene(|p, c| shape3(p, c, [cx + 150.0, cy, -250.0]))), + ]; + for (label, (p, cid)) in &cases { + let clipped = frame(p, *cid, false); + let whole = frame(p, *cid, true); + assert_eq!(clipped.data, whole.data, "{label}"); + } +} + +/// Close to the camera only part of the text shows: its raster is a fraction of the whole one +/// (millions of pixels), and the frame still shows text. +#[test] +fn text_close_to_the_camera_rasterises_what_shows() { + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let clipped = raster_px(&p, cid, false); + let whole = raster_px(&p, cid, true); + assert!(whole > 5_000_000, "the whole raster is huge ({whole} px)"); + eprintln!("near camera: {clipped} px rasterised, {whole} px whole"); + assert!(clipped * 10 < whole, "{clipped} px of {whole}"); + let img = frame(&p, cid, false); + let covered = img.data.iter().filter(|px| px[3] > 0.5).count(); + assert!(covered > (W * H / 20) as usize, "text shows ({covered} px)"); + // Behind the camera there is nothing to rasterise. + let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0], 0.0)); + assert!(raster_px(&p, cid, false) <= 1); +} + +/// The #1388 case at full size: 200 pt text in a 1920 × 1080 comp, close to the camera. Rasterised +/// whole it fills the ~16 million pixel cap; clipped, it stays within a few frames' worth of pixels +/// (and the text still shows). +#[test] +fn hd_text_close_to_the_camera_stays_within_a_few_frames() { + let mut p = Project::default(); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (1920, 1080), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA".into(), size: 200.0, ..Default::default() })); + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0 + 60.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let (clipped, whole) = (raster_px(&p, cid, false), raster_px(&p, cid, true)); + eprintln!("HD near camera: {clipped} px rasterised, {whole} px whole"); + assert!(whole > 12_000_000, "{whole}"); + assert!(clipped <= 4 * 1920 * 1080, "{clipped} px"); + assert!(frame(&p, cid, false).data.iter().any(|px| px[3] > 0.5), "the text shows"); +} + +/// A slow move of the layer against the camera (a camera move does the same) keeps the part +/// rasterised within the same grid cells for several frames: they reuse the cached raster instead +/// of drawing the text again each frame. +#[test] +fn a_slow_move_reuses_the_clipped_raster() { + let cache = crate::LayerCache::default(); + let at = |x: f64| { + let (p, cid) = scene(|p, c| text3(p, c, [x, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, Tick::ZERO); + }; + at(W as f64 / 2.0); + let misses = cache.stats().misses; + // 16 frames, a quarter of a layer pixel apart (here about three output pixels). + for i in 1..=16 { + at(W as f64 / 2.0 + i as f64 * 0.25); + } + let rasters = cache.stats().misses - misses; + assert!(rasters <= 2, "{rasters} new rasters over 16 frames"); +} + +/// One keyframed motion: (seconds, value) pairs. +type Keys<'a> = &'a [(f64, [f64; 3])]; + +fn keyed(l: &mut Layer, path: &str, keys: Keys) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).keys = + keys.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Vec3(*v))).collect(); +} + +/// A camera keyed through `pos` / `poi`, and the text keyed through `text_pos` (and turning about +/// Y through `text_rot`): every frame of a second, rendered with the clipped raster and a layer +/// cache shared across the frames, matches rasterising the whole layer without a cache (or differs +/// by an anti-aliasing sub-sample or two on a few edge pixels) — except where the whole raster is held at +/// its pixel cap (softer than the clipped one, which isn't): there the text shows and the clipped +/// raster stays small. +fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f64)]) { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, 0.0], 0.0); + if !text_pos.is_empty() { + keyed(&mut text, "transform/position", text_pos); + } + if !text_rot.is_empty() { + text.props.prop_mut("transform/rotationY").unwrap().keys = + text_rot.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Scalar(*v))).collect(); + } + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom())); + keyed(&mut cam, "transform/position", pos); + keyed(&mut cam, "transform/poi", poi); + p.comp_mut(cid).unwrap().layers = vec![cam, text]; + let cache = crate::LayerCache::default(); + let (mut exact, mut near, mut capped) = (0, 0, 0); + for f in 0..30 { + let t = Tick::from_seconds_f64(f as f64 / 30.0); + let mut clipped = Renderer::new(&p, &NoFootage, RenderOpts::default()); + clipped.cache = Some(&cache); + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + let ctx = whole.eval_ctx(cid, t).unwrap(); + let layer = &p.comp(cid).unwrap().layers[1]; + let a = clipped.comp_frame(cid, t); + if clipped.raster_scale(&ctx, layer) == whole.raster_scale(&ctx, layer) { + let b = whole.comp_frame(cid, t); + if a.data == b.data { + exact += 1; + } else { + // The clipped raster places the glyphs at other coordinates (smaller ones: the whole + // raster can be thousands of pixels wide), and tiny-skia transforms them in f32: its + // 4 × 4 anti-aliasing may land a sub-sample or two differently on a few edge pixels. + let (n, worst) = a.data.iter().zip(&b.data).fold((0usize, 0.0f32), |(n, w), (p, q)| { + let d = (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0f32, f32::max); + (n + usize::from(d > 0.0), w.max(d)) + }); + assert!(worst <= 2.0 / 16.0 + 1e-4 && n * 1000 <= (W * H) as usize, "{label}: frame {f}: {n} px differ, by up to {worst}"); + near += 1; + } + } else { + // Held at the cap whole: the clipped raster is sharper, and small. + let buf = clipped.layer_buf(&ctx, layer).unwrap(); + assert!(buf.img.width as u64 * buf.img.height as u64 <= 16_500_000, "{label}: frame {f}"); + let shows = |img: &effectcraft_raster::Image| img.data.iter().filter(|px| px[3] > 0.5).count(); + let (sa, sb) = (shows(&a), shows(&whole.comp_frame(cid, t))); + // The same text shows (or none, the camera past it); coverage within a few percent. + assert!(sa.abs_diff(sb) * 50 <= sb.max(1) + 50, "{label}: frame {f}: {sa} px of text, {sb} rasterised whole"); + capped += 1; + } + } + eprintln!("{label}: {exact} frames identical, {near} within anti-aliasing noise, {capped} sharper than the capped whole raster"); + assert!(exact + near >= 10, "{label}: {exact} identical, {near} close, {capped} at the cap"); +} + +#[test] +fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() { + let (cx, cy, z) = (W as f64 / 2.0, H as f64 / 2.0, -zoom()); + let still = [(0.0, [cx, cy, 0.0])]; + // In towards the text, through it and past it; and back out. + moving("dolly in through the text", &[(0.0, [cx, cy, 2.0 * z]), (1.0, [cx, cy, 150.0])], &[(0.0, [cx, cy, 400.0]), (1.0, [cx, cy, 700.0])], &[], &[]); + moving("dolly out", &[(0.0, [cx, cy, -60.0]), (1.0, [cx, cy, 3.0 * z])], &still, &[], &[]); + // Sideways and up and down close to the text, so it leaves the frame on both sides. + moving( + "pan x", + &[(0.0, [cx - 500.0, cy, -120.0]), (1.0, [cx + 500.0, cy, -120.0])], + &[(0.0, [cx - 500.0, cy, 0.0]), (1.0, [cx + 500.0, cy, 0.0])], + &[], + &[], + ); + moving("pan y", &[(0.0, [cx, cy - 300.0, -90.0]), (1.0, [cx, cy + 300.0, -90.0])], &[(0.0, [cx, cy - 300.0, 0.0]), (1.0, [cx, cy + 300.0, 0.0])], &[], &[]); + // Around the text, close, looking at it. + let orbit: Vec<(f64, [f64; 3])> = (0..=8) + .map(|i| { + let a = i as f64 / 8.0 * std::f64::consts::TAU * 0.4 - 0.8; + (i as f64 / 8.0, [cx + 150.0 * a.sin(), cy, -150.0 * a.cos()]) + }) + .collect(); + moving("orbit", &orbit, &still, &[], &[]); + // The text moves instead: towards the camera, past it, and across. + moving( + "text towards and past the camera", + &[(0.0, [cx, cy, z])], + &still, + &[(0.0, [cx + 200.0, cy - 80.0, 400.0]), (1.0, [cx - 150.0, cy + 60.0, z - 100.0])], + &[], + ); + // Turning close to the camera. + moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]); +} + +/// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole. +#[test] +fn layers_with_effects_rasterise_whole() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into()); + assert_eq!(raster_px(&p, cid, false), raster_px(&p, cid, true)); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..2aa93ecfa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -943,8 +943,46 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt } } +/// Buffer pixels kept around a clip (see [`raster_window`]). +const CLIP_PAD: f64 = 24.0; + +/// The buffer for content bounds `b` (layer space) at scale `s`: (width, height, layer → buffer +/// offset), two pixels of padding around it. With `clip`, only the pixels of that grid inside +/// `clip`, padded by [`CLIP_PAD`]: a whole-pixel window of the whole buffer's grid. Its pixels are +/// the whole buffer's, except that a glyph cut by the window is rasterised from the window's edge: +/// tiny-skia's 4 × 4 anti-aliasing then lands a sub-sample differently on a few edge pixels, up to +/// a dozen rows in from that edge — inside the padding, out of the frame. `None` when `clip` +/// misses the content. +pub(crate) fn raster_window(b: kurbo::Rect, s: f64, clip: Option) -> Option<(u32, u32, [f64; 2])> { + let (full_w, full_h) = ((b.width() * s).ceil() + 4.0, (b.height() * s).ceil() + 4.0); + let full = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let (x0, y0, x1, y1) = match clip { + None => (0.0, 0.0, full_w, full_h), + // Nothing shows (behind the camera). + Some(c) if c.area() <= 0.0 => return None, + Some(c) => ( + (c.x0 * s + full[0] - CLIP_PAD).floor().max(0.0), + (c.y0 * s + full[1] - CLIP_PAD).floor().max(0.0), + (c.x1 * s + full[0] + CLIP_PAD).ceil().min(full_w), + (c.y1 * s + full[1] + CLIP_PAD).ceil().min(full_h), + ), + }; + if !(x1 > x0 && y1 > y0) { + return None; + } + let w = ((x1 - x0) as u32).clamp(1, 16384); + let h = ((y1 - y0) as u32).clamp(1, 16384); + Some((w, h, [full[0] - x0, full[1] - y0])) +} + /// Render a text layer into a layer-space buffer at scale `s`. pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { + render_in(ctx, layer, s, None) +} + +/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows, +/// see `Renderer::raster_clip`). +pub fn render_in(ctx: &EvalCtx, layer: &Layer, s: f64, clip: Option) -> Buf { let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s }; let Some(geom) = text_geom(ctx, layer) else { return empty() }; let mut bounds: Option = None; @@ -957,9 +995,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { } let Some(b) = bounds else { return empty() }; let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let Some((w, h, offset)) = raster_window(b, s, clip) else { return empty() }; let mut img = Image::new(w, h); let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); draw_glyphs(&geom, &mut img, &base, s, None); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 6cf87e70e..1f17e3314 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -185,6 +185,52 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) { (c.width as f64, c.height as f64) } +/// The layer-space rectangle of a 3D layer that this render's output can show: the output frame +/// (padded by two pixels) projected back onto the layer's plane, the way [`geo`] draws it. `None` +/// when that can't be bounded: an orthographic camera, depth of field (its blur reads past the +/// frame), motion blur (each sub-sample sees another part), or a frame corner whose ray misses the +/// plane in front of the camera (a plane seen towards its horizon). An empty rectangle when the +/// whole content is behind the camera. +pub(crate) fn visible_clip(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + let cam = camera_for(r, ctx); + if cam.ortho || cam.dof.is_some() || mb_samples(r, ctx, layer) > 1 { + return None; + } + let comp = view_size(r, ctx); + let s = r.opts.scale; + let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); + let world = outer * ctx.world_matrix(layer); + let (off, w, h) = match r.roi_offset() { + Some((x, y, w, h)) => ((x, y), w as f64, h as f64), + None => ((0.0, 0.0), (comp.0 * s).round(), (comp.1 * s).round()), + }; + let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(s, s, 1.0)) * cam.projection(comp.0, comp.1); + let hinv = (proj * world).plane_to_mat3().inverse()?; + let cv = cam.view * world; + let depth = |u: f64, v: f64| cv.0[2][0] * u + cv.0[2][1] * v + cv.0[2][3]; + if let Some(b) = crate::content_bounds(ctx, layer) + && [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].iter().all(|&(u, v)| depth(u, v) < NEAR) + { + return Some(kurbo::Rect::ZERO); + } + const PAD: f64 = 2.0; + let mut rect: Option = None; + for (sx, sy) in [(-PAD, -PAD), (w + PAD, -PAD), (w + PAD, h + PAD), (-PAD, h + PAD)] { + let m = &hinv.0; + let qz = m[2][0] * sx + m[2][1] * sy + m[2][2]; + if qz <= 1e-300 { + return None; + } + let (u, v) = ((m[0][0] * sx + m[0][1] * sy + m[0][2]) / qz, (m[1][0] * sx + m[1][1] * sy + m[1][2]) / qz); + if !u.is_finite() || !v.is_finite() || depth(u, v) < NEAR { + return None; + } + let p = kurbo::Rect::new(u, v, u, v); + rect = Some(rect.map_or(p, |r| r.union(p))); + } + rect +} + /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { From 3ac9a82378898f2fe05ec9fa454425f95d798b51 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 18:32:28 +0300 Subject: [PATCH 02/18] Layer-space reads of a 3D text or shape layer get the whole layer (#1388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: effects that take another layer as a parameter (Set Matte, Displacement Map, time effects…) read it through Renderer::nested in layer space, and the motion tracker reads layer_source; both could get only the part of the layer the frame shows. The nested renderer and layer_source now rasterise whole. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 6 +++++- crates/render/src/tests_raster_clip.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47e301666..54948c8dd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -766,6 +766,9 @@ impl<'a> Renderer<'a> { top: false, through: [None; MAX_THROUGH], through_lost: false, + // Effects read other layers through it in layer space (layer parameters, Echo…): + // they need the whole layer, not the part this frame shows (see `raster_clip`). + whole_raster: true, ..*self } } @@ -1573,7 +1576,8 @@ impl<'a> Renderer<'a> { /// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers /// without pixels. pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - self.source(ctx, layer) + // The whole layer, not the part the frame shows (see `raster_clip`). + Renderer { whole_raster: true, ..*self }.source(ctx, layer) } /// Layer pixels after source → masks → effects (no layer styles). diff --git a/crates/render/src/tests_raster_clip.rs b/crates/render/src/tests_raster_clip.rs index 74da0e616..da0b40884 100644 --- a/crates/render/src/tests_raster_clip.rs +++ b/crates/render/src/tests_raster_clip.rs @@ -268,6 +268,34 @@ fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() { moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]); } +/// Reads of a layer in its own space get the whole layer, not the part the frame shows: an effect +/// taking another layer as a parameter (Set Matte, stretched to fit, from hidden text close to the +/// camera), and the source the motion tracker analyses. +#[test] +fn layer_space_reads_get_the_whole_layer() { + let (mut p, cid, comp) = setup(); + let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0); + text.switches.video = false; + let text_id = text.id; + let sid = + p.add_item("Solid", Label::Red, None, ItemKind::Solid(effectcraft_project::Solid { color: [0.2, 0.6, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0)); + solid.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers = vec![text, solid]; + assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text"); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default()); + whole.whole_raster = true; + assert_eq!(r.layer_source(&ctx, layer).unwrap().img, whole.layer_source(&ctx, layer).unwrap().img, "the tracker's source"); +} + /// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole. #[test] fn layers_with_effects_rasterise_whole() { From 29619dfe6749cd977f005a6b73e4ef7bcf12d228 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:38:57 +0100 Subject: [PATCH 03/18] Preview: render at most two frames on the GPU at once so video memory stays flat (#3028) --- crates/ui-egui/src/frames.rs | 59 ++++++++++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 22 +++++++++++ docs/architecture.md | 4 +- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9aad2a81c..c4cc1eee1 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -26,7 +26,7 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; @@ -47,6 +47,46 @@ const MIN_GPU_BUDGET: usize = 64 << 20; const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// GPU frames that render at once, however many frame threads there are. Each one holds its own +/// working textures, so 16 at once ran an RTX 3090 out of video memory (#3028). +const MAX_GPU_RENDERS: usize = 2; + +/// Counts the GPU renders in flight; the rest of the frame threads wait their turn. +struct GpuSlots { + busy: Mutex, + freed: Condvar, +} + +/// One GPU render slot, given back on drop. +struct GpuSlot<'a>(&'a GpuSlots); + +static GPU_SLOTS: GpuSlots = GpuSlots::new(); + +impl GpuSlots { + const fn new() -> Self { + Self { busy: Mutex::new(0), freed: Condvar::new() } + } + + /// Wait until fewer than `max` renders are in flight, then take a slot. The browser has no + /// threads to wait on: it never blocks. + fn acquire(&self, max: usize) -> GpuSlot<'_> { + let mut busy = self.busy.lock().unwrap_or_else(PoisonError::into_inner); + while !cfg!(target_arch = "wasm32") && *busy >= max { + busy = self.freed.wait(busy).unwrap_or_else(PoisonError::into_inner); + } + *busy += 1; + GpuSlot(self) + } +} + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut busy = self.0.busy.lock().unwrap_or_else(PoisonError::into_inner); + *busy = busy.saturating_sub(1); + self.0.freed.notify_one(); + } +} + /// A rendered viewer frame: CPU pixels or a GPU texture. #[derive(Clone)] pub enum FrameImage { @@ -1458,7 +1498,10 @@ impl Worker { r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; - let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + let f = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; effectcraft_engine::render::Accelerator::frame_end(g); match f { Ok(f) => { @@ -1497,7 +1540,11 @@ impl Worker { let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; if auto.is_none_or(|(p, key)| p.choose(key)) { let t0 = web_time::Instant::now(); - match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { + let shown = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; + match shown { Ok(Some(f)) => { self.gpu_succeeded(); if let Some((p, key)) = auto { @@ -1530,7 +1577,11 @@ impl Worker { } if let Some(g) = gpu { // The GPU composites and the frame is read back. - return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { + let composited = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| r.comp_frame(job.comp, job.t)) + }; + return match composited { Ok(img) => { self.gpu_succeeded(); FrameImage::Cpu(Arc::new(to_color_image(&img))) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index ace51b0bb..dde9225c5 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1113,3 +1113,25 @@ fn timing_markers_and_expressions_still_change_the_frames() { s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_renders_in_flight_stay_within_the_cap() { + use std::sync::atomic::AtomicUsize; + let slots = GpuSlots::new(); + let now = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..16 { + s.spawn(|| { + let _slot = slots.acquire(2); + let n = now.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(n, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + assert!(peak.load(Ordering::SeqCst) <= 2); + assert_eq!(*slots.busy.lock().unwrap_or_else(PoisonError::into_inner), 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index c4bebd9ed..7637dbc28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -362,7 +362,9 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer +runs, at most two of them render on the GPU at once and the rest wait their turn, so their working +textures don't add up to more video memory than the GPU has (#3028). The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on the CPU and the kept textures are freed, but the compositor stays on and, with no allocation failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is From 2320255c8b685898d1b0fdad3f06a03a6e74f8b9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 00:47:50 +0100 Subject: [PATCH 04/18] #3333: upload each footage frame once per frame and share it across layers --- crates/gpu/src/context.rs | 73 ++++++- crates/gpu/src/lib.rs | 2 + crates/gpu/src/tests.rs | 2 + crates/gpu/src/tests/bench_share.rs | 83 ++++++++ crates/gpu/src/tests/footage_share.rs | 233 +++++++++++++++++++++++ crates/gpu/src/upload.rs | 264 ++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 75 +++++++- crates/render/src/cache.rs | 5 + crates/render/src/lib.rs | 2 +- crates/render/src/pipeline.rs | 109 +++++++++-- 10 files changed, 818 insertions(+), 30 deletions(-) create mode 100644 crates/gpu/src/tests/bench_share.rs create mode 100644 crates/gpu/src/tests/footage_share.rs create mode 100644 crates/gpu/src/upload.rs diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 1f04fa5b1..99178a5fc 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -128,6 +128,11 @@ const POOL_BUDGET: usize = 256 << 20; /// miss (a new allocation), never a wrong pixel. const STALE_FRAMES: u64 = 2; +/// The pool owner of uploaded textures: never registered by an [`Enc`] (their ids start at 1), +/// so an upload takes only from the shared free list ([`Pool::take`], textures no open encoder +/// can read) and gives its texture back through the guarded [`Pool::release`]. +const UPLOAD_OWNER: u64 = 0; + /// Readback staging buffers kept for reuse across sizes (bytes). const STAGING_BUDGET: u64 = 256 << 20; @@ -693,6 +698,10 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + /// Uploaded footage pictures, shared by the layers that show them (see [`crate::upload`]). + pictures: Mutex, + /// Reused staging buffers of large uploads (see [`crate::upload::Staging`]). + upload_staging: crate::upload::Staging, upload_budget: std::sync::atomic::AtomicUsize, upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, @@ -1017,6 +1026,8 @@ impl GpuContext { dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + pictures: Default::default(), + upload_staging: Default::default(), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), pool, @@ -1132,11 +1143,11 @@ impl GpuContext { /// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting /// on `owner` (`closing_enc`) cannot free. fn kept_bytes(&self, owner: u64, closing_enc: Option) -> u64 { - let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0); + // (uploaded layer and footage textures are pooled: the pool's leases count them) let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0); let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0); let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0); - uploads + pool + staging + zeros + pool + staging + self.upload_staging.bytes() + zeros } #[cfg(test)] @@ -1446,6 +1457,8 @@ impl GpuContext { dummy: self.dummy.clone(), dummy_buf: self.dummy_buf.clone(), uploads: Mutex::new(Uploads::default()), + pictures: Default::default(), + upload_staging: Default::default(), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), pool: self.pool.clone(), @@ -1625,16 +1638,54 @@ impl GpuContext { return None; } self.transfers.count(true, img.data.len() * 16); - let g = self.image(img.width, img.height); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(&img.data), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) }, - wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, - ); + // A texture from the pool's shared free list: no open encoder can still read it, and the + // copy below runs before anything recorded after this call. + let (g, _) = self.pooled(img.width, img.height, UPLOAD_OWNER); + if !self.upload_staging.write(&self.device, &self.queue, img, &g.texture) { + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&img.data), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) }, + wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, + ); + } Some(g) } + /// Upload a decoded footage picture once while the footage source keeps it: every layer + /// and effect input showing the same frame shares the texture (`Renderer::footage_picture`). + pub(crate) fn upload_picture(&self, img: &Arc) -> Option { + if let Some(t) = self.pictures.lock().ok().and_then(|mut p| p.get(img)) { + return Some(t); + } + let tex = self.upload_image(img)?; + if let Ok(mut p) = self.pictures.lock() { + p.insert(img, tex.clone(), crate::upload::PICTURE_BUDGET.min(self.upload_budget() / 4)); + } + Some(tex) + } + + /// Entries in the footage picture cache. + #[cfg(test)] + pub(crate) fn picture_entries(&self) -> usize { + self.pictures.lock().map(|p| p.len()).unwrap_or_default() + } + + /// The upload staging buffers (tests). + #[cfg(test)] + pub(crate) fn upload_staging(&self) -> &crate::upload::Staging { + &self.upload_staging + } + + /// A render is done: drop the footage picture textures and the upload staging buffers + /// (the next render's pictures upload again; their textures come from the pool). + pub fn trim_uploads(&self) { + if let Ok(mut p) = self.pictures.lock() { + p.clear(); + } + self.upload_staging.clear(); + } + /// Entries in the layer upload cache. #[cfg(test)] pub(crate) fn upload_entries(&self) -> usize { @@ -1696,6 +1747,10 @@ impl GpuContext { u.map.clear(); u.bytes = 0; } + if let Ok(mut p) = self.pictures.lock() { + p.clear(); + } + self.upload_staging.clear(); if let Ok(mut p) = self.pool.lock() { p.clear(); } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3ede5c6b9..db6061487 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -84,6 +84,7 @@ mod memcap; mod ops; mod particles; mod readback; +mod upload; mod walk; mod yuv; @@ -439,6 +440,7 @@ impl Accelerator for Gpu { target: "effectcraft_gpu", "gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far" ); + self.ctx.trim_uploads(); self.ctx.trim_pool(); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b88bac0be..1060d0a84 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -28,8 +28,10 @@ use crate::Gpu; mod adjust; mod adv3d; +mod bench_share; mod deferred; mod deferred_jobs; +mod footage_share; mod fx_color; mod fx_depth; mod fx_distort; diff --git a/crates/gpu/src/tests/bench_share.rs b/crates/gpu/src/tests/bench_share.rs new file mode 100644 index 000000000..580c7f4e6 --- /dev/null +++ b/crates/gpu/src/tests/bench_share.rs @@ -0,0 +1,83 @@ +//! Warm-frame benchmark of #3333 (run by hand): three layers of the same 1080p footage frame, +//! 32 bpc, a 30-frame sequence already decoded (as the media pool serves a warm frame), with a +//! layer cache as the Render Queue has. Prints the median and mean ms per frame and a hash of +//! every frame (to compare the pixels of two builds). +//! +//! `cargo test -p effectcraft-gpu --release --lib bench_share -- --ignored --nocapture` + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::*; + +struct Decoded { + frames: Mutex>>, +} + +impl FootageSource for Decoded { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + let i = f.frame_rate.frame_at(t).clamp(0, 29); + let mut m = self.frames.lock().unwrap(); + Some(m.entry(i).or_insert_with(|| Arc::new(pattern(item.0 as u32 + i as u32, f.width, f.height))).clone()) + } +} + +#[test] +#[ignore] +fn bench_share() { + let Some(g) = gpu() else { return }; + let (w, h) = (1920, 1080); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "seq.exr".into(), + kind: FootageKind::Sequence, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + alpha: AlphaMode::Premultiplied, + ..Default::default() + }; + let fid = p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f)); + for i in 0..3 { + let mut l = build::layer(&mut p, &comp, "Sequence", LayerSource::Footage { item: fid }, (w, h), None); + if i > 0 { + l.blend_mode = BlendMode::Add; + set(&mut l, "transform/opacity", Value::Scalar(25.0)); + } + p.comp_mut(cid).unwrap().layers.insert(0, l); + } + let src = Decoded { frames: Mutex::new(HashMap::new()) }; + let frames = 30; + let mut runs = vec![]; + let mut hashes = vec![]; + for pass in 0..4 { + let cache = effectcraft_render::cache::LayerCache::new(1 << 30); + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() }); + r.accel = Some(g); + r.cache = Some(&cache); + for k in 0..frames { + let t0 = std::time::Instant::now(); + let img = g.render(&r, cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame"); + let ms = t0.elapsed().as_secs_f64() * 1e3; + if pass > 0 { + runs.push(ms); + } + if pass == 1 { + use std::hash::{Hash, Hasher}; + let mut hs = std::collections::hash_map::DefaultHasher::new(); + img.data.iter().for_each(|px| px.map(f32::to_bits).hash(&mut hs)); + hashes.push(hs.finish()); + } + } + } + runs.sort_by(f64::total_cmp); + let mean = runs.iter().sum::() / runs.len() as f64; + eprintln!("bench_share: {} frames, median {:.1} ms, mean {:.1} ms, min {:.1} ms", runs.len(), runs[runs.len() / 2], mean, runs[0]); + eprintln!("bench_share frame hashes: {hashes:016x?}"); +} diff --git a/crates/gpu/src/tests/footage_share.rs b/crates/gpu/src/tests/footage_share.rs new file mode 100644 index 000000000..1a31c3981 --- /dev/null +++ b/crates/gpu/src/tests/footage_share.rs @@ -0,0 +1,233 @@ +//! #3333: a footage frame uploads once per frame and its texture is shared by every layer and +//! effect input that shows it; the frame is bit-identical to drawing each layer from its own copy. + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::*; +use crate::walk::SHARED_PICTURES; + +/// Keeps decoded frames as the media pool does: the same frame (at the same size) of the same +/// footage is the same `Arc` while kept. Counts the frames it decodes. +#[derive(Default)] +struct Kept { + frames: Mutex), Arc>>, +} + +impl Kept { + fn get(&self, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + let i = f.frame_rate.frame_at(t); + let mut m = self.frames.lock().unwrap(); + let img = m.entry((item, f.width, f.height, i, size)).or_insert_with(|| { + let full = pattern(item.0 as u32 + i as u32 * 7 + f.width, f.width, f.height); + Arc::new(match size { + Some((w, h)) => effectcraft_raster::resample(&full, w, h), + None => full, + }) + }); + Some(img.clone()) + } +} + +impl FootageSource for Kept { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.get(item, f, t, None) + } + fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.get(item, f, t, Some((width, height))) + } +} + +/// One footage item (a sequence: a new picture every frame) shown by `layers` layers, each +/// placed differently (the first fills the comp). +fn shared_scene(depth: BitDepth, layers: usize) -> (Scene, ItemId) { + let mut s = Scene::new(depth); + let f = Footage { + path: "seq.exr".into(), + kind: FootageKind::Sequence, + width: 97, + height: 61, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(2.0), + has_video: true, + alpha: AlphaMode::Premultiplied, + ..Default::default() + }; + let fid = s.p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f)); + for i in 0..layers { + let mut l = build::layer(&mut s.p, &s.comp, "Sequence", LayerSource::Footage { item: fid }, (97, 61), None); + if i > 0 { + set(&mut l, "transform/rotation", Value::Scalar(11.0 * i as f64)); + set(&mut l, "transform/scale", Value::Vec3([70.0 + 9.0 * i as f64, 64.0, 100.0])); + set(&mut l, "transform/position", v3(30.0 + 13.0 * i as f64, 25.0 + 4.0 * i as f64)); + set(&mut l, "transform/opacity", Value::Scalar(80.0)); + l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Add }; + } + s.push(l); + } + (s, fid) +} + +/// The GPU frame with shared pictures on and off, and the number of uploads of each. +fn render_both(g: &crate::Gpu, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick) -> (Image, Image, u64, u64) { + let mut r = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Gpu, ..opts }); + r.accel = Some(g); + let before = g.ctx.transfer_stats().uploads; + let on = g.render(&r, s.cid, t).expect("the GPU renders the frame"); + let uploads = g.ctx.transfer_stats().uploads - before; + SHARED_PICTURES.store(false, std::sync::atomic::Ordering::Relaxed); + let before = g.ctx.transfer_stats().uploads; + let off = g.render(&r, s.cid, t); + let copied = g.ctx.transfer_stats().uploads - before; + SHARED_PICTURES.store(true, std::sync::atomic::Ordering::Relaxed); + (on, off.expect("the GPU renders the frame from per-layer copies"), uploads, copied) +} + +fn check_cpu(label: &str, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick, gpu_img: &Image) { + let cpu = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t); + let mut d = diff(&cpu, gpu_img, tolerance(s.p.settings.bit_depth)); + d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32; + check(label, Some(d), 0.0); +} + +/// Three layers showing the same frame upload it once; a fourth that runs an effect on it +/// (resident on the GPU) reads the same texture as its input. Every frame of the sequence +/// uploads its own picture once. +#[test] +fn three_layers_of_one_footage_frame_upload_once() { + let Some(g) = gpu() else { return }; + let src = Kept::default(); + let (mut s, fid) = shared_scene(BitDepth::Bpc32, 3); + for k in 0..3 { + let t = Tick::from_seconds_f64(k as f64 / 30.0); + let (on, off, uploads, copied) = render_both(g, &s, &src, opts(), t); + assert_eq!(uploads, 1, "frame {k}: three layers of one footage frame uploaded {uploads} times"); + assert_eq!(copied, 3, "frame {k}: per-layer copies upload once per layer"); + assert!(on.data == off.data, "frame {k}: the shared picture changed the frame"); + check_cpu(&format!("shared picture frame {k}"), &s, &src, opts(), t, &on); + } + // An effect input: the blur reads the shared texture. Alone, the blurred layer uploads + // its picture (and whatever the blur itself uploads); the three plain layers showing the + // same frame add no upload. + let mut fx = build::layer(&mut s.p, &s.comp, "Blurred", LayerSource::Footage { item: fid }, (97, 61), None); + set(&mut fx, "transform/position", v3(20.0, 20.0)); + s.effect(&mut fx, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + let (mut alone, _) = shared_scene(BitDepth::Bpc32, 0); + alone.p = s.p.clone(); + alone.p.comp_mut(alone.cid).unwrap().layers = vec![fx.clone()].into(); + let (_, _, blur_uploads, _) = render_both(g, &alone, &src, opts(), Tick::from_seconds_f64(0.4)); + s.push(fx); + let t = Tick::from_seconds_f64(0.5); + let (on, off, uploads, _) = render_both(g, &s, &src, opts(), t); + assert_eq!(uploads, blur_uploads, "three layers and an effect input of one footage frame uploaded {uploads} times, the effect layer alone {blur_uploads}"); + assert!(on.data == off.data, "the shared picture changed the frame with an effect input"); + check_cpu("shared picture with an effect input", &s, &src, opts(), t, &on); +} + +/// The frame is bit-identical with shared pictures on and off wherever they apply or not: every +/// bit depth, colour management and linear blending, half resolution, a track matte, Preserve +/// Transparency, motion blur, masks, layer styles and a proxy. +#[test] +fn shared_pictures_do_not_change_the_frame() { + let Some(g) = gpu() else { return }; + let src = Kept::default(); + let t = Tick::from_seconds_f64(0.2); + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + let mut cases: Vec<(String, Scene, RenderOpts)> = vec![]; + cases.push(("plain".into(), shared_scene(depth, 3).0, opts())); + cases.push(("half resolution".into(), shared_scene(depth, 3).0, RenderOpts { scale: 0.5, ..opts() })); + for (ws, blend_linear) in [(Some(ColorSpace::Rec709), false), (None, true)] { + let (mut s, _) = shared_scene(depth, 3); + s.p.settings.working_space = ws; + s.p.settings.blend_linear = blend_linear; + cases.push((format!("colour {ws:?} linear blending {blend_linear}"), s, opts())); + } + { + let (mut s, _) = shared_scene(depth, 3); + let comp = s.p.comp_mut(s.cid).unwrap(); + comp.enable_motion_blur = true; + let matte = comp.layers[0].id; + comp.layers[1].track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Luma }); + comp.layers[2].preserve_transparency = true; + comp.layers[2].switches.motion_blur = true; + let pos = comp.layers[2].props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))]; + s.comp.enable_motion_blur = true; + cases.push(("matte, preserve transparency, motion blur".into(), s, opts())); + } + { + let (mut s, _) = shared_scene(depth, 3); + let mut next = s.p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 30.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]); + let comp = s.p.comp_mut(s.cid).unwrap(); + comp.layers[0].props.sub_mut("masks").unwrap().children.push(m.into()); + effectcraft_project::styles::add_style(&mut comp.layers[1], &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap(); + s.p.next_id = next; + cases.push(("mask and layer style".into(), s, opts())); + } + { + let (mut s, fid) = shared_scene(depth, 3); + let proxy = Footage { path: "proxy.png".into(), kind: FootageKind::Still, width: 48, height: 30, has_video: true, ..Default::default() }; + s.p.item_mut(fid).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true })); + cases.push(("proxy".into(), s, opts())); + } + for (label, s, o) in cases { + let (on, off, _, _) = render_both(g, &s, &src, o, t); + assert!(on.data == off.data, "{label} {depth:?}: the shared picture changed the frame"); + check_cpu(&format!("{label} {depth:?}"), &s, &src, o, t, &on); + } + } +} + +/// The picture cache keeps no texture of a picture the footage source dropped. +#[test] +fn picture_cache_drops_textures_of_dropped_pictures() { + let Some(g) = gpu() else { return }; + let before = g.ctx.picture_entries(); + for i in 0..6 { + let img = Arc::new(pattern(i, 64, 64)); + let up = g.ctx.upload_picture(&img).expect("upload"); + let again = g.ctx.upload_picture(&img).expect("upload"); + assert!(up.texture == again.texture, "the same picture uploaded twice"); + drop((up, again, img)); + } + let kept = g.ctx.picture_entries(); + assert!(kept <= before + 1, "the picture cache kept {kept} textures of dropped pictures (had {before})"); +} + +/// Large uploads copy through reused staging buffers (rows padded to the copy alignment) with +/// the same pixels as `Queue::write_texture`; the buffers stay within their budget, and a +/// clear while one is in flight forgets it. +#[test] +fn staging_uploads_keep_pixels_and_reuse_buffers() { + let Some(g) = gpu() else { return }; + let wait = || { + let _ = g.ctx.poll_readbacks(wgpu::PollType::wait_indefinitely()); + }; + // 300 px: 4800-byte rows, padded to 4864. + for (w, h) in [(300u32, 300u32), (512, 600)] { + let before = g.ctx.upload_staging().copies(); + for i in 0..4 { + let img = pattern(40 + i, w, h); + let up = g.ctx.upload_image(&img).expect("upload"); + let mut e = crate::context::Enc::new(&g.ctx); + let back = e.download(&up).expect("readback"); + assert!(back.data.iter().flatten().map(|v| v.to_bits()).eq(img.data.iter().flatten().map(|v| v.to_bits())), "{w}×{h} upload {i} changed pixels"); + drop((up, e)); + wait(); + } + assert!(g.ctx.upload_staging().copies() - before >= 2, "{w}×{h}: no upload went through a reused staging buffer"); + assert!(g.ctx.upload_staging().bytes() <= 192 << 20); + } + // A clear with a copy in flight: nothing kept, and later uploads still work. + let img = pattern(7, 300, 300); + let up = g.ctx.upload_image(&img).expect("upload"); + g.ctx.upload_staging().clear(); + wait(); + assert_eq!(g.ctx.upload_staging().bytes(), 0, "a cleared staging kept its buffers"); + let mut e = crate::context::Enc::new(&g.ctx); + assert!(e.download(&up).expect("readback").data == img.data); + let again = g.ctx.upload_image(&img).expect("upload after a clear"); + assert!(e.download(&again).expect("readback").data == img.data); +} diff --git a/crates/gpu/src/upload.rs b/crates/gpu/src/upload.rs new file mode 100644 index 000000000..9825fb335 --- /dev/null +++ b/crates/gpu/src/upload.rs @@ -0,0 +1,264 @@ +//! Footage uploads (#3333): each decoded footage picture uploads once and its texture is shared +//! by every layer and effect input that shows it ([`Pictures`]), and large uploads copy through +//! staging buffers that are kept mapped and reused ([`Staging`]) instead of one that +//! `Queue::write_texture` allocates and frees per upload. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +use effectcraft_raster::Image; + +use crate::context::GpuImage; + +/// Bytes of picture textures kept after their frame (a sequence's next frame takes the oldest +/// one's texture back from the pool), at most a quarter of the context's upload budget. The +/// newest picture is always kept, whatever its size. +pub(crate) const PICTURE_BUDGET: usize = 256 << 20; + +/// Uploads from this size go through [`Staging`]; smaller ones through `Queue::write_texture`. +const STAGING_MIN: u64 = 1 << 20; + +/// Bytes of staging buffers that may exist at once (mapped and ready, or in flight). +const STAGING_BUDGET: u64 = 192 << 20; + +struct Picture { + img: Weak, + tex: GpuImage, + bytes: usize, + last_use: u64, +} + +/// Uploaded footage pictures by the identity of the decoded frame (its `Arc`): the footage +/// source serves one `Arc` per (file, frame, size, colour curve) while it keeps the frame, and +/// a new one when the file changed, so the same `Arc` means the same pixels. The `Weak` keeps +/// the allocation, so a key cannot be reused by another picture while its entry exists. +#[derive(Default)] +pub(crate) struct Pictures { + map: HashMap, + bytes: usize, + clock: u64, +} + +impl Pictures { + /// The texture of `img` if it was uploaded and is still kept. + pub(crate) fn get(&mut self, img: &Arc) -> Option { + self.clock += 1; + let now = self.clock; + let e = self.map.get_mut(&(Arc::as_ptr(img) as usize))?; + if !e.img.upgrade().is_some_and(|i| Arc::ptr_eq(&i, img)) { + return None; + } + e.last_use = now; + Some(e.tex.clone()) + } + + /// Keep `tex`, the upload of `img`. Pictures the footage source no longer holds go at once + /// (nothing can ask for them again), then the least recently used over [`PICTURE_BUDGET`], + /// never `img` itself. A dropped texture goes back to the pool once no open encoder can + /// still read it. + pub(crate) fn insert(&mut self, img: &Arc, tex: GpuImage, budget: usize) { + let key = Arc::as_ptr(img) as usize; + let bytes = img.data.len().saturating_mul(16); + self.clock += 1; + let entry = Picture { img: Arc::downgrade(img), tex, bytes, last_use: self.clock }; + if let Some(old) = self.map.insert(key, entry) { + self.bytes = self.bytes.saturating_sub(old.bytes); + } + self.bytes = self.bytes.saturating_add(bytes); + let dead: Vec = self.map.iter().filter(|(_, e)| e.img.strong_count() == 0).map(|(k, _)| *k).collect(); + for k in dead { + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + while self.bytes > budget { + let Some(k) = self.map.iter().filter(|(k, _)| **k != key).min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + pub(crate) fn clear(&mut self) { + self.map.clear(); + self.bytes = 0; + } +} + +#[derive(Default)] +struct StagingState { + /// Buffers mapped for writing and not in use, each mapped whole. + ready: Vec, + /// Bytes of every staging buffer that exists (ready, being mapped or in flight). + total: u64, + /// Raised by [`Staging::clear`]: a buffer of an earlier generation is dropped when its + /// mapping completes. + generation: u64, + /// Uploads copied through a staging buffer (diagnostics, tests). + copies: u64, +} + +/// Reused upload staging buffers (`MAP_WRITE | COPY_SRC`). A buffer is written while mapped, +/// unmapped, copied into the texture by a command buffer submitted at once, and mapped again +/// when that submission is done (`CommandEncoder::map_buffer_on_submit`; the callback runs when +/// the device is polled, as every frame does). A new buffer is mapped asynchronously too (never +/// `mapped_at_creation`, which fails hard on drivers that report a failed allocation as an +/// internal error, #591), so the upload that made it, and any upload while no ready buffer fits +/// or the budget is spent, goes through `Queue::write_texture` instead. +#[derive(Default)] +pub(crate) struct Staging { + state: Arc>, +} + +/// `bytes_per_row` of a staging copy: rows padded to `COPY_BYTES_PER_ROW_ALIGNMENT`. +fn padded_row(width: u32) -> Option { + let row = u64::from(width).checked_mul(16)?; + let a = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + row.div_ceil(a).checked_mul(a) +} + +impl Staging { + fn lock(&self) -> std::sync::MutexGuard<'_, StagingState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Bytes of staging buffers that exist (see [`STAGING_BUDGET`]). + pub(crate) fn bytes(&self) -> u64 { + self.lock().total + } + + /// Uploads copied through a staging buffer so far. + #[cfg(test)] + pub(crate) fn copies(&self) -> u64 { + self.lock().copies + } + + /// Forget every staging buffer (after a failed frame, #1319): those in flight are dropped + /// when their mapping completes. + pub(crate) fn clear(&self) { + let mut s = self.lock(); + s.ready.clear(); + // (buffers still in flight are no longer counted: they drop when their mapping ends) + s.total = 0; + s.generation = s.generation.wrapping_add(1); + } + + /// The callback of a write mapping of `buffer`: once mapped it is ready for the next + /// upload; it is dropped (and its bytes uncounted) when mapping failed, and dropped when + /// the staging was cleared meanwhile (whose [`Staging::clear`] stopped counting it). + fn on_mapped(&self, buffer: &wgpu::Buffer, generation: u64) -> impl FnOnce(Result<(), wgpu::BufferAsyncError>) + wgpu::WasmNotSend + 'static { + let state = Arc::downgrade(&self.state); + let buffer = buffer.clone(); + move |res: Result<(), wgpu::BufferAsyncError>| { + let Some(state) = state.upgrade() else { return }; + let mut s = state.lock().unwrap_or_else(PoisonError::into_inner); + if s.generation != generation { + return; + } + if res.is_ok() { + s.ready.push(buffer); + } else { + s.total = s.total.saturating_sub(buffer.size()); + } + } + } + + /// Copy `img` into `texture` (its size) through a ready staging buffer, recorded and + /// submitted at once: before anything an open encoder records later, as + /// `Queue::write_texture` would. `false`: not done (small, no ready buffer fits, or the + /// mapping failed); the caller writes it through the queue. Starts mapping a new buffer for + /// later uploads when none fits and the budget allows. + pub(crate) fn write(&self, device: &wgpu::Device, queue: &wgpu::Queue, img: &Image, texture: &wgpu::Texture) -> bool { + let (w, h) = (img.width, img.height); + let row = w as usize * 16; + let Some(stride) = padded_row(w) else { return false }; + let (Some(need), Ok(stride32)) = (stride.checked_mul(u64::from(h)), u32::try_from(stride)) else { return false }; + if need < STAGING_MIN || img.data.len() != w as usize * h as usize { + return false; + } + let (buffer, generation) = { + let mut s = self.lock(); + let generation = s.generation; + // The smallest ready buffer that holds it. + let best = s.ready.iter().enumerate().filter(|(_, b)| b.size() >= need).min_by_key(|(_, b)| b.size()).map(|(i, _)| i); + match best { + Some(i) => (Some(s.ready.swap_remove(i)), generation), + None => { + // Ready buffers too small for it make room (a size that no longer occurs). + while s.total.saturating_add(need) > STAGING_BUDGET + && let Some(b) = s.ready.pop() + { + s.total = s.total.saturating_sub(b.size()); + } + if s.total.saturating_add(need) <= STAGING_BUDGET { + s.total += need; + drop(s); + let b = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("upload staging"), + size: need, + usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let done = self.on_mapped(&b, generation); + b.map_async(wgpu::MapMode::Write, .., done); + } + (None, generation) + } + } + }; + let Some(buffer) = buffer else { return false }; + self.lock().copies += 1; + { + let Ok(mut view) = buffer.get_mapped_range_mut(..need) else { + // Not mapped after all: give it up (its bytes too). + let mut s = self.lock(); + if s.generation == generation { + s.total = s.total.saturating_sub(buffer.size()); + } + return false; + }; + let src: &[u8] = bytemuck::cast_slice(&img.data); + let stride = stride as usize; + if stride == row { + view.slice(..src.len()).copy_from_slice(src); + } else { + for (y, line) in src.chunks_exact(row).enumerate() { + let at = y * stride; + view.slice(at..at + row).copy_from_slice(line); + } + } + } + buffer.unmap(); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("upload") }); + enc.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride32), rows_per_image: Some(h) }, + }, + wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let done = self.on_mapped(&buffer, generation); + enc.map_buffer_on_submit(&buffer, wgpu::MapMode::Write, .., done); + queue.submit([enc.finish()]); + true + } +} + +#[cfg(test)] +mod tests { + use super::padded_row; + + #[test] + fn staging_rows_are_padded_to_the_copy_alignment() { + assert_eq!(padded_row(1920), Some(30720)); + assert_eq!(padded_row(97), Some(1792)); + assert_eq!(padded_row(16), Some(256)); + assert_eq!(padded_row(1), Some(256)); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index f688a6375..471c91339 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -241,6 +241,9 @@ fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) { return done; } + if let Some(done) = draw_picture(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -286,14 +289,28 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can { return None; } - let input = r.layer_input(ctx, layer, 0)?; - if input.img.is_empty() { - return Some(Some(())); - } - let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + // The decoded footage picture itself when it is the layer's input (uploaded once per frame + // and shared with every layer that shows it), else the cached input buffer. + let (src, offset, scale) = match picture(r, ctx, layer) { + Some(p) => { + if p.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) }; + (src, p.effect_offset, p.scale) + } + None => { + let input = r.layer_input(ctx, layer, 0)?; + if input.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + (src, input.offset, input.scale) + } + }; let pipe = r.pipe(); // `layer_input` is in effect space: run the stack there, then back to layer space. - let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels); + let mut fx = crate::effects::GpuFx::new(e, src, offset, scale, pipe.levels); let origin = r.run_layer_effects_on(ctx, layer, &mut fx); let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) }; offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale]; @@ -308,6 +325,52 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) } +/// Shared footage pictures ([`picture`]); tests turn it off to compare. +pub(crate) static SHARED_PICTURES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +/// The layer's source as the decoded footage picture (`Renderer::footage_picture`), unless +/// sharing is off or per-layer timings are being recorded (those time the layer-cache path). +fn picture(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + if !SHARED_PICTURES.load(std::sync::atomic::Ordering::Relaxed) || r.profile.is_some() { + return None; + } + r.footage_picture(ctx, layer) +} + +/// A footage layer without effects or layer styles whose source is the decoded picture as it +/// is (`Renderer::footage_picture`) and needs no conversion to the blending space: placed +/// straight from the picture's texture, uploaded once per frame and shared by every layer that +/// shows the same frame, instead of from a copy per layer in the layer cache, uploaded per +/// layer. The pixels are the same: the layer-cache buffer is that picture, copied. `None`: not +/// this kind of layer; `Some(None)`: the GPU failed. +fn draw_picture(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if (layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty())) || styles::active(ctx, layer) || r.pipe().to_blend().is_some() { + return None; + } + let p = picture(r, ctx, layer)?; + if p.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) }; + // The placement only reads the buffer's size, offset and scale. + let shape = Buf { img: Image { width: p.img.width, height: p.img.height, data: Vec::new() }, offset: [0.0; 2], scale: p.scale }; + Some(composite_src(e, r, ctx, layer, &shape, src, canvas, opacity)) +} + +/// [`composite_layer`] of a GPU image `src` shaped like `shape`. +#[allow(clippy::too_many_arguments)] +fn composite_src(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, shape: &Buf, src: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> { + if opacity <= 0.0 { + return Some(()); + } + if r.track_matte(ctx, layer).is_none() && !layer.preserve_transparency { + *canvas = place_src(e, r, ctx, layer, shape, src, canvas, layer.blend_mode, opacity, r.pipe().levels)?; + return Some(()); + } + let iso = place_src(e, r, ctx, layer, shape, src, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?; + composite_iso(e, r, ctx, layer, iso, canvas, opacity) +} + /// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level /// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` /// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8a3d7b8ae..4bbd4e198 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -230,6 +230,11 @@ impl LayerCache { /// used. (A render that started before a drop may publish its buffer after the drop's /// purge, Reload Footage clearing the cache while the buffer was being written: the check /// here, not the order of the two, keeps it from being served.) + /// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read). + pub(crate) fn holds(&self, key: u64) -> bool { + self.kept(key, false).is_some() + } + fn kept(&self, key: u64, hit: bool) -> Option<(Arc, Arc)> { let found = { let mut g = self.inner.lock().ok()?; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e09794de2..0f3243e60 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -61,7 +61,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; -pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; +pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6c79ba7bb..1d9efc795 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -19,6 +19,24 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]): +/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what +/// [`Renderer::layer_input`] gives) at `effect_offset`. +#[derive(Clone, Debug)] +pub struct FootagePicture { + pub img: Arc, + pub scale: f64, + pub effect_offset: [f64; 2], +} + +/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]). +enum FootageLayout { + /// Its own pixels, at this buffer scale. + Native { scale: f64 }, + /// Resampled to `width`×`height` at this buffer scale. + Resample { width: u32, height: u32, scale: f64 }, +} + /// A property value an Essential Properties override gives a layer of a collapsed precomp. #[derive(Clone, Debug)] pub struct PropertyOverride { @@ -861,12 +879,7 @@ impl<'a> Renderer<'a> { return Some(buf); } } - let lt = ctx.source_time(layer); - // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(item); - let pf = proxy.unwrap_or(f); - let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; + let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } // (the font files go to this render's log, which `ctx` may not carry: see @@ -898,14 +911,9 @@ impl<'a> Renderer<'a> { /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's /// nominal size; proxies are scaled up to it), converted from its colour profile. fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { - let s = self.opts.scale; - let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; - // Keep native pixels when downsampling is small; resample otherwise. - let mut buf = match self.reduced_size(w, h) { - // The source resampled it already (`FootageSource::frame_at_size`). - Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s }, - Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }, - _ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }, + let mut buf = match self.footage_layout(img, w, h) { + FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale }, + FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale }, }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -913,6 +921,79 @@ impl<'a> Renderer<'a> { buf } + /// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer + /// pixels: its own pixels, or resampled. + fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout { + let s = self.opts.scale; + let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; + // Keep native pixels when downsampling is small; resample otherwise. + match self.reduced_size(w, h) { + // The source resampled it already (`FootageSource::frame_at_size`). + Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s }, + Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s }, + _ => FootageLayout::Native { scale: 1.0 / k }, + } + } + + /// A footage layer's decoded frame at the context time (the proxy's when one is used) and + /// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of. + fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc, &'a Footage)> { + let lt = ctx.source_time(layer); + // The proxy is decoded instead, at its own size, and fills the footage's frame. + let proxy = self.proxy_for(item); + let pf = proxy.unwrap_or(f); + let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; + Some((img, pf)) + } + + /// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a + /// footage layer (not continuously rasterised vector footage) without masks whose frame + /// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer + /// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and + /// `scale`. + /// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so + /// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is + /// made the usual way (any of the above applies, or the frame is missing), and also when the + /// frame is made per call rather than served by the footage source (frame blending, + /// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new + /// picture each time, nothing to share) or when the layer cache already holds the layer's + /// buffer (served from memory as before, without asking the footage source). + pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off { + return None; + } + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) { + return None; + } + let pf = self.proxy_for(item).unwrap_or(f); + if self.pipe.media_in(pf).is_some() + || pf.fields != effectcraft_project::FieldOrder::Off + || pf.invert_alpha + || pf.linear_light_applies() + || pf.cineon_applies() + { + return None; + } + if let Some(cache) = self.cache { + let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); + let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0)); + if [content, input].into_iter().flatten().any(|k| cache.holds(k)) { + return None; + } + } + let (img, _) = self.footage_picture_of(ctx, layer, item, f)?; + let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None }; + // `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin). + let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale }; + at.rebase(ctx.effect_bounds(layer).1); + Some(FootagePicture { img, scale, effect_offset: at.offset }) + } + /// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage /// is resampled to it (below 3/4 scale). fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> { From 4649c27ef97ea649845243500eac1c109c3d4dfc Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 00:55:30 +0100 Subject: [PATCH 05/18] #3335: Render Queue reads frames back once, pipelines GPU frames and picks the faster renderer --- crates/engine/src/cmd/render_queue.rs | 3 + crates/engine/src/cmd/timeline.rs | 3 + crates/engine/src/render_queue.rs | 39 +++ crates/engine/src/rq_tests.rs | 58 ++++ crates/export/src/lib.rs | 35 ++- crates/export/src/pipeline.rs | 341 ++++++++++++++++++++-- crates/export/tests/it/h264_gpu_yuv.rs | 4 +- crates/export/tests/it/render_settings.rs | 147 ++++++++++ crates/gpu/src/adv3d.rs | 2 +- crates/gpu/src/context.rs | 176 +++++++++-- crates/gpu/src/readback.rs | 46 ++- crates/gpu/src/tests.rs | 43 +++ crates/gpu/src/yuv.rs | 2 +- crates/host/src/lib.rs | 2 + crates/ui-egui/examples/rq_bench.rs | 141 +++++++++ 15 files changed, 973 insertions(+), 69 deletions(-) create mode 100644 crates/ui-egui/examples/rq_bench.rs diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 6ca5b9ba0..e9ce1ffab 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -863,6 +863,9 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { storage: s.storage_quota.as_deref(), label: "Save Current Preview".into(), nested_switches: s.prefs.general.switches_affect_nested_comps, + + caches: None, + renderer: None, }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index fbc11654f..df804f2ef 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -145,6 +145,9 @@ fn prerender( storage: None, label: format!("Pre-render {}", l.name), nested_switches: cx.nested_switches, + + caches: None, + renderer: None, }; let r = exporter.export(&job, progress)?; Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start }) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index cc141f3fd..8e50ad3b5 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -34,6 +34,40 @@ pub struct ExportJob<'a> { pub label: String, /// Settings ▸ General ▸ Switches Affect Nested Comps. pub nested_switches: bool, + /// The layer caches of the Render Queue run this job belongs to (#3335): consecutive jobs + /// start warm, and the run trims the accelerator's pooled textures once at its end. `None`: + /// a one-off export (its own caches, trimmed when it ends). + pub caches: Option<&'a RunCaches>, + /// The compositor the queue item's output modules render on (#3335): the first module + /// chooses (measuring a GPU project's first frames) and stores it here, the others follow. + pub renderer: Option<&'a Mutex>>, +} + +/// What the jobs of one Render Queue run keep between them (#3335): a layer cache per +/// compositor (a buffer an effect made on the GPU is not the CPU's), together at the budget +/// each job's single cache had before. +pub struct RunCaches { + pub gpu: effectcraft_render::LayerCache, + pub cpu: effectcraft_render::LayerCache, +} + +impl Default for RunCaches { + fn default() -> Self { + let half = effectcraft_render::LayerCache::default().budget() / 2; + RunCaches { gpu: effectcraft_render::LayerCache::new(half), cpu: effectcraft_render::LayerCache::new(half) } + } +} + +/// Calls [`effectcraft_render::Accelerator::render_end`] when a Render Queue run ends, however +/// it ends (done, failed, stopped, panicked): its jobs leave the pooled textures to each other. +struct RunEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>); + +impl Drop for RunEnd<'_> { + fn drop(&mut self) { + if let Some(a) = self.0 { + a.render_end(); + } + } } /// What an export wrote. @@ -317,6 +351,8 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared let mut s = lock(&shared.state); s.items_total = w.items.len(); } + let caches = RunCaches::default(); + let _run_end = RunEnd(w.accel.as_deref()); for (k, modules) in w.items.iter().enumerate() { let Some((item, _)) = modules.first() else { continue }; if shared.cancel.load(Ordering::Relaxed) { @@ -337,6 +373,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared // Every output module encodes the same frames (Composition ▸ Add Output Module); the // first module's file is the item's output. let mut r: Result = Err("no output module".into()); + let renderer = Mutex::new(None); for (mi, (mitem, path)) in modules.iter().enumerate() { let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1); @@ -350,6 +387,8 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared storage: w.storage.as_deref(), label: format!("#{index} {name} (output module {})", mi + 1), nested_switches: w.nested_switches, + caches: Some(&caches), + renderer: Some(&renderer), }; let rr = w .exporter diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index d6bb96831..18e3da711 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -248,3 +248,61 @@ fn locked_resize_uses_height_for_height_only_output_module_updates() { let comp = s.project.comp(item.comp).unwrap(); assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence"); } + +#[test] +fn a_queue_run_keeps_its_jobs_warm() { + // #3335: every job started with a fresh layer cache and trimmed the GPU's texture pool when + // it ended, so each item's first frames were cold. A run's jobs share its caches; the pool is + // trimmed once, when the run ends; an item's output modules share one compositor choice. + use std::sync::atomic::{AtomicUsize, Ordering}; + struct Recorder(Mutex, Option)>>); + impl Exporter for Recorder { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + let caches = job.caches.map(|c| c as *const _ as usize); + let slot = job.renderer.map(|r| { + // The first module chooses; the second sees its choice. + r.lock().unwrap().get_or_insert(effectcraft_render::Backend::Cpu); + r as *const _ as usize + }); + self.0.lock().unwrap().push((caches, slot)); + Ok(ExportResult { path: job.path.to_string(), ..Default::default() }) + } + } + #[derive(Default)] + struct Ends(AtomicUsize); + impl effectcraft_render::Accelerator for Ends { + fn name(&self) -> String { + "ends".into() + } + fn comp_frame(&self, _: &effectcraft_render::Renderer, _: effectcraft_project::ItemId, _: effectcraft_time::Tick) -> Option { + None + } + fn supports_effect(&self, _: &str) -> bool { + false + } + fn effects(&self, _: &[effectcraft_render::FxStep], _: &effectcraft_effects::Buf, _: Option) -> Option { + None + } + fn render_end(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let mut s = demo(); + let ex = Arc::new(Recorder(Mutex::new(vec![]))); + s.exporter = Some(ex.clone()); + let ends = Arc::new(Ends::default()); + s.accel = Some(ends.clone()); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_a.mp4")})).unwrap(); + s.execute("render.addOutputModule", json!({"format": "png"})).unwrap(); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_b.mp4")})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + let jobs = ex.0.lock().unwrap().clone(); + assert_eq!(jobs.len(), 3, "{jobs:?}"); + assert!(jobs.iter().all(|(c, r)| c.is_some() && r.is_some()), "{jobs:?}"); + assert!(jobs.iter().all(|(c, _)| *c == jobs[0].0), "every job of the run shares its caches"); + assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice"); + assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 9827ce754..e7ffc39ef 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -53,6 +53,7 @@ pub use effectcraft_project::render_queue; pub use effectcraft_project::render_queue::StorageQuota; pub use out::Sink; pub(crate) use pipeline::Cx; +pub use pipeline::{CACHE_BUDGET, RENDERER_ENV, pick_renderer}; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -107,6 +108,37 @@ pub struct JobOptions<'a> { pub storage: Option<&'a dyn StorageQuota>, /// Overflow folders, in order. pub overflow: Vec, + /// Layer caches shared by the consecutive jobs of a Render Queue run (#3335): the job + /// renders through them instead of fresh ones, and leaves the accelerator's pooled working + /// textures for the next job (whoever holds the caches calls + /// [`effectcraft_render::Accelerator::render_end`] after the last one). `None`: the job + /// starts cold and trims the pool when it ends, as a one-off export does. + pub caches: Option>, + /// The compositor of the job's frames (`Backend::Gpu` or `Backend::Cpu`; `Auto` counts as unset), shared by the output modules of + /// one queue item: when it holds a choice the job renders on it; otherwise the job stores + /// the compositor it chose there, so the item's other output modules encode the same + /// frames. `None`: the job decides alone. + pub renderer: Option<&'a std::sync::Mutex>>, +} + +/// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, +/// because a buffer an effect made on the GPU is not the CPU's, and neither may stand in for +/// the other. +#[derive(Clone, Copy)] +pub struct JobCaches<'a> { + pub gpu: &'a effectcraft_render::LayerCache, + pub cpu: &'a effectcraft_render::LayerCache, +} + +/// The compositor a job's frames render on: the GPU's (Mercury GPU Acceleration, with an +/// accelerator) or the CPU's frame-parallel one (Mercury Software Only). The whole job renders +/// on one of them, never a mix: the two differ slightly. A GPU project with an accelerator +/// measures both on its first frames and renders the job on the faster (#3335); the choice is +/// in the render log. `EFFECTCRAFT_RQ_RENDERER=gpu|cpu|auto` overrides the measurement. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum JobRenderer { + Gpu, + Cpu, } /// Progress after a batch of frames. @@ -176,7 +208,8 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result< pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { let t0 = Instant::now(); let cx = Cx::new(job); - let render = RenderEnd(job.accel); + // A job of a Render Queue run leaves the pooled working textures warm for the next one. + let render = RenderEnd(job.accel.filter(|_| job.options.caches.is_none())); let mut r = export_with(&cx, progress, t0).await; drop(render); if matches!(r, Err(ExportError::Cancelled)) { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index e0c5cfc87..ed813e1da 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -37,6 +37,42 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Overrides the Render Queue's measured choice of compositor for GPU projects with an +/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Mercury Software Only) or +/// `auto` (measure, the default). +pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER"; + +/// Frames each compositor renders, timed, when a job measures which is faster (after one +/// untimed warm-up frame each: layer cache misses, uploads, kernel builds). +const PROBE_FRAMES: usize = 2; +/// Jobs shorter than this render on the project's compositor without measuring: the frames +/// the slower one renders for the measurement would cost more than they could save. +const PROBE_MIN_JOB: u64 = 12; +/// Bytes each of a job's two layer caches (one per compositor) may hold: together what the +/// job's single cache held before (`LayerCache::default()`). +pub const CACHE_BUDGET: usize = 1 << 29; + +/// The faster compositor from measured milliseconds per frame (`None`: not measured, or the +/// GPU stopped). As the viewer's Auto ([`effectcraft_render::AutoPick::MARGIN`]), the CPU wins +/// only when clearly faster: ties and noise stay on the GPU. +pub fn pick_renderer(gpu_ms: Option, cpu_ms: Option) -> crate::JobRenderer { + match (gpu_ms, cpu_ms) { + (Some(g), Some(c)) if g.is_finite() && c.is_finite() && c < g * effectcraft_render::AutoPick::MARGIN => crate::JobRenderer::Cpu, + (None, Some(_)) => crate::JobRenderer::Cpu, + _ => crate::JobRenderer::Gpu, + } +} + +/// How a job's compositor was chosen, for the render log. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Choice { + pub renderer: crate::JobRenderer, + /// Measured ms per frame on each (`None`: not measured). + pub gpu_ms: Option, + pub cpu_ms: Option, + pub why: &'static str, +} + /// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders /// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working /// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI @@ -168,8 +204,10 @@ pub(crate) struct Cx<'a> { pub project: Project, /// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static /// and transform-only layers render once per job (the GPU compositor then uploads them - /// once). Dropped with the job. - cache: LayerCache, + /// once). One per compositor; the job's own (dropped with it) or a Render Queue run's. + caches: Caches<'a>, + /// The compositor the job renders on, once chosen ([`Cx::backend`]). + choice: Mutex>, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -177,6 +215,23 @@ pub(crate) struct Cx<'a> { written: Mutex>, } +/// A job's layer caches: its own, or those of the Render Queue run it belongs to. +enum Caches<'a> { + Own { gpu: Box, cpu: Box }, + Run(crate::JobCaches<'a>), +} + +impl Caches<'_> { + fn of(&self, r: crate::JobRenderer) -> &LayerCache { + match (self, r) { + (Caches::Own { gpu, .. }, crate::JobRenderer::Gpu) => gpu, + (Caches::Own { cpu, .. }, crate::JobRenderer::Cpu) => cpu, + (Caches::Run(c), crate::JobRenderer::Gpu) => c.gpu, + (Caches::Run(c), crate::JobRenderer::Cpu) => c.cpu, + } + } +} + impl<'a> std::ops::Deref for Cx<'a> { type Target = Job<'a>; fn deref(&self) -> &Job<'a> { @@ -222,10 +277,85 @@ impl<'a> Cx<'a> { // layer and effect quantises at it), not just the finished frame (#2022). project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); - let cache = LayerCache::default(); + let caches = match job.options.caches { + Some(c) => Caches::Run(c), + None => Caches::Own { gpu: Box::new(LayerCache::new(CACHE_BUDGET)), cpu: Box::new(LayerCache::new(CACHE_BUDGET)) }, + }; // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. - cache.set_gate(job.accel.and_then(|a| a.miss_gate())); - Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } + for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] { + caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate())); + } + let cx = Cx { job, project, caches, choice: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }; + if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) { + cx.decide(c); + } + cx + } + + /// The project's compositor: the GPU's for Mercury GPU Acceleration with an accelerator. + fn project_renderer(&self) -> crate::JobRenderer { + if self.project.settings.gpu_acceleration && self.job.accel.is_some() { crate::JobRenderer::Gpu } else { crate::JobRenderer::Cpu } + } + + /// The job's compositor when it is decided without measuring: the queue item's first + /// output module's, the project's when there is nothing to choose between (Software Only, + /// no accelerator, deferred readbacks), or `env` ([`RENDERER_ENV`]). `None`: measure. + fn fixed_choice(&self, env: Option<&str>) -> Option { + let fixed = |renderer, why| Some(Choice { renderer, gpu_ms: None, cpu_ms: None, why }); + if self.project_renderer() == crate::JobRenderer::Cpu { + return fixed(crate::JobRenderer::Cpu, "the project's renderer"); + } + if self.deferred() { + return fixed(crate::JobRenderer::Gpu, "the project's renderer"); + } + let shared = self.job.options.renderer.and_then(|s| *s.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + let shared = match shared { + Some(Backend::Gpu) => Some(crate::JobRenderer::Gpu), + Some(Backend::Cpu) => Some(crate::JobRenderer::Cpu), + Some(Backend::Auto) | None => None, + }; + if let Some(r) = shared { + return Some(Choice { renderer: r, gpu_ms: None, cpu_ms: None, why: "as the queue item's first output module" }); + } + match env.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("gpu") => fixed(crate::JobRenderer::Gpu, "set by EFFECTCRAFT_RQ_RENDERER"), + Some("cpu") => fixed(crate::JobRenderer::Cpu, "set by EFFECTCRAFT_RQ_RENDERER"), + None | Some("" | "auto") => None, + Some(v) => { + log::warn!(target: "effectcraft_export", "{RENDERER_ENV}={v:?} is not gpu, cpu or auto: measuring"); + None + } + } + } + + /// Settle the job's compositor (once): remember it for the queue item's other output + /// modules and say so in the log. + fn decide(&self, c: Choice) { + let mut slot = self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.is_some() { + return; + } + *slot = Some(c); + if let Some(shared) = self.job.options.renderer { + let mut shared = shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if shared.is_none_or(|b| b == Backend::Auto) { + *shared = Some(match c.renderer { + crate::JobRenderer::Gpu => Backend::Gpu, + crate::JobRenderer::Cpu => Backend::Cpu, + }); + } + } + log::info!(target: "effectcraft_export", "{}: {}", self.job.options.label, describe_choice(&c)); + } + + /// The job's choice, once made. + pub(crate) fn choice(&self) -> Option { + *self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The compositor frames render on: the choice, or the project's until it is made. + fn backend(&self) -> crate::JobRenderer { + self.choice().map_or_else(|| self.project_renderer(), |c| c.renderer) } pub fn comp(&self) -> Option<&Comp> { @@ -234,12 +364,22 @@ impl<'a> Cx<'a> { /// The renderer of the job's frames: the Render Settings' options, the job's accelerator /// and layer cache. + #[cfg(test)] fn renderer(&self) -> Renderer<'_> { + self.renderer_on(self.backend()) + } + + /// [`Cx::renderer`] on compositor `on`, with that compositor's layer cache. + fn renderer_on(&self, on: crate::JobRenderer) -> Renderer<'_> { let s = self.job.settings; - // The whole job renders on the project's renderer (Mercury GPU Acceleration or Software - // Only), not on whichever compositor Auto timed faster for each frame: the CPU and GPU - // compositors differ slightly, so a per-frame choice made renders differ run to run. - let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; + // The whole job renders on one compositor (the project's, or the faster one measured on + // the first frames, #3335), not on whichever compositor Auto timed faster for each + // frame: the CPU and GPU compositors differ slightly, so a per-frame choice made renders + // differ within a render. + let backend = match on { + crate::JobRenderer::Gpu => Backend::Gpu, + crate::JobRenderer::Cpu => Backend::Cpu, + }; let opts = RenderOpts { scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, @@ -258,13 +398,13 @@ impl<'a> Cx<'a> { let mut r = Renderer::new(&self.project, self.job.footage, opts); r.expr = self.job.expr; r.accel = self.job.accel; - r.cache = Some(&self.cache); + r.cache = Some(self.caches.of(on)); r } - async fn render_at(&self, t: Tick) -> Image { + async fn render_at(&self, t: Tick, on: crate::JobRenderer) -> Image { // With deferred GPU readbacks (a browser worker) the frame renders in passes. - effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await + effectcraft_render::passes::comp_frame(&self.renderer_on(on), self.job.comp, t).await } /// Whether frames render in passes (an accelerator with deferred readbacks). @@ -283,10 +423,91 @@ impl<'a> Cx<'a> { } return out; } + self.render_frames(ks, |k, on| self.frame_on(comp, k, on), f) + } + + /// `render` then `encode` over the frames `ks` on the job's compositor, results in `ks` + /// order. The first call of a job that still has to choose its compositor measures both on + /// its first frames ([`Cx::probe`]) and keeps the faster one's. + fn render_frames( + &self, + ks: Vec, + render: impl Fn(u64, crate::JobRenderer) -> I + Sync + Send, + encode: impl Fn(u64, I) -> T + Sync + Send, + ) -> Vec { + use rayon::prelude::*; + let probed = self.probe(&ks, &render); // The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the // CPU, in parallel or under the CPU cap (#1615), which holds throughout. let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f) + let on = self.backend(); + if probed.is_empty() { + return map_frames_while(ks, self.inflight(), gpu_live, |k| render(k, on), encode); + } + let rest: Vec = ks.iter().copied().filter(|k| !probed.iter().any(|(p, _)| p == k)).collect(); + let mut done: Vec<(u64, T)> = probed.into_par_iter().map(|(k, i)| (k, encode(k, i))).collect(); + done.extend(rest.iter().copied().zip(map_frames_while(rest.clone(), self.inflight(), gpu_live, |k| render(k, on), &encode))); + // Back in `ks` order. + let mut out = Vec::with_capacity(ks.len()); + for k in &ks { + if let Some(i) = done.iter().position(|(d, _)| d == k) { + out.push(done.swap_remove(i).1); + } + } + out + } + + /// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the + /// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel + /// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap + /// and the CPU the [`PROBE_FRAMES`] after them in parallel. The CPU renders more frames at + /// once in the job than here, so the measurement leans towards the GPU, as the margin of + /// [`pick_renderer`] does: the CPU is chosen only when clearly faster. Returns + /// the frames the faster one rendered (the slower one's are rendered again); empty when + /// the choice was made already or is made without measuring (a short job). + fn probe(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> { + if self.choice().is_some() { + return vec![]; + } + let total = self.comp().map_or(0, |c| self.job.settings.frame_count(c)); + if total < PROBE_MIN_JOB { + self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" }); + return vec![]; + } + // Small batches (few threads) measure one frame each; a batch too small for even that + // (a resumed sequence skipping frames) renders on the GPU and leaves it to the next. + let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2); + if n == 0 { + return vec![]; + } + let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..1 + 2 * n)) else { return vec![] }; + let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len()); + let cpu_cap = self.inflight().cpu; + let timed = |on: crate::JobRenderer, frames: &[u64], caps: Inflight| { + let warm = render(first, on); + let t0 = Instant::now(); + let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); + let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i)); + let ms = t0.elapsed().as_secs_f64() * 1e3 / frames.len().max(1) as f64; + (std::iter::once((first, warm)).chain(items).collect::>(), ms) + }; + let log0 = logged(); + let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks, Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }); + let log1 = logged(); + let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks, Inflight { gpu: None, cpu: cpu_cap }); + // A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice. + let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms); + let renderer = pick_renderer(gpu_ms, Some(cpu_ms)); + // The per-frame log keeps the kept frames' times only. + if let Some(ft) = &self.frame_times { + let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let (a, b) = if renderer == crate::JobRenderer::Gpu { (log1, v.len()) } else { (log0, log1) }; + if a <= b && b <= v.len() { + v.drain(a..b); + } + } + self.decide(Choice { renderer, gpu_ms, cpu_ms: Some(cpu_ms), why: "measured on the first frames" }); + if renderer == crate::JobRenderer::Gpu { gpu } else { cpu } } /// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the @@ -338,9 +559,15 @@ impl<'a> Cx<'a> { /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every /// frame in flight holds its own working textures, so a whole batch rendered at once can /// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the - /// frames render on the CPU: no accelerator, or a Software Only project. + /// frames render on the CPU: no accelerator, a Software Only project, or a job that chose + /// the CPU's compositor (#3335), whose frames render in parallel. fn gpu_cap(&self) -> Option { - (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) + (self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight()) + } + + /// GPU frames in flight ([`GPU_INFLIGHT_ENV`] or [`GPU_INFLIGHT_DEFAULT`]). + fn gpu_inflight(&self) -> usize { + gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()) } /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). @@ -349,18 +576,28 @@ impl<'a> Cx<'a> { } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. + #[cfg(test)] pub fn frame(&self, comp: &Comp, i: u64) -> Image { - effectcraft_render::passes::block_on(self.frame_async(comp, i)) + self.frame_on(comp, i, self.backend()) + } + + /// [`Cx::frame`] on compositor `on`. + fn frame_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image { + effectcraft_render::passes::block_on(self.frame_async_on(comp, i, on)) } /// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes). pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image { + self.frame_async_on(comp, i, self.backend()).await + } + + async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image { let t0 = Instant::now(); let img = match self.job.settings.sample(comp, i) { - FrameSample::Progressive(t) => self.render_at(t).await, + FrameSample::Progressive(t) => self.render_at(t, on).await, FrameSample::Fields { first, second, upper_first } => { - let a = self.render_at(first).await; - if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } + let a = self.render_at(first, on).await; + if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) } } }; let img = self.crop_resize(img); @@ -374,7 +611,7 @@ impl<'a> Cx<'a> { /// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for /// frames that need nothing done after the render (progressive, at the project's depth, no /// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`]. - pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option> { + fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32, on: crate::JobRenderer) -> Option> { if self.deferred() { return None; } @@ -392,7 +629,7 @@ impl<'a> Cx<'a> { return None; } let t0 = Instant::now(); - let r = self.renderer(); + let r = self.renderer_on(on); let a = r.active_accel()?; let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?; let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize); @@ -419,12 +656,9 @@ impl<'a> Cx<'a> { fallback: impl Fn(u64, Image) -> T + Sync + Send, ) -> Vec { // As in `frames`: once the GPU has stopped for good, the rest render in parallel. - let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while( + self.render_frames( ks, - self.inflight(), - gpu_live, - |k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)), + |k, on| self.yuv420_frame(comp, k, w, h, on).ok_or_else(|| self.frame_on(comp, k, on)), |k, r| match r { Ok(p) => planes(p), Err(img) => fallback(k, img), @@ -544,6 +778,9 @@ impl<'a> Cx<'a> { Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)), Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")), } + if let Some(c) = self.choice() { + s.push_str(&format!("Renderer: {}\n", describe_choice(&c))); + } let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !over.is_empty() { s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0])); @@ -575,6 +812,20 @@ impl<'a> Cx<'a> { } } +/// The render log's account of a job's compositor: which, why, and what was measured. +pub(crate) fn describe_choice(c: &Choice) -> String { + let name = match c.renderer { + crate::JobRenderer::Gpu => "Mercury GPU Acceleration", + crate::JobRenderer::Cpu => "Mercury Software Only", + }; + let ms = |v: Option| v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms/frame")); + if c.gpu_ms.is_none() && c.cpu_ms.is_none() { + format!("{name} ({})", c.why) + } else { + format!("{name} ({}: GPU {}, CPU {})", c.why, ms(c.gpu_ms), ms(c.cpu_ms)) + } +} + /// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`. pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image { let mut out = first.clone(); @@ -860,6 +1111,40 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn a_job_that_chose_the_cpu_is_not_capped() { + // #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project + // that measured the CPU faster must not keep rendering one frame at a time. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let accel = NoAccel; + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: effectcraft_project::ItemId(0), + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + let cx = Cx::new(&job); + if std::env::var_os(RENDERER_ENV).is_none() { + assert_eq!(cx.choice(), None, "a GPU project measures first"); + assert!(cx.gpu_cap().is_some()); + } + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(117.0), cpu_ms: Some(68.0), why: "measured on the first frames" }); + assert_eq!(cx.backend(), crate::JobRenderer::Cpu); + assert_eq!(cx.gpu_cap(), None); + assert_eq!(cx.renderer().active_accel().map(|a| a.name()), None, "CPU frames don't use the accelerator"); + assert_eq!(describe_choice(&cx.choice().unwrap()), "Mercury Software Only (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); @@ -1002,7 +1287,7 @@ mod tests { let cx = Cx::new(&job); let comp = cx.comp().unwrap(); cx.frame(comp, 0); - let renders = cx.cache.stats().misses; + let renders = cx.caches.of(cx.backend()).stats().misses; assert!(renders > 0); for i in 1..10 { let img = cx.frame(comp, i); @@ -1012,7 +1297,7 @@ mod tests { uncached.cache = None; assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}"); } - let stats = cx.cache.stats(); + let stats = cx.caches.of(cx.backend()).stats(); assert_eq!(stats.misses, renders, "the solid rendered on the first frame only"); assert!(stats.hits >= 9, "{stats:?}"); } diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 02963ef46..2b285a44e 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -104,6 +104,8 @@ fn project() -> (Project, ItemId) { fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec { let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; let path = path.to_string_lossy().to_string(); + // These tests are about the accelerator's own path: no measuring against the CPU (#3335). + let gpu = std::sync::Mutex::new(Some(effectcraft_render::Backend::Gpu)); let job = Job { project: p, footage: &NoFootage, @@ -114,7 +116,7 @@ fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSett output: &om, path: &path, sink: None, - options: Default::default(), + options: effectcraft_export::JobOptions { renderer: Some(&gpu), ..Default::default() }, nested_switches: true, }; export(&job, &mut |_| true).expect("export"); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 0fd6d471b..bb46d86ba 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -501,3 +501,150 @@ fn a_job_renders_on_one_compositor() { assert_eq!(cpu.frames.load(Ordering::SeqCst), 0); assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block"); } + +/// A GPU compositor stand-in that renders the frame exactly as the CPU does, after `delay`, and +/// either as it is or solid green (`green`), counting its frames. +struct TimedAccel { + delay: std::time::Duration, + green: bool, + frames: AtomicUsize, +} + +impl TimedAccel { + fn new(delay_ms: u64, green: bool) -> TimedAccel { + TimedAccel { delay: std::time::Duration::from_millis(delay_ms), green, frames: AtomicUsize::new(0) } + } +} + +impl Accelerator for TimedAccel { + fn name(&self) -> String { + "timed".into() + } + fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option { + self.frames.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(self.delay); + let c = r.project.comp(comp)?; + Some(if self.green { Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0]) } else { r.comp_frame_cpu(comp, t) }) + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option) -> Option { + None + } +} + +/// The environment forces the compositor or the GPU frames in flight: the measured choice +/// isn't what these tests check then. +fn measurement_overridden() -> bool { + std::env::var_os(effectcraft_export::RENDERER_ENV).is_some() || std::env::var_os("EFFECTCRAFT_GPU_INFLIGHT").is_some() +} + +/// [`run_on`] on a pool of 8 threads (a batch of 8 frames: room to measure both compositors on +/// the first one, whatever the machine), returning the PNGs and the render log. +fn run_pooled(p: &Project, cid: ItemId, s: &RenderSettings, path: &str, options: JobOptions, accel: &dyn Accelerator) -> (Vec, String) { + let om = OutputModule::for_format(OutputFormat::PngSequence); + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let options = JobOptions { log: RenderLog::PlusSettings, ..options }; + let (r, files) = pool.install(|| run_on(p, cid, s, &om, path, options, (Some(accel), false))); + let n = r.expect("export").frames as usize; + let log = files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8(d.clone()).unwrap()).expect("log"); + ((0..n).map(|i| png(&files, i)).collect(), log) +} + +#[test] +fn a_transfer_bound_gpu_job_renders_on_the_cpu() { + // #3335: a GPU project whose GPU frames are slower than the CPU's renders the whole job on + // the CPU's frame-parallel compositor, measured on its first frames, and says so in the log. + if measurement_overridden() { + return; + } + let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + p.settings.gpu_acceleration = true; + let s = custom(0.0, 0.8); + let slow = TimedAccel::new(60, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/a_[#####].png", JobOptions::default(), &slow); + assert_eq!(frames.len(), 20); + // The GPU rendered its warm-up and timed frames only, and none of them is in the output. + assert_eq!(slow.frames.load(Ordering::SeqCst), 3); + for (i, f) in frames.iter().enumerate() { + assert_eq!(f.get_pixel(10, 44).0, [255, 0, 0, 255], "frame {i} is not the CPU's"); + } + assert!(log.contains("Renderer: Mercury Software Only (measured on the first frames: GPU "), "{log}"); + // The queue item's next output module follows the first one's choice without measuring. + let shared = Mutex::new(None); + let opts = || JobOptions { renderer: Some(&shared), ..Default::default() }; + let slow = TimedAccel::new(60, true); + run_pooled(&p, cid, &s, "/r/b_[#####].png", opts(), &slow); + assert_eq!(*shared.lock().unwrap(), Some(effectcraft_render::Backend::Cpu)); + let again = TimedAccel::new(0, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/c_[#####].png", opts(), &again); + assert_eq!(again.frames.load(Ordering::SeqCst), 0); + assert!(frames.iter().all(|f| f.get_pixel(10, 44).0 == [255, 0, 0, 255])); + assert!(log.contains("Renderer: Mercury Software Only (as the queue item's first output module)"), "{log}"); +} + +#[test] +fn a_gpu_job_that_measures_no_faster_on_the_cpu_stays_on_the_gpu() { + if measurement_overridden() { + return; + } + // A slow CPU (a large comp of eight rotating, translucent full-frame layers, composited + // anew every frame) against a GPU that only fills the frame: every frame is the GPU's. + let (mut p, cid) = project(FrameRate::new(25, 1), |p, comp| { + comp.width = 1920; + comp.height = 1080; + let big = p.add_item("Big", Label::Red, None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 1920, height: 1080, pixel_aspect: 1.0 })); + for k in 0..8 { + let mut l = build::layer(p, comp, "Big", LayerSource::Solid { item: big }, (1920, 1080), None); + l.props.prop_mut("transform/rotation").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(k as f64)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0 + k as f64))]; + l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + comp.layers.insert(0, l); + } + }); + p.settings.gpu_acceleration = true; + let s = custom(0.0, 0.6); + let fast = TimedAccel::new(0, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/d_[#####].png", JobOptions::default(), &fast); + assert_eq!(frames.len(), 15); + assert_eq!(fast.frames.load(Ordering::SeqCst), 15, "every kept frame, warm-up and measured ones included, is the GPU's"); + assert!(frames.iter().all(|f| f.pixels().all(|px| px.0 == [0, 255, 0, 255]))); + assert!(log.contains("Renderer: Mercury GPU Acceleration (measured on the first frames: GPU "), "{log}"); +} + +#[test] +fn gpu_cpu_and_measured_jobs_write_the_same_frames() { + // #3335: where the two compositors agree, a job is the same whichever renders it: the GPU + // (forced), the CPU (Software Only) or the measured choice. + if measurement_overridden() { + return; + } + let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let s = custom(0.0, 0.8); + let echo = TimedAccel::new(5, false); + p.settings.gpu_acceleration = false; + let (cpu, log) = run_pooled(&p, cid, &s, "/i/a_[#####].png", JobOptions::default(), &echo); + assert!(log.contains("Renderer: Mercury Software Only (the project's renderer)"), "{log}"); + p.settings.gpu_acceleration = true; + let gpu_slot = Mutex::new(Some(effectcraft_render::Backend::Gpu)); + let (gpu, _) = run_pooled(&p, cid, &s, "/i/b_[#####].png", JobOptions { renderer: Some(&gpu_slot), ..Default::default() }, &echo); + let (auto, _) = run_pooled(&p, cid, &s, "/i/c_[#####].png", JobOptions::default(), &echo); + assert_eq!(cpu.len(), 20); + assert_eq!(cpu, gpu); + assert_eq!(cpu, auto); +} + +#[test] +fn renderer_choice_follows_the_measurement() { + use effectcraft_export::{JobRenderer, pick_renderer}; + assert_eq!(pick_renderer(Some(117.0), Some(68.0)), JobRenderer::Cpu); + assert_eq!(pick_renderer(Some(68.0), Some(117.0)), JobRenderer::Gpu); + // Ties and noise stay on the GPU: the CPU must be clearly faster. + assert_eq!(pick_renderer(Some(100.0), Some(90.0)), JobRenderer::Gpu); + assert_eq!(pick_renderer(Some(100.0), Some(80.0)), JobRenderer::Cpu); + // Unmeasured or nonsense: the GPU, unless the GPU stopped while measuring. + assert_eq!(pick_renderer(None, None), JobRenderer::Gpu); + assert_eq!(pick_renderer(Some(f64::NAN), Some(1.0)), JobRenderer::Gpu); + assert_eq!(pick_renderer(None, Some(50.0)), JobRenderer::Cpu); +} diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index b88322316..6c9e8b6a6 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -801,7 +801,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option> { }); e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size); e.submit(); - let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?; + let out = e.g.read_buffer_after(&rb, e.last_submission(), row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?; Some(out) } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 1f04fa5b1..fb7edc812 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1198,18 +1198,99 @@ impl GpuContext { /// never consumes the readback deadline or holds the mutex across the whole timeout. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result, String> { + self.read_buffer_after(buffer, None, row, stride, height) + } + + /// [`Self::read_buffer`] waiting only for `submission` (the one that wrote `buffer`), not + /// for every submission so far: another frame's GPU work submitted meanwhile doesn't hold + /// this readback up (#3335). `None`: wait for all submitted work. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn read_buffer_after( + &self, + buffer: &wgpu::Buffer, + submission: Option, + row: usize, + stride: usize, + height: usize, + ) -> Result, String> { + self.read_mapped(buffer, submission, |view| crate::readback::packed(view, row, stride, height)) + } + + /// Map `buffer`, wait for the mapping (only for `submission` when given) and hand the + /// mapped bytes to `read`, on this thread, before unmapping: `read` copies the rows + /// straight into their destination, once (#3335). The copy happens outside `poll_lock`, so + /// it doesn't hold up other threads' readbacks the way copying inside the map callback + /// (which runs during a device poll) would. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn read_mapped( + &self, + buffer: &wgpu::Buffer, + submission: Option, + read: impl FnOnce(&[u8]) -> Result, + ) -> Result { let (tx, rx) = std::sync::mpsc::channel(); - self.map_readback(buffer, row, stride, height, move |r| { + let ticket = self.readbacks.start(move |r| { let _ = tx.send(r); + })?; + if !ticket.active() { + return Err("GPU readback cancelled".into()); + } + let b = buffer.clone(); + buffer.map_async(wgpu::MapMode::Read, .., move |r| { + // Timed out or cancelled: nobody reads the mapping. + if !ticket.active() { + b.unmap(); + return; + } + // Mapped: the waiting thread reads the rows and unmaps (an empty vector = mapped). + ticket.complete(r.map(|_| Vec::new()).map_err(|e| format!("GPU buffer mapping: {e}"))); }); + // On any failure, unmap (harmless when not mapped; cancels a pending map) so a mapped + // buffer can never be handed out again. + let fail = |e: String| { + buffer.unmap(); + e + }; + self.wait_mapped(&rx, submission).map_err(fail)?.map_err(fail)?; + let result = { + let view = buffer.get_mapped_range(..).map_err(|e| format!("GPU mapped range: {e}")); + view.and_then(|view| read(&view)) + }; + buffer.unmap(); + result + } + + /// Poll until `rx` reports the mapping (see [`Self::read_mapped`]): polling and receiving + /// share one deadline, started once `poll_lock` is first acquired and polled in + /// [`Self::POLL_SLICE`] slices so lock contention never consumes the readback deadline or + /// holds the mutex across the whole timeout. While another thread holds `poll_lock` it is + /// polling the device, which delivers this mapping too: wait for it instead of queueing. + #[cfg(not(target_arch = "wasm32"))] + fn wait_mapped( + &self, + rx: &std::sync::mpsc::Receiver, String>>, + submission: Option, + ) -> Result, String>, String> { let mut deadline = None; loop { self.readbacks.check()?; if let Ok(result) = rx.try_recv() { - return result; + return Ok(result); } let (remaining, slice, poll_res) = { - let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = match self.poll_lock.try_lock() { + Ok(g) => g, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + match rx.recv_timeout(std::time::Duration::from_millis(1)) { + Ok(result) => return Ok(result), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(e) => return Err(format!("GPU readback completion channel: {e}")), + } + // The ticket's own deadline still bounds a wait that never gets the lock. + continue; + } + }; self.readbacks.check()?; let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT); let remaining = d.saturating_duration_since(std::time::Instant::now()); @@ -1217,12 +1298,12 @@ impl GpuContext { (remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll)) } else { let slice = remaining.min(Self::POLL_SLICE); - let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) }); + let res = self.device.poll(wgpu::PollType::Wait { submission_index: submission.clone(), timeout: Some(slice) }); (remaining, slice, res) } }; if let Ok(result) = rx.try_recv() { - return result; + return Ok(result); } if remaining.is_zero() { let error = "GPU readback receive timed out; context retired".to_string(); @@ -1241,7 +1322,7 @@ impl GpuContext { } // The callback may be running on another thread that polled the same device. match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) { - Ok(result) => return result, + Ok(result) => return Ok(result), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(e) => return Err(format!("GPU readback completion channel: {e}")), } @@ -1253,6 +1334,28 @@ impl GpuContext { Err("synchronous GPU readback is unavailable in the browser".into()) } + #[cfg(target_arch = "wasm32")] + pub(crate) fn read_buffer_after( + &self, + _buffer: &wgpu::Buffer, + _submission: Option, + _row: usize, + _stride: usize, + _height: usize, + ) -> Result, String> { + Err("synchronous GPU readback is unavailable in the browser".into()) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn read_mapped( + &self, + _buffer: &wgpu::Buffer, + _submission: Option, + _read: impl FnOnce(&[u8]) -> Result, + ) -> Result { + Err("synchronous GPU readback is unavailable in the browser".into()) + } + /// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for. pub fn set_deferred(&mut self, on: bool) { self.readbacks.cancel_pending("GPU readback mode changed".into()); @@ -1952,6 +2055,11 @@ impl<'g> Enc<'g> { dst } + /// The last submission of this encoder's work (what a readback after it waits for). + pub(crate) fn last_submission(&self) -> Option { + self.last_submit.clone() + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { @@ -1994,13 +2102,13 @@ impl<'g> Enc<'g> { let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size) .map_err(|e| log::error!("gpu image readback: {e}")) .ok()?; - let mut data = Vec::new(); - data.try_reserve_exact(layout.len / 16).map_err(|e| log::error!("gpu image allocation: {e}")).ok()?; - data.resize(layout.len / 16, [0.0; 4]); - let mut out = Image { width: img.width, height: img.height, data }; - let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data); - self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?; - Some(out) + // The mapped rows go straight into the image's pixels: one copy (#3335). + let data: Vec<[f32; 4]> = self.read_texture_with(&img.texture, [0, 0, img.width, img.height], 16, crate::readback::rows::<[f32; 4]>)?; + if data.len() != layout.len / 16 { + log::error!("gpu image readback size mismatch"); + return None; + } + Some(Image { width: img.width, height: img.height, data }) } /// Tightly packed texel bytes of a texture (`bpp` bytes per pixel). @@ -2013,27 +2121,32 @@ impl<'g> Enc<'g> { pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option> { let layout = crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - let mut out = Vec::new(); - out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?; - out.resize(layout.len, 0); - self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?; + let out: Vec = self.read_texture_with(texture, [x, y, w, h], bpp, crate::readback::rows::)?; + if out.len() != layout.len { + log::error!("gpu texture readback size mismatch"); + return None; + } Some(out) } - /// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer. - /// `rect` is `[x, y, w, h]`. - fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> { + /// Read `rect` (`[x, y, w, h]`) of `texture` back through a reused staging buffer: `read` + /// gets the mapped bytes, the row length, the padded row stride and the row count, and + /// copies the rows into their destination itself (one copy, #3335). Waits only for this + /// encoder's own submission, so frames rendering on other threads don't delay it. + fn read_texture_with( + &mut self, + texture: &wgpu::Texture, + rect: [u32; 4], + bpp: u32, + read: impl FnOnce(&[u8], usize, usize, usize) -> Result, + ) -> Option { let [x, y, w, h] = rect; if !self.g.can_wait() { return None; } - self.g.transfers.count(false, out.len()); let layout = crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - if out.len() != layout.len { - log::error!("gpu readback destination size mismatch"); - return None; - } + self.g.transfers.count(false, layout.len); let row = layout.row as usize; let padded = layout.stride; let size = layout.size; @@ -2051,12 +2164,11 @@ impl<'g> Enc<'g> { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); self.submit(); - let bytes = self.g.read_buffer(&buf, row, padded as usize, h as usize).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - if bytes.len() != out.len() { - log::error!("gpu texture readback size mismatch"); - return None; - } - out.copy_from_slice(&bytes); + let out = self + .g + .read_mapped(&buf, self.last_submit.clone(), |view| read(view, row, padded as usize, h as usize)) + .map_err(|e| log::error!("gpu texture readback: {e}")) + .ok()?; // Keep a few staging buffers per size (frames read back at the same size every time). // Layers whose bounds change every frame read back at a new size each time: drop the // other sizes once over budget, or mapped host memory grows for the whole render (#2081). @@ -2070,7 +2182,7 @@ impl<'g> Enc<'g> { v.push(buf); } } - Some(()) + Some(out) } /// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done` diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index b9d0a28a8..290ae08de 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -367,20 +367,32 @@ thread_local! { /// Validate every row before publishing tightly packed bytes. pub(crate) fn packed(view: &[u8], row: usize, stride: usize, height: usize) -> Result, Error> { - if row == 0 || height == 0 || stride < row { + rows(view, row, stride, height) +} + +/// The `height` rows of `row` bytes each, `stride` bytes apart in the mapped `view`, as tightly +/// packed `T`s: one copy, straight from the mapped rows into a vector reserved once (no +/// zero-filled buffer first, no intermediate byte vector). Every row is validated before any +/// is copied. A row that is not aligned for `T` is decoded value by value (same bytes). +pub(crate) fn rows(view: &[u8], row: usize, stride: usize, height: usize) -> Result, Error> { + let unit = std::mem::size_of::().max(1); + if row == 0 || height == 0 || stride < row || !row.is_multiple_of(unit) { return Err("invalid GPU readback layout".into()); } - let size = row.checked_mul(height).ok_or("GPU readback output size overflow")?; + let size = row.checked_mul(height).ok_or("GPU readback output size overflow")? / unit; let end = (height - 1).checked_mul(stride).and_then(|s| s.checked_add(row)).ok_or("GPU readback mapped size overflow")?; if view.len() < end { return Err(format!("short GPU readback: {} bytes, needs {end}", view.len())); } - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); out.try_reserve_exact(size).map_err(|e| format!("allocating GPU readback output: {e}"))?; for y in 0..height { let start = y.checked_mul(stride).ok_or("GPU readback row overflow")?; - let bytes = view.get(start..start + row).ok_or("short GPU readback row")?; - out.extend_from_slice(bytes); + let bytes = start.checked_add(row).and_then(|e| view.get(start..e)).ok_or("short GPU readback row")?; + match bytemuck::try_cast_slice::(bytes) { + Ok(values) => out.extend_from_slice(values), + Err(_) => out.extend(bytes.chunks_exact(unit).map(bytemuck::pod_read_unaligned::)), + } } Ok(out) } @@ -658,4 +670,28 @@ mod tests { assert!(packed(&[0; 7], 4, 4, 2).is_err()); assert_eq!(packed(&[1, 2, 0, 0, 3, 4], 2, 4, 2).unwrap(), vec![1, 2, 3, 4]); } + + /// #3335: rows go from the mapped buffer straight into the destination type, skipping the + /// row padding, whether or not the mapped bytes are aligned for it. + #[test] + fn rows_copy_once_into_the_destination_type() { + // Two rows of two RGBA f32 pixels, padded to a 48-byte stride. + let px = |i: f32| [i, i + 0.25, i + 0.5, 1.0]; + let want = vec![px(1.0), px(2.0), px(3.0), px(4.0)]; + let mut view = Vec::new(); + for r in want.chunks(2) { + view.extend_from_slice(bytemuck::cast_slice(r)); + view.extend_from_slice(&[0xAB; 16]); + } + assert_eq!(rows::<[f32; 4]>(&view, 32, 48, 2).unwrap(), want); + // The same bytes one byte off any f32 alignment decode the same. + let mut shifted = vec![0u8]; + shifted.extend_from_slice(&view); + assert_eq!(rows::<[f32; 4]>(&shifted[1..], 32, 48, 2).unwrap(), want); + // A row that isn't whole pixels, a short last row and a stride under the row fail. + assert!(rows::<[f32; 4]>(&view, 30, 48, 2).is_err()); + assert!(rows::<[f32; 4]>(&view[..view.len() - 17], 32, 48, 2).is_err()); + assert!(rows::<[f32; 4]>(&view, 32, 16, 2).is_err()); + assert_eq!(rows::(&[1, 2, 9, 3, 4], 2, 3, 2).unwrap(), vec![1, 2, 3, 4]); + } } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b88bac0be..37fcd42d3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1247,6 +1247,49 @@ fn readback_staging_stays_bounded_across_sizes() { assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes"); } +/// #3335: a readback copies the mapped rows straight into the image (no zero-filled buffer, +/// no intermediate byte vector), skipping each row's padding: odd widths, sub-rectangles and +/// readbacks from several threads at once (each waiting for its own submission) return the +/// uploaded pixels exactly. +#[test] +fn single_copy_readback_returns_the_exact_pixels() { + let Some(g) = gpu() else { return }; + if !g.ctx.can_wait() { + return; + } + let image = |w: u32, h: u32, seed: f32| { + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = i as f32 * 0.37 + seed; + *p = [v, -v, v * 1e-3, 1.0 / (1.0 + v)]; + } + img + }; + // 37 × 16 bytes = 592 per row: padded to 768 in the staging buffer. + let src = image(37, 5, 0.5); + let up = g.ctx.upload_image(&src).expect("upload"); + let mut e = crate::context::Enc::new(&g.ctx); + let back = e.download(&up).expect("download"); + assert_eq!((back.width, back.height), (37, 5)); + assert_eq!(back.data, src.data); + let rect = e.read_rect(&up.texture, 3, 1, 5, 2, 16).expect("read_rect"); + let want: Vec<[f32; 4]> = (1..3).flat_map(|y| (3..8).map(move |x| (x, y))).map(|(x, y)| src.data[src.idx(x, y)]).collect(); + assert_eq!(rect, bytemuck::cast_slice::<[f32; 4], u8>(&want)); + // Frames on several threads, each with its own encoder and submission. + std::thread::scope(|s| { + for t in 0..4 { + s.spawn(move || { + for k in 0..3 { + let src = image(61 + t, 7 + k, (t * 10 + k) as f32); + let up = g.ctx.upload_image(&src).expect("upload"); + let back = crate::context::Enc::new(&g.ctx).download(&up).expect("download"); + assert_eq!(back.data, src.data, "thread {t} frame {k}"); + } + }); + } + }); +} + /// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key /// across frames (the footprint is the source through the masks, not the effects), so the GPU /// uploads nothing after the first frame; matches the CPU. diff --git a/crates/gpu/src/yuv.rs b/crates/gpu/src/yuv.rs index 27df90110..978ee4d5d 100644 --- a/crates/gpu/src/yuv.rs +++ b/crates/gpu/src/yuv.rs @@ -147,7 +147,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option> { let stg = record(e, yp, img, bg)?; let n = planes_len(img.width, img.height) as usize; - e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok() + e.g.read_buffer_after(&stg, e.last_submission(), n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok() } #[cfg(test)] diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 74fa8348d..6c5f417ce 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -62,6 +62,8 @@ impl Exporter for FileExporter { label: job.label.clone(), storage: job.storage, overflow: job.project.render_prefs.overflow_folders.clone(), + caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), + renderer: job.renderer, }, nested_switches: job.nested_switches, }; diff --git a/crates/ui-egui/examples/rq_bench.rs b/crates/ui-egui/examples/rq_bench.rs new file mode 100644 index 000000000..d290210e4 --- /dev/null +++ b/crates/ui-egui/examples/rq_bench.rs @@ -0,0 +1,141 @@ +//! Render Queue benchmark (#3335): a 1080p 32 bpc job on the GPU, the CPU or the measured choice. +//! +//! `cargo run --release -p effectcraft-ui-egui --example rq_bench -- [options]` +//! +//! It builds a comp like a compositing shot: a 1920 × 1080 OpenEXR sequence (float RGBA with a +//! Cryptomatte ID channel and manifest, generated once into `target/rq-bench/media`, no +//! committed media) under Gaussian Blur, Curves and the Cryptomatte effect and a moving +//! translucent solid, at 32 bpc. It queues the comp twice (the second item shows a warm start) and +//! renders the queue as the app does, printing each item's milliseconds per frame and a hash of +//! the written pixels (equal hashes: identical output). +//! +//! Options: `--renderer gpu|cpu` (the project's renderer, default gpu; with gpu the Render +//! Queue measures both unless `EFFECTCRAFT_RQ_RENDERER` says otherwise), `--frames N` (default +//! 60), `--format png|exr|tiff` (default png), `--out DIR` (default `target/rq-bench/out`). + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::{Value, json}; + +const W: usize = 1920; +const H: usize = 1080; + +fn arg(name: &str) -> Option { + let a: Vec = std::env::args().collect(); + a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// The plate's object at pixel (x, y) in frame `f`: 24 bands that drift sideways. +fn object(x: usize, y: usize, f: usize) -> String { + format!("obj{}", ((x + f * 7) / 80 + y / 270 * 24) % 96) +} + +/// Frame `f` of the plate: float colour over 1.0 (32 bpc), alpha, and Cryptomatte IDs. +fn plate(path: &std::path::Path, f: usize) { + use exr::prelude::*; + let px = |c: usize| -> Vec { + (0..W * H) + .map(|i| { + let (x, y) = (i % W, i / W); + let v = ((x as f32 * 0.003 + f as f32 * 0.05 + c as f32).sin() * 0.5 + 0.5) * (1.0 + y as f32 / H as f32); + if c == 3 { 1.0 } else { v } + }) + .collect() + }; + let ids: Vec = (0..W * H).map(|i| crypto_float(crypto_hash(&object(i % W, i / W, f)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let channels = AnyChannels::sort( + vec![ch("R", px(0)), ch("G", px(1)), ch("B", px(2)), ch("A", px(3)), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", vec![1.0; W * H])].into(), + ); + let mut layer = Layer::new((W, H), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..96).map(|k| format!("obj{k}")).map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))).collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + Image::from_layer(layer).write().to_file(path).expect("write plate"); +} + +/// FNV-1a over every written frame file, in name order (equal: identical output). +fn hash_dir(dir: &std::path::Path) -> (usize, u64) { + let mut names: Vec<_> = std::fs::read_dir(dir).map(|d| d.filter_map(|e| e.ok()).map(|e| e.path()).collect()).unwrap_or_default(); + names.retain(|p| !p.to_string_lossy().ends_with(".txt")); + names.sort(); + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in &names { + for b in std::fs::read(p).unwrap_or_default() { + h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3); + } + } + (names.len(), h) +} + +fn main() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/rq-bench"); + let frames: usize = arg("--frames").and_then(|n| n.parse().ok()).unwrap_or(60); + let format = arg("--format").unwrap_or_else(|| "png".into()); + let gpu = arg("--renderer").as_deref() != Some("cpu"); + let out = arg("--out").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("out")); + let media = root.join("media"); + std::fs::create_dir_all(&media).expect("media folder"); + let first = media.join("plate_0000.exr"); + for f in 0..frames { + let p = media.join(format!("plate_{f:04}.exr")); + if !p.exists() { + if f == 0 { + eprintln!("writing the {frames}-frame EXR plate (once)…"); + } + plate(&p, f); + } + } + + let mut s = effectcraft_host::session(); + let accel = effectcraft_gpu::Gpu::try_headless().unwrap_or_else(|e| panic!("no GPU: {e}")); + eprintln!("GPU: {}", effectcraft_engine::render::Accelerator::name(&accel)); + s.accel = Some(Arc::new(accel)); + let r = run(&mut s, "file.import", json!({"paths": [first.to_string_lossy()], "sequence": true})); + let footage = r["items"][0].as_u64().expect("the plate"); + let secs = frames as f64 / 30.0; + run(&mut s, "comp.new", json!({"name": "Shot", "width": W, "height": H, "frameRate": 30, "duration": secs})); + let plate = run(&mut s, "layer.addItem", json!({"item": footage}))["layer"].clone(); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Gaussian Blur"})); + run(&mut s, "prop.set", json!({"layer": plate, "path": "effects/#1/blurriness", "value": 12.0})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Curves"})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Cryptomatte"})); + let solid = run(&mut s, "layer.newSolid", json!({"color": "#d04020", "width": 400, "height": 300}))["layer"].clone(); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": 0.0, "value": [200.0, 540.0]})); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": secs, "value": [1720.0, 540.0]})); + run(&mut s, "prop.set", json!({"layer": solid, "path": "transform/opacity", "value": 70.0})); + let mut p = (*s.project).clone(); + p.settings.bit_depth = effectcraft_engine::project::BitDepth::Bpc32; + p.settings.gpu_acceleration = gpu; + s.project = Arc::new(p); + + let _ = std::fs::remove_dir_all(&out); + let dirs = [out.join("a"), out.join("b")]; + for d in &dirs { + std::fs::create_dir_all(d).expect("output folder"); + let path = d.join(format!("f_[####].{format}")); + run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy()})); + } + let t0 = std::time::Instant::now(); + run(&mut s, "renderQueue.render", json!({"wait": true})); + let total = t0.elapsed().as_secs_f64(); + println!( + "renderer {} (EFFECTCRAFT_RQ_RENDERER={:?}, EFFECTCRAFT_GPU_INFLIGHT={:?}), {frames} frames {W}x{H} 32 bpc, {format}", + if gpu { "gpu" } else { "cpu" }, + std::env::var("EFFECTCRAFT_RQ_RENDERER").ok(), + std::env::var("EFFECTCRAFT_GPU_INFLIGHT").ok() + ); + for (i, (item, d)) in s.project.render_queue.iter().zip(&dirs).enumerate() { + let secs = item.render_time.unwrap_or(f64::NAN); + let (n, h) = hash_dir(d); + println!(" item {}: {:?}, {:.1} ms/frame, {n} files, hash {h:016x}", i + 1, item.status, secs * 1000.0 / frames as f64); + } + println!(" queue: {total:.2} s"); +} From e0154351f372cfa0d7c65bf05ede0848643613ef Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 01:18:49 +0100 Subject: [PATCH 06/18] #3335: time the CPU on up to eight frames in parallel when choosing the renderer --- crates/export/src/pipeline.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index ed813e1da..46fc7b785 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -42,9 +42,12 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { /// `auto` (measure, the default). pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER"; -/// Frames each compositor renders, timed, when a job measures which is faster (after one +/// Frames the GPU renders, timed, when a job measures which compositor is faster (after one /// untimed warm-up frame each: layer cache misses, uploads, kernel builds). const PROBE_FRAMES: usize = 2; +/// Frames the CPU renders at once, timed, in that measurement: its throughput comes from +/// rendering several frames in parallel, which two frames understate. +const PROBE_CPU_FRAMES: usize = 8; /// Jobs shorter than this render on the project's compositor without measuring: the frames /// the slower one renders for the measurement would cost more than they could save. const PROBE_MIN_JOB: u64 = 12; @@ -460,9 +463,10 @@ impl<'a> Cx<'a> { /// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the /// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel /// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap - /// and the CPU the [`PROBE_FRAMES`] after them in parallel. The CPU renders more frames at - /// once in the job than here, so the measurement leans towards the GPU, as the margin of - /// [`pick_renderer`] does: the CPU is chosen only when clearly faster. Returns + /// and the CPU up to [`PROBE_CPU_FRAMES`] after them in parallel (its throughput). The CPU + /// may render more frames at once in the job than here, so the measurement leans towards + /// the GPU, as the margin of [`pick_renderer`] does: the CPU is chosen only when clearly + /// faster. Returns /// the frames the faster one rendered (the slower one's are rendered again); empty when /// the choice was made already or is made without measuring (a short job). fn probe(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> { @@ -480,7 +484,9 @@ impl<'a> Cx<'a> { if n == 0 { return vec![]; } - let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..1 + 2 * n)) else { return vec![] }; + let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else { + return vec![]; + }; let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len()); let cpu_cap = self.inflight().cpu; let timed = |on: crate::JobRenderer, frames: &[u64], caps: Inflight| { From 74ac5a2f2678539e97f75b13b80c7dff83ab3058 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 02:04:15 +0100 Subject: [PATCH 07/18] #3335: choose the GPU frames in flight from measured GPU memory, with a Memory & CPU setting --- crates/engine/src/cmd/render_queue.rs | 1 + crates/engine/src/cmd/timeline.rs | 1 + crates/engine/src/render_queue.rs | 7 + crates/engine/src/rq_tests.rs | 31 +++ crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 16 ++ crates/export/src/lib.rs | 5 +- crates/export/src/pipeline.rs | 236 +++++++++++++++++-- crates/gpu/src/context.rs | 19 ++ crates/gpu/src/lib.rs | 5 + crates/gpu/src/memcap.rs | 19 ++ crates/host/src/lib.rs | 1 + crates/render/src/accel.rs | 22 ++ crates/render/src/lib.rs | 2 +- crates/ui-egui/examples/rq_bench.rs | 10 +- crates/ui-egui/src/i18n/ui.rs | 5 + docs/preferences.md | 8 + 17 files changed, 361 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index e9ce1ffab..9881f578f 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -866,6 +866,7 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { caches: None, renderer: None, + gpu_frames: s.prefs.memory.gpu_frames(), }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index df804f2ef..691f373de 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -148,6 +148,7 @@ fn prerender( caches: None, renderer: None, + gpu_frames: None, }; let r = exporter.export(&job, progress)?; Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start }) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 8e50ad3b5..7c10af40c 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -41,6 +41,8 @@ pub struct ExportJob<'a> { /// The compositor the queue item's output modules render on (#3335): the first module /// chooses (measuring a GPU project's first frames) and stores it here, the others follow. pub renderer: Option<&'a Mutex>>, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, `None` for Auto. + pub gpu_frames: Option, } /// What the jobs of one Render Queue run keep between them (#3335): a layer cache per @@ -246,6 +248,8 @@ struct Work { items: Vec>, /// Settings ▸ General ▸ Switches Affect Nested Comps. nested_switches: bool, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once. + gpu_frames: Option, } /// Where a relative render output goes when there is no default folder and the project is unsaved: @@ -341,6 +345,7 @@ pub(crate) async fn run_items( storage: s.storage_quota.clone(), items, nested_switches: s.prefs.general.switches_affect_nested_comps, + gpu_frames: s.prefs.memory.gpu_frames(), }; run_work(work, shared, notify).await; } @@ -389,6 +394,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared nested_switches: w.nested_switches, caches: Some(&caches), renderer: Some(&renderer), + gpu_frames: w.gpu_frames, }; let rr = w .exporter @@ -649,6 +655,7 @@ impl Session { storage: self.storage_quota.clone(), items, nested_switches: self.prefs.general.switches_affect_nested_comps, + gpu_frames: self.prefs.memory.gpu_frames(), }; let shared = Arc::new(JobShared::default()); if wait { diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 18e3da711..60fc2b2c3 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -306,3 +306,34 @@ fn a_queue_run_keeps_its_jobs_warm() { assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice"); assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run"); } + +#[test] +fn the_gpu_frames_setting_reaches_every_render_queue_job() { + // #3335: Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once is Auto (`None`) or 1–3. + struct Seen(Mutex>>); + impl Exporter for Seen { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + self.0.lock().unwrap().push(job.gpu_frames); + Ok(ExportResult { path: job.path.to_string(), ..Default::default() }) + } + } + let mut s = demo(); + let ex = Arc::new(Seen(Mutex::new(vec![]))); + s.exporter = Some(ex.clone()); + assert_eq!(s.prefs.memory.gpu_frames_in_flight, "auto"); + let pages = crate::settings::prefs::pages_json(); + assert!(pages.to_string().contains("memory.gpuFramesInFlight"), "the Memory & CPU page lists it"); + for (value, want) in [("auto", None), ("1", Some(1)), ("2", Some(2)), ("3", Some(3))] { + s.execute("prefs.set", json!({"key": "memory.gpuFramesInFlight", "value": value})).unwrap(); + assert_eq!(s.prefs.memory.gpu_frames(), want, "{value}"); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out(&format!("rq/frames_{value}.mp4"))})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(ex.0.lock().unwrap().last().copied(), Some(want), "{value}"); + } + // A hand-edited settings file's nonsense is Auto. + s.prefs.memory.gpu_frames_in_flight = "9".into(); + assert_eq!(s.prefs.memory.gpu_frames(), None); +} diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index b00a69ed3..1e79cdd46 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -310,6 +310,7 @@ pub fn pages() -> Vec { s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true), + s("memory.gpuFramesInFlight", "GPU Frames Rendered at Once", Kind::Choice(&[("Auto", "auto"), ("1", "1"), ("2", "2"), ("3", "3")]), true), ], }, Page { diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ac2fab1cc..328f93bf4 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -201,8 +201,24 @@ page!(Memory { /// RAM preview (viewer frame) cache budget (MB). preview_cache_mb: u32 = 3072, reduce_cache_when_low: bool = true, + /// GPU Frames Rendered at Once (Render Queue): `auto` (from the GPU memory a frame + /// measured, 1 to 3) or `1`, `2`, `3`. `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + gpu_frames_in_flight: String = "auto".into(), }); +impl Memory { + /// [`Memory::gpu_frames_in_flight`] as a count: 1 to 3, or `None` for Auto (and anything + /// else, as a hand-edited settings file may hold). + pub fn gpu_frames(&self) -> Option { + match self.gpu_frames_in_flight.trim() { + "1" => Some(1), + "2" => Some(2), + "3" => Some(3), + _ => None, + } + } +} + page!(Video { enable_output: bool = false, device: String = String::new(), diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e7ffc39ef..d9c315558 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -53,7 +53,7 @@ pub use effectcraft_project::render_queue; pub use effectcraft_project::render_queue::StorageQuota; pub use out::Sink; pub(crate) use pipeline::Cx; -pub use pipeline::{CACHE_BUDGET, RENDERER_ENV, pick_renderer}; +pub use pipeline::{CACHE_BUDGET, GPU_INFLIGHT_MAX, RENDERER_ENV, auto_gpu_frames, pick_renderer}; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -119,6 +119,9 @@ pub struct JobOptions<'a> { /// the compositor it chose there, so the item's other output modules encode the same /// frames. `None`: the job decides alone. pub renderer: Option<&'a std::sync::Mutex>>, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from + /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + pub gpu_frames: Option, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 46fc7b785..649235486 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -13,30 +13,68 @@ use web_time::Instant; use crate::{Job, RenderQuality, out}; -/// GPU frames rendered at once by default: one, as the export scheduler's single GPU worker -/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV -/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one, -/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU. -/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every -/// platform, so a portable estimate is not available here. +/// GPU frames rendered at once until a frame has measured the device memory, and whenever it +/// can't be measured (#898, #879: measured on the #879 project at 1080p on an 8 GB card, 6.3 GB +/// peak with one frame, 7.0 to 7.8 GB with two, the whole card with three). pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1; -/// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1. +/// The most GPU frames Auto renders at once (#3335: four and more measured no faster). +pub const GPU_INFLIGHT_MAX: usize = 3; +/// Overrides Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: a whole number of frames, +/// at least 1. pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT"; /// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or -/// the default (with a warning when a value was given) when it is 0, negative, not a number or -/// too large. -pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { - let Some(v) = var else { return GPU_INFLIGHT_DEFAULT }; +/// `None` (with a warning when a value was given) when it is unset, 0, negative, not a number +/// or too large. +pub(crate) fn gpu_inflight_env(var: Option<&str>) -> Option { + let v = var?; match v.trim().parse::() { - Ok(n) if n >= 1 => n, + Ok(n) if n >= 1 => Some(n), _ => { - log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}"); - GPU_INFLIGHT_DEFAULT + log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: ignored"); + None } } } +/// Auto's GPU frames in flight from the device memory after a frame (#3335): as many more +/// frames as the budget has room for beyond the allocator's peak (which holds one frame's +/// working memory) and what the caches may still grow by, 1 to [`GPU_INFLIGHT_MAX`]. Unknown +/// memory: 1. +pub fn auto_gpu_frames(m: Option) -> usize { + let Some(m) = m.filter(|m| m.budget > 0 && m.frame > 0 && m.peak > 0) else { return GPU_INFLIGHT_DEFAULT }; + let more = m.budget.saturating_sub(m.peak).saturating_sub(m.reserve) / m.frame; + usize::try_from(more.saturating_add(1)).unwrap_or(GPU_INFLIGHT_MAX).clamp(1, GPU_INFLIGHT_MAX) +} + +/// The GPU frames a job renders at once and why (for the render log): the environment's +/// override, else the setting (`pref`, `None` = Auto), else Auto from `measured` (`None`: no +/// GPU frame has rendered yet, so nothing is settled). +pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option, measured: Option>) -> Option<(usize, String)> { + if let Some(n) = gpu_inflight_env(env) { + return Some((n, format!("set by {GPU_INFLIGHT_ENV}"))); + } + if let Some(n) = pref.filter(|n| *n >= 1) { + return Some((n, "Settings".into())); + } + let m = measured?; + let n = auto_gpu_frames(m); + let mib = |b: u64| b >> 20; + Some(( + n, + match m { + Some(m) => format!( + "Auto: {} MiB per frame, {} MiB peak and {} MiB cache reserve in a {} MiB budget", + mib(m.frame), + mib(m.peak), + mib(m.reserve), + mib(m.budget) + ), + None => "Auto: the GPU's memory is not known".into(), + }, + )) +} + /// Overrides the Render Queue's measured choice of compositor for GPU projects with an /// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Mercury Software Only) or /// `auto` (measure, the default). @@ -211,6 +249,10 @@ pub(crate) struct Cx<'a> { caches: Caches<'a>, /// The compositor the job renders on, once chosen ([`Cx::backend`]). choice: Mutex>, + /// GPU frames rendered at once and why, once settled ([`Cx::gpu_inflight`]). + gpu_frames: Mutex>, + /// A frame has rendered on the GPU compositor: its memory can be measured. + gpu_rendered: std::sync::atomic::AtomicBool, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -288,7 +330,17 @@ impl<'a> Cx<'a> { for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] { caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate())); } - let cx = Cx { job, project, caches, choice: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }; + let cx = Cx { + job, + project, + caches, + choice: Mutex::new(None), + gpu_frames: Mutex::new(None), + gpu_rendered: Default::default(), + frame_times, + overflowed: Mutex::new(vec![]), + written: Mutex::new(vec![]), + }; if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) { cx.decide(c); } @@ -489,8 +541,13 @@ impl<'a> Cx<'a> { }; let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len()); let cpu_cap = self.inflight().cpu; - let timed = |on: crate::JobRenderer, frames: &[u64], caps: Inflight| { + let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); + // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + let caps = match on { + crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i)); @@ -498,9 +555,9 @@ impl<'a> Cx<'a> { (std::iter::once((first, warm)).chain(items).collect::>(), ms) }; let log0 = logged(); - let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks, Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }); + let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks); let log1 = logged(); - let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks, Inflight { gpu: None, cpu: cpu_cap }); + let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks); // A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice. let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms); let renderer = pick_renderer(gpu_ms, Some(cpu_ms)); @@ -571,9 +628,23 @@ impl<'a> Cx<'a> { (self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight()) } - /// GPU frames in flight ([`GPU_INFLIGHT_ENV`] or [`GPU_INFLIGHT_DEFAULT`]). + /// GPU frames in flight ([`resolve_gpu_frames`]): [`GPU_INFLIGHT_DEFAULT`] until settled, + /// which Auto is after the job's first GPU frame. fn gpu_inflight(&self) -> usize { - gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()) + if let Some((n, _)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return *n; + } + // Measured outside the lock (the allocator report walks every allocation). + let measured = self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed).then(|| self.job.accel.and_then(|a| a.frame_memory())); + let Some((n, why)) = resolve_gpu_frames(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref(), self.job.options.gpu_frames, measured) else { + return GPU_INFLIGHT_DEFAULT; + }; + let mut slot = self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + slot.get_or_insert_with(|| { + log::info!(target: "effectcraft_export", "{}: {n} GPU frame(s) at once ({why})", self.job.options.label); + (n, why) + }) + .0 } /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). @@ -599,6 +670,7 @@ impl<'a> Cx<'a> { async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image { let t0 = Instant::now(); + let gpu = on == crate::JobRenderer::Gpu && self.job.accel.is_some(); let img = match self.job.settings.sample(comp, i) { FrameSample::Progressive(t) => self.render_at(t, on).await, FrameSample::Fields { first, second, upper_first } => { @@ -606,6 +678,9 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) } } }; + if gpu { + self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed); + } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -644,6 +719,7 @@ impl<'a> Cx<'a> { } // A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it. a.frame_end(); + self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64())); } @@ -787,6 +863,11 @@ impl<'a> Cx<'a> { if let Some(c) = self.choice() { s.push_str(&format!("Renderer: {}\n", describe_choice(&c))); } + if self.backend() == crate::JobRenderer::Gpu + && let Some((n, why)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + { + s.push_str(&format!("GPU frames rendered at once: {n} ({why})\n")); + } let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !over.is_empty() { s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0])); @@ -1249,12 +1330,117 @@ mod tests { } #[test] - fn gpu_inflight_override_is_a_positive_number_or_the_default() { - assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); - assert_eq!(gpu_inflight(Some("3")), 3); - assert_eq!(gpu_inflight(Some(" 5 ")), 5); + fn gpu_inflight_override_is_a_positive_number_or_ignored() { + assert_eq!(gpu_inflight_env(None), None); + assert_eq!(gpu_inflight_env(Some("3")), Some(3)); + assert_eq!(gpu_inflight_env(Some(" 5 ")), Some(5)); for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] { - assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}"); + assert_eq!(gpu_inflight_env(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn auto_gpu_frames_follow_the_memory_budget() { + // #3335: Auto renders as many GPU frames at once as the budget has room for, 1 to 3. + use effectcraft_render::FrameMemory; + const GIB: u64 = 1 << 30; + let m = |budget: u64, peak: u64, frame: u64, reserve: u64| Some(FrameMemory { budget, peak, frame, reserve }); + // Unknown memory (no allocator report, no budget): one frame. + assert_eq!(auto_gpu_frames(None), 1); + assert_eq!(auto_gpu_frames(m(0, GIB, GIB / 2, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, 0, GIB / 2, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 0, 0)), 1); + // #879 on an 8 GB card: one frame peaked at 6.3 GB, over the 3 GiB budget: one. + assert_eq!(auto_gpu_frames(m(3 * GIB, 63 * GIB / 10, 5 * GIB, 0)), 1); + // Frames that had to wait for memory report the whole budget per frame: one. + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 3 * GIB, 0)), 1); + // Room for half a frame more, or room the upload cache may still take: one. + assert_eq!(auto_gpu_frames(m(2 * GIB, 3 * GIB / 2, GIB, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 3 * GIB / 2)), 2); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 2 * GIB)), 1); + // Room for one more frame: two; ample room: three, never more. + assert_eq!(auto_gpu_frames(m(3 * GIB, 2 * GIB, GIB, 0)), 2); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB / 2, GIB / 4, 0)), 3); + assert_eq!(auto_gpu_frames(m(u64::MAX, 1, 1, 0)), 3); + } + + #[test] + fn gpu_frames_come_from_the_environment_the_setting_or_auto() { + use effectcraft_render::FrameMemory; + let ample = Some(Some(FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 1 << 28 })); + let n = |env, pref, measured| resolve_gpu_frames(env, pref, measured).map(|(n, _)| n); + // Auto: unsettled until a GPU frame rendered, then measured. + assert_eq!(n(None, None, None), None); + assert_eq!(n(None, None, Some(None)), Some(1)); + assert_eq!(n(None, None, ample), Some(3)); + // The setting's 1, 2 or 3, without measuring. + for k in 1..=3 { + assert_eq!(n(None, Some(k), None), Some(k)); + assert_eq!(n(None, Some(k), ample), Some(k)); + } + // The environment overrides both; a bad value is ignored. + assert_eq!(n(Some("2"), Some(1), None), Some(2)); + assert_eq!(n(Some("4"), None, ample), Some(4)); + assert_eq!(n(Some("zero"), Some(1), None), Some(1)); + let (_, why) = resolve_gpu_frames(None, None, ample).unwrap(); + assert_eq!(why, "Auto: 256 MiB per frame, 512 MiB peak and 256 MiB cache reserve in a 3072 MiB budget"); + } + + /// An accelerator reporting `memory` once asked (renders nothing itself). + struct MemAccel(effectcraft_render::FrameMemory); + impl effectcraft_render::Accelerator for MemAccel { + fn name(&self) -> String { + "mem".into() + } + fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option { + None + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option) -> Option { + None + } + fn frame_memory(&self) -> Option { + Some(self.0) + } + } + + #[test] + fn auto_gpu_frames_settle_after_the_first_gpu_frame() { + // #3335: one GPU frame at a time until a frame measured the memory, then Auto's count + // for the rest of the job; the setting skips the measurement. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(GPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 0 }); + for (pref, want) in [(None, 3), (Some(2), 2), (Some(1), 1)] { + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { gpu_frames: pref, ..Default::default() }, + }; + let cx = Cx::new(&job); + if pref.is_none() { + assert_eq!(cx.gpu_cap(), Some(1), "before any GPU frame"); + } + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.gpu_cap(), Some(want), "setting {pref:?}"); } } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e2ab46bde..d074fe213 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1135,6 +1135,24 @@ impl GpuContext { } } + /// The memory budget (the memory cap: a conservative share of the device, see `memcap`), + /// the allocator's peak, one frame's share of it and what the upload cache may still add. + /// `None` where any is unknown: no allocator report (Metal, WebGPU, GL) or the cap is off. + pub fn frame_memory(&self) -> Option { + let budget = self.memcap.cap(); + let now = self.allocated_bytes(); + self.memcap.note(now); + // The allocator's peak (sampled while frames render) holds a frame's working textures + // at their most; what is gone again after the frame (and at least what the pool keeps + // for the next) is one frame's share. Frames that had to wait for memory: no room. + let peak = self.memcap.peak(); + let frame = if self.memcap.pressed() { budget } else { peak.saturating_sub(now).max(self.pool_retained_bytes() as u64) }; + // The upload cache may still fill up to its budget. + let uploads = self.uploads.lock().map(|u| u.bytes).unwrap_or(0); + let reserve = self.upload_budget().saturating_sub(uploads) as u64; + (budget > 0 && peak > 0 && frame > 0).then_some(effectcraft_render::FrameMemory { budget, peak, frame, reserve }) + } + /// Bytes held by wgpu's device allocator (`0` on backends that do not report). pub(crate) fn allocated_bytes(&self) -> u64 { self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes) @@ -2133,6 +2151,7 @@ impl<'g> Enc<'g> { return; } let allocated = self.g.allocated_bytes(); + self.g.memcap.note(allocated); if allocated <= self.g.memcap.cap() { let _ = self.g.memcap.should_wait(allocated, 0); return; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index db6061487..b087a2ceb 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -433,6 +433,11 @@ impl Accelerator for Gpu { Some(&self.auto) } + fn frame_memory(&self) -> Option { + self.ctx.check_health().ok()?; + self.ctx.frame_memory() + } + fn render_end(&self) { let (hits, misses) = self.ctx.pool_stats(); let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1); diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs index 9c745322f..25194a7c2 100644 --- a/crates/gpu/src/memcap.rs +++ b/crates/gpu/src/memcap.rs @@ -40,6 +40,8 @@ pub(crate) struct MemCap { floor: AtomicU64, /// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap. warned: AtomicBool, + /// The most the allocator held in any sample (what frames in flight are sized by, #3335). + peak: AtomicU64, #[cfg(test)] timeout_ns: AtomicU64, } @@ -59,6 +61,7 @@ impl MemCap { waits: AtomicU64::new(0), floor: AtomicU64::new(0), warned: AtomicBool::new(false), + peak: AtomicU64::new(0), #[cfg(test)] timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64), } @@ -74,6 +77,7 @@ impl MemCap { waits: AtomicU64::new(0), floor: AtomicU64::new(0), warned: AtomicBool::new(false), + peak: AtomicU64::new(0), timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)), } } @@ -82,6 +86,21 @@ impl MemCap { self.cap } + /// Remember an allocator sample: the peak only grows. + pub(crate) fn note(&self, allocated: u64) { + self.peak.fetch_max(allocated, Relaxed); + } + + /// The most the allocator held in any sample so far. + pub(crate) fn peak(&self) -> u64 { + self.peak.load(Relaxed) + } + + /// Whether frames have had to wait for memory (the device ran over the cap). + pub(crate) fn pressed(&self) -> bool { + self.waits.load(Relaxed) > 0 + } + /// Whether to sample the allocator on this call. pub(crate) fn due(&self) -> bool { self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 6c5f417ce..3d1384a90 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -64,6 +64,7 @@ impl Exporter for FileExporter { overflow: job.project.render_prefs.overflow_folders.clone(), caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, + gpu_frames: job.gpu_frames, }, nested_switches: job.nested_switches, }; diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 0f271c59d..7e91c6f62 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -31,6 +31,22 @@ impl Backend { } } +/// An accelerator's device memory, to size how many frames an export renders on it at once +/// (see [`Accelerator::frame_memory`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameMemory { + /// Bytes the accelerator may use (a conservative share of the device, not its free memory). + pub budget: u64, + /// The most its allocator has held while frames rendered (one frame's working memory + /// included). + pub peak: u64, + /// Bytes of that peak one frame needed (what it released again), or the whole budget once + /// frames have had to wait for memory. + pub frame: u64, + /// Bytes long-lived caches (uploaded layers) may still grow by. + pub reserve: u64, +} + /// One effect of a GPU effect chain (see [`Accelerator::effects`]). pub struct FxStep<'x> { pub spec: &'static effectcraft_effects::EffectSpec, @@ -91,6 +107,12 @@ pub trait Accelerator: Send + Sync { fn auto_pick(&self) -> Option<&AutoPick> { None } + /// The device memory budget and use, after at least one frame rendered: what an export + /// sizes its frames in flight by. `None` where it isn't known (no allocator report, no + /// budget): the export then renders one frame at a time. + fn frame_memory(&self) -> Option { + None + } /// Whether the accelerator can still render frames. `false` once it has stopped for good /// (its device was lost or retired): every frame it is asked for falls back to the CPU, so /// callers that limit frames in flight for it can stop doing so. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0f3243e60..27fbf8cec 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,7 +46,7 @@ mod layers; mod opts; mod pipeline; -pub use accel::{Accelerator, Backend, FxStep, FxTarget}; +pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, diff --git a/crates/ui-egui/examples/rq_bench.rs b/crates/ui-egui/examples/rq_bench.rs index d290210e4..6d470108b 100644 --- a/crates/ui-egui/examples/rq_bench.rs +++ b/crates/ui-egui/examples/rq_bench.rs @@ -121,7 +121,7 @@ fn main() { for d in &dirs { std::fs::create_dir_all(d).expect("output folder"); let path = d.join(format!("f_[####].{format}")); - run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy()})); + run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy(), "log": "plusSettings"})); } let t0 = std::time::Instant::now(); run(&mut s, "renderQueue.render", json!({"wait": true})); @@ -136,6 +136,14 @@ fn main() { let secs = item.render_time.unwrap_or(f64::NAN); let (n, h) = hash_dir(d); println!(" item {}: {:?}, {:.1} ms/frame, {n} files, hash {h:016x}", i + 1, item.status, secs * 1000.0 / frames as f64); + // The render log's account of the compositor and the GPU frames in flight. + let logs = + std::fs::read_dir(d).map(|r| r.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(".txt")).collect::>()); + for log in logs.unwrap_or_default() { + for line in std::fs::read_to_string(log).unwrap_or_default().lines().filter(|l| l.starts_with("Renderer:") || l.starts_with("GPU frames")) { + println!(" {line}"); + } + } } println!(" queue: {total:.2} s"); } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 15bf774f9..72ba2a719 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -891,6 +891,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "フッテージフレームキャッシュ"), ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("GPU Frames Rendered at Once", "一度にレンダリングするGPUフレーム数"), ("Video", "ビデオ"), ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), ("Video Device", "ビデオデバイス"), @@ -2022,6 +2023,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材帧缓存"), ("Preview (RAM) Cache", "预览(内存)缓存"), ("Reduce Cache Size When System Is Low on Memory", "系统内存不足时减少缓存大小"), + ("GPU Frames Rendered at Once", "GPU 同时渲染的帧数"), ("Video", "视频"), ("Enable Video Preview Output", "启用视频预览输出"), ("Video Device", "视频设备"), @@ -3133,6 +3135,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Footage Frame Cache", "Bildrutecache för material"), ("Preview (RAM) Cache", "Förhandsgranskningscache (RAM)"), ("Reduce Cache Size When System Is Low on Memory", "Minska cachestorleken när systemet har ont om minne"), + ("GPU Frames Rendered at Once", "GPU-bildrutor som renderas samtidigt"), ("Video", "Video"), ("Enable Video Preview Output", "Aktivera videoutgång för förhandsgranskning"), ("Video Device", "Videoenhet"), @@ -4350,6 +4353,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材影格快取"), ("Preview (RAM) Cache", "預覽(記憶體)快取"), ("Reduce Cache Size When System Is Low on Memory", "系統記憶體不足時減少快取大小"), + ("GPU Frames Rendered at Once", "GPU 同時算圖的影格數"), ("Video", "視訊"), ("Enable Video Preview Output", "啟用視訊預覽輸出"), ("Video Device", "視訊裝置"), @@ -5910,6 +5914,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Recent Projects Shown", "Projetos recentes exibidos"), ("Red", "Vermelho"), ("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"), + ("GPU Frames Rendered at Once", "Quadros da GPU renderizados de uma vez"), ("Remove from favorites", "Remover dos favoritos"), ("Repair", "Reparo"), ("Report Missing Frames", "Relatar quadros ausentes"), diff --git a/docs/preferences.md b/docs/preferences.md index b8bbb2bcf..927b66788 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -82,6 +82,14 @@ Commands (CLI, MCP, control channel): - `memory.layerCacheMb`: Layer Cache (in the browser at most 512 MB, see [web.md](web.md)) - `memory.mediaCacheMb`: Footage Frame Cache (in the browser at most 256 MB) - `memory.previewCacheMb`: Preview (RAM) Cache +- `memory.gpuFramesInFlight`: GPU Frames Rendered at Once (`auto`, `1`, `2`, `3`): how many + frames the Render Queue renders on the GPU at once. Auto measures the GPU memory after the + job's first GPU frame and fits up to 3 frames into the GPU memory cap (by default a + conservative 3 GiB on a discrete card, less when its memory is known, 2 GiB on an integrated + one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It + renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the + cap turned off) and once frames have had to wait for memory. The render log names the count. + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 9a0a198ff1e69ed8f62061db1cc739abfe9ede58 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 9 Nov 2026 18:27:04 +0530 Subject: [PATCH 08/18] Preview: the window stays smooth while lanes render on the GPU (#3345) Since #595 (v0.19.0) a playing preview and Cache Frames When Idle render one frame per lane, a lane per core, each recording, submitting and reading back a whole frame on the GPU the window draws with. On a GPU that can't keep up, the window froze for hundreds of milliseconds to seconds and the playhead jumped ahead when it came back. Lanes now take a GPU frame only while fewer than a limit of them composite. The limit starts at one lane and adapts to the window's frame gaps while a preview plays: a stall (>100 ms) with lanes compositing halves it, half a second of smooth frames (<50 ms) with every allowed lane busy adds one, up to every lane; a limit that stalled is tried again only after 10 s. Frames on the CPU (Software Only, no or retired GPU, GPU memory pressure) and disk-cache hits use every lane; the pool and the viewer's frame are not limited, and frames ahead of the playhead still start first. --- crates/ui-egui/src/frames.rs | 126 ++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 166 +++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 968b2d791..8bfcd73e8 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -54,6 +54,14 @@ const GPU_BUDGET: usize = 1 << 30; const MIN_GPU_BUDGET: usize = 64 << 20; const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// The window's frame gaps that adapt [`Queue::gpu_limit`] while a preview plays: a longer one +/// is a stall, shorter ones for a while are smooth. +const UI_STALL: std::time::Duration = std::time::Duration::from_millis(100); +const UI_SMOOTH: std::time::Duration = std::time::Duration::from_millis(50); +/// How long the window must stay smooth before one more lane composites on the GPU, and before it +/// tries again a limit that stalled. +const GPU_LANE_STEP: std::time::Duration = std::time::Duration::from_millis(500); +const GPU_LANE_RETRY: std::time::Duration = std::time::Duration::from_secs(10); mod f16; pub use f16::{F16_PIXEL_BYTES, F16Frame}; @@ -639,7 +647,7 @@ impl Default for Frames { }) .collect(); #[cfg(not(target_arch = "wasm32"))] - let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), gpu_limit: 1, ..Default::default() }; #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { @@ -1282,6 +1290,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; + // (called once per frame of the window while a preview plays) + let more_gpu = q.ui_frame(playhead.map(|_| web_time::Instant::now())); if let Some(p) = playhead && let Ok(mut inf) = self.inflight.lock() { @@ -1293,6 +1303,13 @@ impl Frames { !gone }); } + // A lane more may composite: one that stopped at the limit starts again. + #[cfg(not(target_arch = "wasm32"))] + if more_gpu && !moved { + self.start_lanes(&mut q, 1); + } + #[cfg(target_arch = "wasm32")] + let _ = more_gpu; #[cfg(not(target_arch = "wasm32"))] if moved { self.start_lanes(&mut q, usize::MAX); @@ -1610,6 +1627,17 @@ impl Drop for Draining { } } +/// A lane's hold on one of the [`Queue::gpu_limit`] GPU slots (see [`Queue::gpu_lanes`]), given back when dropped, +/// even by a panic. +struct GpuSlot<'a>(&'a Mutex); + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut q = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + q.gpu_lanes = q.gpu_lanes.saturating_sub(1); + } +} + impl Worker { /// Render queued jobs, most important first, until the queue is empty. /// @@ -1636,19 +1664,25 @@ impl Worker { /// idle again. fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; - let job = { + // (asked before the queue's lock: it takes the cache's) + let gpu = lane.is_some() && self.gpu_ready(); + let (job, mut slot) = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.next(lane.is_some()) else { + let Some(i) = q.next(lane.is_some(), gpu) else { q.idle_lanes.extend(lane); return false; }; let job = q.jobs.swap_remove(i); + let slot = (gpu && q.gpu_job(&job)).then(|| { + q.gpu_lanes += 1; + GpuSlot(queue) + }); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; q.urgent_cancels.push((job.key, job.cancel.clone())); } - job + (job, slot) }; let t0 = web_time::Instant::now(); // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile @@ -1657,6 +1691,10 @@ impl Worker { job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); + if disk_hit { + // Nothing for the GPU to do: another lane may composite meanwhile. + slot = None; + } // `files`: the source files the frame was made of (see `render::reads`). let (ci, files) = match from_disk { Some(hit) => hit, @@ -1683,6 +1721,8 @@ impl Worker { // and compression. Persistence still runs on this worker, using the same snapshot. let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + // The GPU's part is done; storing it on disk is the CPU's. + drop(slot); if !disk_hit && let Some((dc, k, since)) = &disk && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) @@ -1951,6 +1991,24 @@ struct Queue { /// caching left the same gaps, and held up the viewer's frame when it shared a piece. lanes: usize, idle_lanes: Vec, + /// Lanes compositing a frame on the GPU, at most [`Queue::gpu_limit`]. The GPU is the + /// viewer's device: each lane records, submits and reads back a whole frame on it, and the + /// window can't draw until that work is done. With a lane per core on a GPU that can't keep + /// up, the window froze for hundreds of milliseconds at a time while a preview played, and + /// the preview jumped ahead when it came back (#3345). The other lanes go on with the frames + /// that don't need the GPU (the disk cache's, or all of them while the GPU is out of memory + /// or retired); the pool and the viewer's frame are not counted. + gpu_lanes: usize, + /// How many lanes may composite on the GPU at a time (at least 1, see [`Queue::ui_frame`]). + gpu_limit: usize, + /// The limit that last stalled the window: tried again only after [`GPU_LANE_RETRY`]. + gpu_stalled_at: usize, + /// While a preview plays: the window's last frame, and since when it has been smooth. + ui_last: Option, + ui_smooth_since: Option, + /// Tests: as if there were a GPU compositor (no adapter is there). + #[cfg(test)] + fake_gpu: bool, } impl Queue { @@ -1959,9 +2017,63 @@ impl Queue { self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) } - /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. - fn next(&self, lane: bool) -> Option { - self.best_of(|j| self.on_lane(j) == lane) + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. `gpu`: + /// the GPU composites (a lane's frames), so a lane takes no GPU frame while + /// [`Queue::gpu_limit`] lanes are compositing. + fn next(&self, lane: bool, gpu: bool) -> Option { + let gpu_full = gpu && self.gpu_lanes >= self.gpu_limit.max(1); + self.best_of(|j| self.on_lane(j) == lane && !(gpu_full && self.gpu_job(j))) + } + + /// The window drew a frame at `now` (`None`: no preview plays). A window that stalls with + /// lanes compositing gets fewer of them: the GPU can't keep up with them and the window. One + /// that stays smooth while every allowed lane composites gets one more, up to all of them: a + /// GPU that keeps up renders the preview on every lane. A limit that stalled is tried again + /// only after a longer smooth spell, so the window doesn't stall over and over. `true` when + /// one more lane may composite. + fn ui_frame(&mut self, now: Option) -> bool { + let Some(now) = now else { + self.ui_last = None; + return false; + }; + let limit = self.gpu_limit.max(1); + let gap = self.ui_last.map(|last| now.saturating_duration_since(last)); + self.ui_last = Some(now); + let Some(gap) = gap else { + self.ui_smooth_since = Some(now); + return false; + }; + if gap > UI_STALL && self.gpu_lanes > 0 { + self.gpu_stalled_at = limit; + self.gpu_limit = (limit / 2).max(1); + self.ui_smooth_since = Some(now); + } else if gap > UI_SMOOTH { + self.ui_smooth_since = Some(now); + } else { + let since = *self.ui_smooth_since.get_or_insert(now); + let wait = if limit + 1 >= self.gpu_stalled_at && self.gpu_stalled_at > 0 { GPU_LANE_RETRY } else { GPU_LANE_STEP }; + if self.gpu_lanes >= limit && limit < self.lanes && now.saturating_duration_since(since) >= wait { + self.gpu_limit = limit + 1; + self.ui_smooth_since = Some(now); + return true; + } + } + false + } + + /// Whether `j` is composited on the GPU when the GPU is ready: there is a GPU compositor and + /// the frame's renderer uses it (as `Renderer::active_accel` decides: Software Only keeps + /// every frame on the CPU). + fn gpu_job(&self, j: &Job) -> bool { + let adapter = j.src.gpu.is_some(); + #[cfg(test)] + let adapter = adapter || self.fake_gpu; + adapter + && match j.opts.backend { + effectcraft_engine::render::Backend::Cpu => false, + effectcraft_engine::render::Backend::Gpu => true, + effectcraft_engine::render::Backend::Auto => j.src.project.settings.gpu_acceleration, + } } /// The job that matters most: urgent first (newest), then, while a preview plays, the diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 360ddf1b6..d980f8565 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -610,15 +610,21 @@ struct ParallelClip { started: std::sync::atomic::AtomicUsize, /// Frames started by the time frame 1 was done. started_at_first: std::sync::atomic::AtomicUsize, + /// Frames decoding now, and the most at a time. + active: std::sync::atomic::AtomicUsize, + most_active: std::sync::atomic::AtomicUsize, } impl FootageSource for ParallelClip { fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { self.started.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.most_active.fetch_max(active, Ordering::SeqCst); (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); } + self.active.fetch_sub(1, Ordering::SeqCst); Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) } } @@ -729,6 +735,166 @@ fn queued_frames_render_when_a_preview_starts_or_stops() { assert_eq!(f.cached_frames(&series).len(), 119); } +/// Waits for the lanes to be idle (after their last frame they give back their GPU slot) and +/// checks every GPU slot was given back. +fn wait_for_idle_lanes(f: &Frames) { + for _ in 0..2000 { + let q = f.queue.lock().unwrap(); + if q.idle_lanes.len() == f.lanes.len() { + break; + } + drop(q); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let q = f.queue.lock().unwrap(); + assert_eq!(q.idle_lanes.len(), f.lanes.len(), "every lane is idle again"); + assert_eq!(q.gpu_lanes, 0, "every lane gave its GPU slot back"); +} + +/// Plays a preview of `frames` frames of a [`parallel_clip`] as the viewer renders them (the +/// project's renderer: `backend` Auto) or with `backend`, as if there were a GPU compositor +/// (`gpu`), `retired` or with the project on Mercury Software Only (`!gpu_acceleration`), and +/// waits for them: the frame cache and the most frames that rendered at a time. +fn play_preview(gpu: bool, retired: bool, backend: effectcraft_engine::render::Backend, gpu_acceleration: bool) -> (Frames, usize) { + use effectcraft_engine::time::FrameRate; + let (f, footage, mut src, cid, series) = parallel_clip(); + Arc::make_mut(&mut src.project).settings.gpu_acceleration = gpu_acceleration; + f.queue.lock().unwrap().fake_gpu = gpu; + if retired { + f.retire_gpu(); + } + let opts = RenderOpts { backend, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + let frames = 24; + for frame in 1..=frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + wait_for_idle_lanes(&f); + let most = footage.most_active.load(Ordering::SeqCst); + (f, most) +} + +/// Every lane composited a playing preview's frame on the GPU at the same time, on the device +/// the window draws with: the window froze for hundreds of milliseconds and the preview jumped +/// ahead (#3345). A preview starts on one GPU lane (see [`Queue::ui_frame`]). +#[test] +fn a_preview_composites_on_a_few_lanes_at_a_time() { + use effectcraft_engine::render::Backend; + for backend in [Backend::Auto, Backend::Gpu] { + let (_, most) = play_preview(true, false, backend, true); + assert!(most <= 1, "{most} lanes composited on the GPU at once ({backend:?})"); + } +} + +/// Frames that render on the CPU use every lane: with no GPU compositor, with the GPU retired, +/// and with Mercury Software Only (the project's renderer, or the CPU backend). +#[test] +fn frames_on_the_cpu_render_on_every_lane() { + use effectcraft_engine::render::Backend; + for (gpu, retired, backend, gpu_acceleration) in + [(false, false, Backend::Auto, true), (true, true, Backend::Auto, true), (true, false, Backend::Auto, false), (true, false, Backend::Cpu, true)] + { + let (f, most) = play_preview(gpu, retired, backend, gpu_acceleration); + if f.lanes.len() > 2 { + assert!(most > 2, "frames on the CPU rendered on {most} lanes at a time ({gpu} {retired} {backend:?} {gpu_acceleration})"); + } + } +} + +/// A queue of `lanes` lanes, `busy` of them compositing on the GPU. +fn gpu_queue(lanes: usize, busy: usize) -> Queue { + Queue { lanes, gpu_lanes: busy, gpu_limit: 1, ..Default::default() } +} + +/// Window frames every `gap` ms from `t` for `ms` ms; the time after the last. +fn ui_frames(q: &mut Queue, t: web_time::Instant, gap: u64, ms: u64) -> web_time::Instant { + let mut t = t; + for _ in 0..ms / gap { + t += std::time::Duration::from_millis(gap); + q.ui_frame(Some(t)); + } + t +} + +/// A GPU that keeps up with the window gets every lane, one more each half second the window +/// stays smooth with every allowed lane compositing. +#[test] +fn a_smooth_window_gives_the_gpu_every_lane() { + let mut q = gpu_queue(8, 0); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + // Nothing compositing: the limit isn't what holds the preview back. + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 1); + let mut t = t; + for _ in 0..20 { + q.gpu_lanes = q.gpu_limit; + t = ui_frames(&mut q, t, 16, 520); + } + assert_eq!(q.gpu_limit, 8, "no more than the lanes"); +} + +/// A window that stalls while lanes composite halves the limit, and the limit that stalled is +/// tried again only after a long smooth spell; a window that is only slow keeps the limit. +#[test] +fn a_stalling_window_takes_lanes_from_the_gpu() { + let mut q = gpu_queue(8, 4); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + let t = ui_frames(&mut q, t, 300, 300); + assert_eq!((q.gpu_limit, q.gpu_stalled_at), (2, 4)); + let t = ui_frames(&mut q, t, 80, 5000); + assert_eq!(q.gpu_limit, 2, "a slow (not stalling) window keeps the limit"); + q.gpu_lanes = 2; + let t = ui_frames(&mut q, t, 16, 600); + assert_eq!(q.gpu_limit, 3); + q.gpu_lanes = 3; + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 3, "4 stalled: it waits longer"); + ui_frames(&mut q, t, 16, 10_000); + assert_eq!(q.gpu_limit, 4); + // A stall with nothing compositing isn't the GPU's. + let mut q = gpu_queue(8, 0); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 500, 500); + assert_eq!(q.gpu_limit, 4); + // Never below one lane; a stopped preview's pause is no stall. + let mut q = gpu_queue(8, 1); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 400, 400); + assert_eq!(q.gpu_limit, 1); + q.gpu_limit = 2; + q.ui_frame(None); + q.ui_frame(Some(t + std::time::Duration::from_secs(5))); + assert_eq!(q.gpu_limit, 2); +} + +/// A preview that stops while lanes composite: their frames finish, the queued ones move to the +/// pool and render, the viewer's frame is not held up, and every GPU slot is given back. +#[test] +fn a_preview_stopping_mid_render_gives_the_gpu_slots_back() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + f.queue.lock().unwrap().fake_gpu = true; + let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Auto, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + for frame in 1..=24 { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + f.set_playhead(None); + f.request_urgent(&src, FrameKey { frame: 100, ..series }, cid, FrameRate::FPS_30.tick_of(100), opts); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 25); + wait_for_idle_lanes(&f); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the From 43e82587fdced6c9942a64eef03206ba3fb9b7f3 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:10:06 +0100 Subject: [PATCH 09/18] #3357: avoid whole-layer cover for Drop Shadow --- crates/effects/src/lib.rs | 4 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/drop_shadow_cover.rs | 142 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/tests/drop_shadow_cover.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 771d51068..019596f80 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -451,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 /// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge /// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these /// skips the renderer's cover of the whole layer rectangle (#1205). -pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; +/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the +/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"]; /// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels /// of the resampling pyramid used for downscaled layers. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 59fa0fadc..e7dd02182 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -11,6 +11,7 @@ mod auto; mod aux_channels; mod collapse; mod color; +mod drop_shadow_cover; mod footage_size; mod frame_blend; mod guides; diff --git a/crates/render/src/tests/drop_shadow_cover.rs b/crates/render/src/tests/drop_shadow_cover.rs new file mode 100644 index 000000000..94d4ca67b --- /dev/null +++ b/crates/render/src/tests/drop_shadow_cover.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer}; +use effectcraft_effects::Buf; + +// A synthetic painted source avoids installed-font dependencies and the multi-gigabyte +// allocation in #3357 while exercising the same text/shape effect-stack bounds. +fn source(scale: f64) -> Buf { + let mut img = Image::new(20, 12); + for y in 2..10 { + for x in 2..18 { + img.set(x, y, [0.8, 0.2, 0.1, 1.0]); + } + } + Buf { img, offset: [7.0, -3.0], scale } +} + +#[test] +fn drop_shadow_does_not_cover_the_zoomed_layer_rectangle() { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + // The old cover makes this at least 800 x 400. Alignment and the shadow's + // own padding need less than 128 pixels per side for this source. + assert!(out.img.width < 128 && out.img.height < 128, "{} x {}", out.img.width, out.img.height); +} + +#[test] +fn drop_shadow_matches_the_covered_reference() { + for scale in [0.5, 1.0, 4.0] { + for only in [false, true] { + for direction in [0.0, 135.0, 270.0] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Text", LayerSource::Text, (0, 0), None); + add_effect_200( + &mut p, + &mut layer, + "ec.perspective.dropshadow", + &[ + ("distance", Value::Scalar(3.5)), + ("softness", Value::Scalar(2.0)), + ("direction", Value::Scalar(direction)), + ("shadowOnly", Value::Bool(only)), + ], + ); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let input = source(scale); + let mut covered = input.clone(); + let (size, origin) = ctx.effect_bounds(&layer); + covered.rebase(origin); + covered.cover(size); + covered.rebase([-origin[0], -origin[1]]); + let actual = r.apply_effects(&ctx, &layer, input, false); + let reference = r.apply_effects(&ctx, &layer, covered, false); + // Compare the union in layer-pixel coordinates, including the shadow outside + // the original content. Buffer origins differ by integral pixels. + let x0 = (-actual.offset[0]).min(-reference.offset[0]) as i32; + let y0 = (-actual.offset[1]).min(-reference.offset[1]) as i32; + let x1 = (f64::from(actual.img.width) - actual.offset[0]).max(f64::from(reference.img.width) - reference.offset[0]) as i32; + let y1 = (f64::from(actual.img.height) - actual.offset[1]).max(f64::from(reference.img.height) - reference.offset[1]) as i32; + for y in y0..y1 { + for x in x0..x1 { + let sample = |b: &Buf| b.img.sample_bilinear(f64::from(x) + b.offset[0] + 0.5, f64::from(y) + b.offset[1] + 0.5); + let (a, b) = (sample(&actual), sample(&reference)); + for c in 0..4 { + assert!( + (a[c] - b[c]).abs() < 1e-5, + "scale {scale}, only {only}, direction {direction}, ({x}, {y}), channel {c}: {} != {}", + a[c], + b[c] + ); + } + } + } + } + } + } +} +#[test] +fn drop_shadow_with_glow_stays_local_but_other_effects_keep_cover() { + for other in ["ec.stylize.glow", "ec.color.tint"] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let values = if other == "ec.stylize.glow" { vec![("radius", Value::Scalar(1.0))] } else { vec![] }; + add_effect_200(&mut p, &mut layer, other, &values); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + if other == "ec.stylize.glow" { + assert!(out.img.width < 192 && out.img.height < 192, "{} x {}", out.img.width, out.img.height); + } else { + assert!(out.img.width >= 800 && out.img.height >= 400); + } + } +} +#[test] +fn parented_text_and_shape_keep_small_working_images_at_2200_percent() { + for text in [false, true] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(80, 40, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut parent = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + parent.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + parent.props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, 0.0]); + parent.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([2200.0, 2200.0, 100.0]); + let source = if text { LayerSource::Text } else { LayerSource::Shape }; + let mut layer = build::layer(&mut p, &comp, "Painted", source, (80, 40), None); + layer.parent = Some(parent.id); + layer.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0; 3]); + if text { + layer.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "I".into(), size: 8.0, ..Default::default() })); + } else { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [4.0, 4.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0; 4]); + let group = build::shape_group(&mut ids, "Rectangle", vec![rect, fill]); + p.next_id = next; + layer.props.sub_mut("contents").unwrap().children.push(group.into()); + } + add_effect(&mut p, &mut layer, "ec.stylize.glow", [80.0, 40.0], &[("radius", Value::Scalar(0.1))]); + add_effect(&mut p, &mut layer, "ec.perspective.dropshadow", [80.0, 40.0], &[("distance", Value::Scalar(0.1)), ("softness", Value::Scalar(0.1))]); + p.comp_mut(cid).unwrap().layers = vec![layer, parent].into(); + let comp = p.comp(cid).unwrap(); + let layer = &comp.layers[0]; + for scale in [1.0, 0.5] { + let r = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() }); + let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); + let input = r.layer_input(&ctx, layer, usize::MAX).unwrap(); + assert!(input.img.width < 512 && input.img.height < 512, "text={text}, output scale={scale}: {} x {}", input.img.width, input.img.height); + assert!(input.img.data.iter().any(|pixel| pixel[3] > 0.5), "the painted source is not empty"); + let frame = r.comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().any(|pixel| pixel[3] > 0.5), "the layer still draws in the composition"); + } + } +} From 45e81e603eb36002cd90363c9987bdb07b98808a Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:16:57 +0100 Subject: [PATCH 10/18] #3335: isolate proxy caches and settle small render batches --- crates/export/src/pipeline.rs | 57 +++++++++++++++++++++++++- crates/render/src/layers.rs | 8 ++++ crates/render/src/tests.rs | 1 + crates/render/src/tests/proxy_cache.rs | 45 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/tests/proxy_cache.rs diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c233435a8..922b1b4ea 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -530,10 +530,21 @@ impl<'a> Cx<'a> { self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" }); return vec![]; } - // Small batches (few threads) measure one frame each; a batch too small for even that - // (a resumed sequence skipping frames) renders on the GPU and leaves it to the next. + // Nothing will be emitted for a skipped batch, so a later batch can still measure. + if ks.is_empty() { + return vec![]; + } + // A resumed sequence can have only one or two missing frames in its first batch. + // Settle before emitting them: measuring a later batch could otherwise switch to the + // CPU after GPU pixels have already been written, including across output modules. let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2); if n == 0 { + self.decide(Choice { + renderer: crate::JobRenderer::Gpu, + gpu_ms: None, + cpu_ms: None, + why: "the project's renderer (too few pending frames to measure)", + }); return vec![]; } let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else { @@ -1198,6 +1209,48 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + /// A resumed sequence can start with one or two missing frames. Once emitted, their + /// compositor must also be used by every later batch and the item's other output modules. + #[test] + fn a_small_first_batch_settles_the_renderer_before_emitting_frames() { + if std::env::var_os(RENDERER_ENV).is_some() { + return; + } + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = NoAccel; + for n in [1, 2] { + let shared = Mutex::new(None); + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/r/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { renderer: Some(&shared), ..Default::default() }, + }; + let cx = Cx::new(&job); + let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on); + assert!(empty.is_empty()); + assert_eq!(cx.choice(), None, "skipped batches do not choose"); + let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on); + assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]); + assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted"); + let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on); + assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu)); + assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows"); + } + } + #[test] fn a_job_that_chose_the_cpu_is_not_capped() { // #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index df976a857..cfae5dbab 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -295,7 +295,15 @@ impl<'a> Renderer<'a> { /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). /// The key has the layer's own motion blur state, but a mask set to On blurs without it. pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + // Queue jobs share this cache but may override Proxy Use differently. The item's + // proxy switch alone does not describe the source a renderer actually reads. let k = key?; + let k = match self.opts.proxy { + effectcraft_project::render_queue::ProxyUse::CurrentSettings => k, + effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901), + effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902), + effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903), + }; let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 59fa0fadc..4ff292d9f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -20,6 +20,7 @@ mod mask_feather; mod motion_tile; mod nested; mod paint; +mod proxy_cache; mod remap; mod roi; mod stretch_limits; diff --git a/crates/render/src/tests/proxy_cache.rs b/crates/render/src/tests/proxy_cache.rs new file mode 100644 index 000000000..85343a25d --- /dev/null +++ b/crates/render/src/tests/proxy_cache.rs @@ -0,0 +1,45 @@ +use super::setup; +use crate::eval::EvalCtx; +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer}; +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Proxy, build, render_queue::ProxyUse}; +use effectcraft_time::Tick; +use std::sync::Arc; + +/// A source and its proxy deliberately have different pixels. +struct Pictures; +impl FootageSource for Pictures { + fn frame(&self, _: ItemId, footage: &Footage, _: Tick) -> Option> { + let color = if footage.path == "proxy" { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(Arc::new(Image::filled(16, 16, color))) + } +} + +/// A queue run reuses its layer cache across jobs with different Proxy Use settings. +/// Time effects read layer_input, which must never serve another job's source pixels. +#[test] +fn shared_layer_cache_separates_proxy_use() { + let (mut p, cid, comp) = setup(); + let footage = Footage { path: "original".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }; + let item = p.add_item("Footage", Label::Red, None, ItemKind::Footage(footage.clone())); + p.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage: Footage { path: "proxy".into(), ..footage }, enabled: false })); + let layer = build::layer(&mut p, &comp, "Footage", LayerSource::Footage { item }, (16, 16), None); + p.comp_mut(cid).unwrap().layers.push(layer); + for enabled in [false, true] { + p.item_mut(item).unwrap().proxy.as_mut().unwrap().enabled = enabled; + let cache = LayerCache::default(); + let ctx = EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let layer = &ctx.comp.layers[0]; + for proxy in [ProxyUse::UseAll, ProxyUse::UseNone, ProxyUse::CurrentSettings, ProxyUse::UseCompOnly, ProxyUse::UseAll] { + let mut r = Renderer::new(&p, &Pictures, RenderOpts { proxy, ..Default::default() }); + let input = r.layer_input(&ctx, layer, 0).unwrap(); + let content = r.content_buf(&ctx, layer).unwrap(); + r.cache = Some(&cache); + assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img.data, input.img.data, "{proxy:?}, enabled={enabled}"); + assert_eq!(r.content_buf(&ctx, layer).unwrap().img.data, content.img.data, "{proxy:?}, enabled={enabled}"); + let hits = cache.stats().hits; + r.layer_input(&ctx, layer, 0).unwrap(); + assert!(cache.stats().hits > hits, "unchanged proxy settings still reuse inputs"); + } + } +} From e8634b1f4bb9a86b90fb476c9a6d6a8b5e7eba0f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:41:39 +0100 Subject: [PATCH 11/18] #3357: release retired GPU resources before CPU fallback --- crates/gpu/src/lib.rs | 25 ++++++++-- crates/gpu/src/tests.rs | 43 ++++++++++++++++ crates/render/src/pipeline.rs | 26 ++++++++-- crates/render/src/tests/auto.rs | 88 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/frames.rs | 2 +- 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3ede5c6b9..0d260d919 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -300,7 +300,12 @@ impl Gpu { /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { - self.ctx.check_health().map_err(FrameError::Retired)?; + if let Err(error) = self.ctx.check_health() { + // Queued jobs may still own this context after the UI detached it. Release its + // working caches before CPU fallback adds host-memory pressure (#3357). + self.ctx.discard_kept(); + return Err(FrameError::Retired(error)); + } #[cfg(not(target_arch = "wasm32"))] { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); @@ -314,7 +319,7 @@ impl Gpu { // The allocation failure first: a validation error is often its consequence. let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); if errors.is_empty() { - return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired); + return self.finish_memory_attempt(out); } // Its textures go back to the pool when dropped: drop them before emptying it. drop(out); @@ -325,7 +330,21 @@ impl Gpu { #[cfg(target_arch = "wasm32")] { let out = f(); - self.ctx.check_health().map(|()| out).map_err(FrameError::Retired) + self.finish_memory_attempt(out) + } + } + + /// Retirement without a scoped error (for example, a readback timeout) must release + /// the same retained resources as a rejected allocation before the caller retries. + fn finish_memory_attempt(&self, out: T) -> Result { + match self.ctx.check_health() { + Ok(()) => Ok(out), + Err(error) => { + // Dropping the result can return leased textures to the pool. + drop(out); + self.ctx.discard_kept(); + Err(FrameError::Retired(error)) + } } } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b88bac0be..e60613238 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -996,6 +996,49 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } +/// A timeout can retire a context without any device error scope reporting a failure. +/// Queued jobs keep that context alive, so CPU fallback must not wait for its last owner +/// to drop before releasing cached uploads and working textures (#3357). +fn retired_attempt_releases_kept_resources(retire_before: bool) { + hold_gpu_lock(); + let made = Gpu::try_headless(); + let Some(g) = require_gpu("retired memory attempt", made.as_ref()) else { return }; + let buf = Arc::new(effectcraft_effects::Buf { img: Image::new(16, 16), offset: [0.0; 2], scale: 1.0 }); + drop(g.ctx.upload_buf(&buf).expect("a small cached upload")); + drop(crate::context::Enc::new(&g.ctx).image(32, 16)); + assert_eq!(g.ctx.upload_entries(), 1); + assert!(g.ctx.pool_retained_bytes() > 0); + // An earlier frame can return a lease after retirement; it must not refill the pool. + let earlier = crate::context::Enc::new(&g.ctx).image(48, 16); + let retire = g.ctx.device_failure_notifier(); + if retire_before { + retire("synthetic readback retirement".into()); + } + let called = std::cell::Cell::new(false); + let result = g.within_memory(|| { + called.set(true); + let out = crate::context::Enc::new(&g.ctx).image(64, 16); + retire("synthetic readback retirement".into()); + out + }); + assert!(matches!(result, Err(crate::FrameError::Retired(_)))); + assert_eq!(called.get(), !retire_before); + assert_eq!(g.ctx.upload_entries(), 0, "CPU fallback must not retain uploaded layers"); + assert_eq!(g.ctx.pool_retained_bytes(), 0, "CPU fallback must not retain working textures"); + drop(earlier); + assert_eq!(g.ctx.pool_retained_bytes(), 0, "late leases cannot refill a retired pool"); +} + +#[test] +fn within_memory_releases_caches_when_already_retired() { + retired_attempt_releases_kept_resources(true); +} + +#[test] +fn within_memory_releases_caches_when_retired_during_work() { + retired_attempt_releases_kept_resources(false); +} + /// A texture the device rejected (deliberately too wide), made outside any frame under a scope /// of its own: the view a frame then makes of it is the reported validation error. fn rejected_texture(g: &Gpu) -> wgpu::Texture { diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6c79ba7bb..114daf0e3 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -482,6 +482,22 @@ impl<'a> Renderer<'a> { /// Render a composition at comp time `t` (transparent background, comp size × scale). /// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them. pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image { + self.comp_frame_with(comp_id, t, false, |a| Ok::<_, std::convert::Infallible>(a.comp_frame(self, comp_id, t))).unwrap_or_else(|never| match never {}) + } + + /// Render a frame, validating the accelerated attempt before starting any CPU fallback. + /// The caller's scope must run only the accelerator, not this whole method: a GPU fault + /// must release failed resources before a potentially large CPU render (#3357). + /// A scope error returns immediately; the caller can then retry with a fresh CPU renderer + /// without accelerator-produced cache entries. Healthy declines and Auto timing retain + /// the same behavior as [`Self::comp_frame`]. Its fallback disables acceleration for + /// effects too: GPU work must never escape the supplied error scope. + pub fn comp_frame_scoped(&self, comp_id: ItemId, t: Tick, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + self.comp_frame_with(comp_id, t, true, scope) + } + + fn comp_frame_with(&self, comp_id: ItemId, t: Tick, scoped: bool, scope: impl FnOnce(&dyn Accelerator) -> Result, E>) -> Result { + let cpu = if scoped { self.with_accel(None) } else { self.with_accel(self.accel) }; // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well; // Auto renders each comp on whichever compositor measured faster ([`AutoPick`]). if self.depth == 0 @@ -490,24 +506,24 @@ impl<'a> Renderer<'a> { let auto = self.frame_auto(a, comp_id, false); if auto.is_none_or(|(p, key)| p.choose(key)) { let t0 = web_time::Instant::now(); - if let Some(img) = a.comp_frame(self, comp_id, t) { + if let Some(img) = scope(a)? { if let Some((p, key)) = auto { p.record(key, true, t0.elapsed().as_secs_f64() * 1e3); } - return img; + return Ok(img); } if let Some((p, key)) = auto { p.declined(key); } } let t0 = web_time::Instant::now(); - let img = self.comp_frame_cpu(comp_id, t); + let img = cpu.comp_frame_cpu(comp_id, t); if let Some((p, key)) = auto { p.record(key, false, t0.elapsed().as_secs_f64() * 1e3); } - return img; + return Ok(img); } - self.comp_frame_cpu(comp_id, t) + Ok(cpu.comp_frame_cpu(comp_id, t)) } /// The Auto timing history and key for a top-level frame of `comp` (`None` unless the diff --git a/crates/render/src/tests/auto.rs b/crates/render/src/tests/auto.rs index 22f1a6215..5cc503173 100644 --- a/crates/render/src/tests/auto.rs +++ b/crates/render/src/tests/auto.rs @@ -14,12 +14,13 @@ struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, + effect_calls: AtomicUsize, } impl MockAccel { /// Construct a renderer stand-in without making assumptions about scheduling or speed. fn new(handle: bool) -> MockAccel { - MockAccel { handle, calls: Default::default(), pick: Default::default() } + MockAccel { handle, calls: Default::default(), pick: Default::default(), effect_calls: Default::default() } } /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { @@ -42,6 +43,7 @@ impl Accelerator for MockAccel { }) } fn supports_effect(&self, _id: &str) -> bool { + self.effect_calls.fetch_add(1, Ordering::SeqCst); false } fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option) -> Option { @@ -160,3 +162,87 @@ fn auto_pick_follows_changing_costs() { assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none()); assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none()); } + +/// Counts actual CPU source reads and rejects any CPU fallback inside a GPU error scope. +struct ScopedFootage { + in_scope: std::sync::atomic::AtomicBool, + reads: AtomicUsize, +} +impl crate::FootageSource for ScopedFootage { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + assert!(!self.in_scope.load(Ordering::SeqCst), "CPU fallback ran before GPU validation/cleanup"); + self.reads.fetch_add(1, Ordering::SeqCst); + Some(std::sync::Arc::new(crate::Image::filled(f.width, f.height, [1.0, 0.0, 0.0, 1.0]))) + } +} + +fn scoped_project() -> (Project, ItemId) { + let (mut p, cid) = project(); + let comp = p.comp(cid).unwrap().clone(); + let footage = effectcraft_project::Footage { + path: "synthetic.png".into(), + kind: effectcraft_project::FootageKind::Still, + width: 50, + height: 50, + has_video: true, + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + let item = p.add_item("Source", Label::Red, None, ItemKind::Footage(footage)); + let mut layer = build::layer(&mut p, &comp, "Source", LayerSource::Footage { item }, (50, 50), None); + super::add_effect_200(&mut p, &mut layer, "ec.blur.gaussian", &[]); + p.comp_mut(cid).unwrap().layers = vec![layer].into(); + (p, cid) +} + +/// #3357: a declined GPU frame must not allocate a CPU frame only to discard it when the +/// enclosing GPU scope reports a fault. The caller retries on a fresh CPU renderer once. +#[test] +fn failed_gpu_scope_returns_before_cpu_fallback() { + let (p, cid) = scoped_project(); + for handled in [false, true] { + let a = MockAccel::new(handled); + let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() }); + r.accel = Some(&a); + let result = r.comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + drop(frame); + src.in_scope.store(false, Ordering::SeqCst); + Err::, _>("device retired") + }); + assert_eq!(result.unwrap_err(), "device retired"); + assert_eq!(src.reads.load(Ordering::SeqCst), 0); + let frame = r.with_accel(None).comp_frame(cid, Tick::ZERO); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 1); + } +} + +/// A healthy decline closes its scope before CPU work, and still records Auto timings. +#[test] +fn healthy_gpu_decline_renders_cpu_once_outside_scope() { + let (p, cid) = scoped_project(); + let a = MockAccel::new(false); + let src = ScopedFootage { in_scope: false.into(), reads: 0.into() }; + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Auto, ..Default::default() }); + r.accel = Some(&a); + let frame = r + .comp_frame_scoped(cid, Tick::ZERO, |a| { + src.in_scope.store(true, Ordering::SeqCst); + let frame = a.comp_frame(&r, cid, Tick::ZERO); + src.in_scope.store(false, Ordering::SeqCst); + Ok::<_, ()>(frame) + }) + .unwrap(); + assert!(frame.get(100, 50)[3] > 0.99); + assert_eq!(src.reads.load(Ordering::SeqCst), 1); + assert_eq!(a.calls(), 1); + assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 1); + // Auto's next frame stays on CPU, without entering a GPU scope at all. + r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result, ()> { panic!("Auto CPU choice must not enter GPU error scopes") }).unwrap(); + assert_eq!(src.reads.load(Ordering::SeqCst), 2); + assert_eq!(a.effect_calls.load(Ordering::SeqCst), 0, "CPU effects must not use the GPU outside its scope"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 8bfcd73e8..72d1edba6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1926,7 +1926,7 @@ impl Worker { } if let Some(g) = gpu { // The GPU composites and the frame is read back. - return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { + return match r.comp_frame_scoped(job.comp, job.t, |a| g.within_memory(|| a.comp_frame(&r, job.comp, job.t))) { Ok(img) => { self.gpu_succeeded(); cpu_frame(&img, &job.src.project, &job.opts) From d643e7f8f0babbf9c80aeba1fbd82dea7d9bd9f8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:55:46 +0100 Subject: [PATCH 12/18] #1388: distinguish fine raster clips and update layer test fixtures --- crates/render/src/pipeline.rs | 8 +++++++- crates/render/src/tests/raster_clip.rs | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 5966ebf2e..029438cc5 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -499,7 +499,7 @@ impl<'a> Renderer<'a> { pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { let k = key?; Some(match self.raster_clip(ctx, layer) { - Some(c) => [c.x0, c.y0, c.x1, c.y1].iter().fold(cache::derive(k, 0xc11b), |d, v| cache::derive(d, (v * 16.0).round() as i64 as u64)), + Some(c) => raster_clip_key(k, c), None => k, }) } @@ -1209,3 +1209,9 @@ impl<'a> Renderer<'a> { Some(self.keep(key, Arc::new(buf?), &files).0) } } + +/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible +/// clips cannot collide with another cell or the empty behind-camera raster. +pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 { + [clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits())) +} diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs index 8bcadb7ee..f7227f634 100644 --- a/crates/render/src/tests/raster_clip.rs +++ b/crates/render/src/tests/raster_clip.rs @@ -190,7 +190,7 @@ fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom())); keyed(&mut cam, "transform/position", pos); keyed(&mut cam, "transform/poi", poi); - p.comp_mut(cid).unwrap().layers = vec![cam, text]; + p.comp_mut(cid).unwrap().layers = vec![cam, text].into(); let cache = crate::LayerCache::default(); let (mut exact, mut near, mut capped) = (0, 0, 0); for f in 0..30 { @@ -286,7 +286,7 @@ fn layer_space_reads_get_the_whole_layer() { p.next_id = next; g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0)); solid.props.sub_mut("effects").unwrap().children.push(g.into()); - p.comp_mut(cid).unwrap().layers = vec![text, solid]; + p.comp_mut(cid).unwrap().layers = vec![text, solid].into(); assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text"); let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); @@ -321,3 +321,14 @@ fn layer_panel_input_reads_the_whole_layer_behind_the_camera() { assert!(expected.img.data.iter().any(|pixel| pixel[3] > 0.5)); assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img, expected.img); } + +#[test] +fn fine_clip_cells_have_distinct_cache_keys() { + let a = kurbo::Rect::new(0.0, 0.0, 1.0 / 128.0, 1.0 / 128.0); + let b = kurbo::Rect::new(1.0 / 128.0, 0.0, 2.0 / 128.0, 1.0 / 128.0); + let key = |clip| crate::pipeline::raster_clip_key(42, clip); + // All three clips rounded to identical coordinates in the old 1/16-unit key. + assert_ne!(key(a), key(b)); + assert_ne!(key(a), key(kurbo::Rect::ZERO)); + assert_ne!(key(b), key(kurbo::Rect::ZERO)); +} From fc4bcd21d474d126e5a59c1cad8ed5589cf45490 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:15:19 +0100 Subject: [PATCH 13/18] #3357: report unexpected desktop exits with local diagnostics --- apps/effectcraft/src/crash_report.rs | 490 +++++++++++++++++++++++++++ apps/effectcraft/src/main.rs | 29 +- crates/engine/src/system/logging.rs | 33 +- docs/testing.md | 21 ++ 4 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft/src/crash_report.rs diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs new file mode 100644 index 000000000..1dd25c571 --- /dev/null +++ b/apps/effectcraft/src/crash_report.rs @@ -0,0 +1,490 @@ +//! A small, separate process reports an unexpected desktop exit (#3357). +//! +//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a +//! helper which never loads a project, font database or GPU. Only an explicit marker after +//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its +//! cause. System-wide exhaustion can still prevent the helper or its native dialog working. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const FLAG: &str = "--internal-crash-reporter"; +const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n"; +const LOG_BYTES: usize = 64 * 1024; +const HEADER_BYTES: usize = 128; + +/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes +/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook. +pub struct Reporter { + pipe: ChildStdin, + child: Child, +} + +impl Reporter { + /// Call only after application teardown, including handled startup/device-loss errors. + pub fn finish(mut self) -> io::Result<()> { + let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush()); + drop(self.pipe); + // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI + // exits where possible, so Windows updates do not race an old executable. + let cleanup = finish_child(&mut self.child, Duration::from_secs(2)); + match (sent, cleanup) { + (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))), + (Err(error), _) | (_, Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } +} + +fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { + let start = Instant::now(); + while start.elapsed() < grace { + if let Some(status) = child.try_wait()? { + return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) }; + } + std::thread::sleep(Duration::from_millis(20)); + } + child.kill()?; + // Even after termination, do not introduce an unbounded wait on a stuck OS process. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(1) { + if child.try_wait()?.is_some() { + return Err(io::Error::other("crash reporter did not finish promptly and was terminated")); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(io::Error::other("crash reporter termination could not be confirmed")) +} + +/// Each process/run has its own report, including simultaneous launches. +pub fn run_id() -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + format!("{}-{nanos}", std::process::id()) +} + +pub fn session_marker(id: &str) -> String { + format!("crash-report session {id}") +} + +/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app. +pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result { + let exe = std::env::current_exe()?; + let args = [ + OsString::from(FLAG), + OsString::from(id), + log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()), + reports.as_os_str().to_owned(), + OsString::from(if interactive { "1" } else { "0" }), + ]; + spawn(&exe, &args) +} + +fn spawn(exe: &Path, args: &[OsString]) -> io::Result { + let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?; + match child.stdin.take() { + Some(pipe) => Ok(Reporter { pipe, child }), + None => { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::other("crash reporter has no control pipe")) + } + } +} + +/// Handle the internal mode before logging, argument parsing, updates or GUI initialization. +/// The callback is a native dialog on this helper's main thread, not on the failed GUI's. +pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { + if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) { + return false; + } + let parsed = match args { + [_, id, log, reports, interactive] => id + .to_str() + .filter(|id| valid_id(id)) + .zip(match interactive.to_str() { + Some("0") => Some(false), + Some("1") => Some(true), + _ => None, + }) + .map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)), + _ => None, + }; + let Some((id, log, reports, interactive)) = parsed else { + eprintln!("EffectCraft crash reporter: invalid internal arguments"); + return true; + }; + // Keep the exact file open while the GUI is alive: a later rotation must not substitute + // another instance's log. If rotation won the startup race, the session marker rejects it. + let mut log = SessionLog::open(log, id); + match clean_exit(&mut io::stdin().lock()) { + Ok(true) => {} + result => { + let failure = result.err().map(|e| e.to_string()); + let saved = write_report(reports, id, &mut log, failure.as_deref()); + let description = description(&saved); + eprintln!("{description}"); + if interactive { + show(&description); + } + } + } + true +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') +} + +/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean. +fn clean_exit(input: &mut impl Read) -> io::Result { + let mut marker = [0u8; CLEAN.len()]; + let mut filled = 0; + while filled < marker.len() { + let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?; + match input.read(remaining) { + Ok(0) => return Ok(false), + Ok(n) => filled += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(marker == CLEAN) +} + +enum SessionLog { + Captured(File), + Unavailable(String), +} + +impl SessionLog { + fn open(path: Option<&Path>, id: &str) -> Self { + let opened = (|| { + let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?; + let mut file = File::open(path)?; + let mut header = [0u8; HEADER_BYTES]; + let marker = format!("{}\n", session_marker(id)); + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + file.read_exact(prefix)?; + if prefix != marker.as_bytes() { + return Err(io::Error::other("matching session marker unavailable; the log may have rotated")); + } + Ok(file) + })(); + match opened { + Ok(file) => Self::Captured(file), + Err(e) => Self::Unavailable(e.to_string()), + } + } + + fn write_tail(&mut self, report: &mut File) -> io::Result<()> { + match self { + Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), + Self::Captured(file) => { + let size = file.metadata()?.len(); + file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; + writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; + io::copy(&mut file.take(LOG_BYTES as u64), report)?; + Ok(()) + } + } + } +} + +fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result { + if !valid_id(id) { + return Err(io::Error::other("invalid report identity")); + } + std::fs::create_dir_all(reports)?; + let path = reports.join(format!("crash-{id}.txt")); + let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?; + writeln!( + report, + "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )?; + writeln!( + report, + "The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere." + )?; + if let Some(error) = pipe_error { + writeln!(report, "Control pipe error: {error}")?; + } + if let Err(error) = log.write_tail(&mut report) { + writeln!(report, "\nCould not read the recent session log: {error}")?; + } + report.flush()?; + Ok(path) +} + +fn description(saved: &io::Result) -> String { + let diagnostic = match saved { + Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()), + Err(error) => format!("A diagnostic report could not be saved: {error}"), + }; + format!( + "EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE"; + + fn folder() -> PathBuf { + let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn test_name(name: &str) -> String { + // Also usable as a standalone std-only rustc test while the app's build cache is busy. + let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest); + format!("{prefix}::{name}") + } + + fn helper(dir: &Path, id: &str) -> Reporter { + helper_mode(dir, id, false) + } + + fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"]) + .env(ROLE, "reporter") + .env("EC_REPORT_DIR", dir) + .env("EC_REPORT_ID", id) + .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" }) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + Reporter { pipe: child.stdin.take().unwrap(), child } + } + + fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(10); + while !path.is_file() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + } + assert!(path.is_file(), "report did not arrive: {}", path.display()); + } + + #[test] + fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() { + let mut clean = CLEAN; + assert!(clean_exit(&mut clean).unwrap()); + for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] { + assert!(!clean_exit(&mut input).unwrap()); + } + assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap()); + } + + #[test] + fn report_is_bounded_and_keeps_its_session_after_log_rotation() { + let dir = folder(); + let original = dir.join("effectcraft.log"); + let id = "123-456"; + std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap(); + let mut log = SessionLog::open(Some(&original), id); + std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap(); + std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap(); + let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap(); + old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap(); + old.write_all(b"\nGPU context retired\n").unwrap(); + let report = write_report(&dir, id, &mut log, None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.len() < LOG_BYTES + 2048); + assert!(text.contains("GPU context retired")); + assert!(!text.contains("OTHER SESSION")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "private other session").unwrap(); + for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] { + let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn session_identity_requires_the_exact_first_line() { + let dir = folder(); + let path = dir.join("log"); + for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] { + std::fs::write(&path, header).unwrap(); + assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_))); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn report_write_failure_is_described_and_existing_report_is_not_overwritten() { + let dir = folder(); + let mut log = SessionLog::Unavailable("no log".into()); + let first = write_report(&dir, "2-1", &mut log, None).unwrap(); + let before = std::fs::read(&first).unwrap(); + let result = write_report(&dir, "2-1", &mut log, None); + assert!(result.is_err()); + assert!(description(&result).contains("could not be saved")); + assert_eq!(std::fs::read(first).unwrap(), before); + assert!(write_report(&dir, "../escape", &mut log, None).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn normal_and_handled_shutdown_send_clean_marker_without_a_report() { + for id in ["3-1", "3-2"] { + let dir = folder(); + let r = helper(&dir, id); + r.finish().unwrap(); + // A completion file proves the helper consumed the marker, not merely that it + // hadn't yet produced a report. + wait_file(&dir.join(format!("ready-{id}"))); + assert!(!dir.join(format!("crash-{id}.txt")).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn an_aborting_parent_leaves_one_diagnostic_report() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "abort") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + wait_file(&dir.join("ready-4-1")); + let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap(); + assert!(text.contains("exact cause are not available")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unrelated_child_does_not_keep_the_control_pipe_open() { + let dir = folder(); + let r = helper(&dir, "5-1"); + let mut unrelated = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + drop(r); + wait_file(&dir.join("ready-5-1")); + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unresponsive_helper_cannot_hang_clean_shutdown() { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let pipe = child.stdin.take().unwrap(); + let started = Instant::now(); + let result = Reporter { pipe, child }.finish(); + assert!(result.unwrap_err().to_string().contains("was terminated")); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn simultaneous_sessions_write_separate_reports() { + let dir = folder(); + let a = helper(&dir, "6-1"); + let b = helper(&dir, "6-2"); + drop(a); + drop(b); + wait_file(&dir.join("ready-6-1")); + wait_file(&dir.join("ready-6-2")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn interactive_failure_calls_dialog_once_after_saving_the_report() { + let dir = folder(); + drop(helper_mode(&dir, "7-1", true)); + wait_file(&dir.join("ready-7-1")); + let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap(); + assert!(text.contains("crash-7-1.txt")); + assert!(text.contains("No report has been sent")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn internal_mode_rejects_invalid_arguments_without_launching_the_app() { + assert!(!maybe_run(&[], |_| panic!("no dialog"))); + assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog"))); + assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog"))); + let args = [FLAG, "../escape", "", "", "1"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + } + + #[test] + fn missing_helper_is_an_error_not_a_panic() { + assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err()); + } + + // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise + // real EOF/abort behavior without allocating a huge image or killing the test runner. + #[test] + #[ignore] + fn reporter_process() { + assert_eq!(std::env::var(ROLE).unwrap(), "reporter"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let id = std::env::var("EC_REPORT_ID").unwrap(); + let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap(); + let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)]; + assert!(maybe_run(&args, |description| { + assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog"); + assert!(dir.join(format!("crash-{id}.txt")).exists()); + let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap(); + shown.write_all(description.as_bytes()).unwrap(); + })); + std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap(); + } + + #[test] + #[ignore] + fn aborting_parent() { + assert_eq!(std::env::var(ROLE).unwrap(), "abort"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let _reporter = helper(&dir, "4-1"); + std::process::abort(); + } + + #[test] + #[ignore] + fn lingering_child() { + assert_eq!(std::env::var(ROLE).unwrap(), "linger"); + thread::sleep(Duration::from_secs(30)); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 5350addbe..ce4bcdd84 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -22,6 +22,7 @@ mod appimage; mod apple_events; mod audio_out; mod control_server; +mod crash_report; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -180,6 +181,25 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) { /// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then /// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if crash_report::maybe_run(&args, |description| { + rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show(); + }) { + return Ok(()); + } + let mut reporter = None; + let result = run_desktop(&mut reporter); + // run_desktop has dropped the app and handled any startup error with its existing dialog. + // An unwinding panic or abort never reaches this marker. + if let Some(reporter) = reporter + && let Err(error) = reporter.finish() + { + log::warn!("could not mark crash reporter clean: {error}"); + } + result +} + +fn run_desktop(reporter: &mut Option) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -205,12 +225,19 @@ fn main() -> eframe::Result { // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so // `--version` leaves no file behind; records logged until now are written to it first. + let run_id = crash_report::run_id(); if let Some(dir) = effectcraft_host::config_dir() { - match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) { + let header = format!("{}\n", crash_report::session_marker(&run_id)); + match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) { Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), Err(e) => log::warn!("no log file: {e}"), } } + let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); + match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { + Ok(started) => *reporter = Some(started), + Err(error) => log::warn!("crash reporter could not start: {error}"), + } // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 857a3e90d..198aa3b46 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -221,9 +221,18 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { + attach_session_log_with_header(dir, "") +} + +/// Open a session log with a bounded, unfiltered identity header before buffered records. +/// A crash reporter can then recognize this run even with logging disabled or verbose startup. +pub fn attach_session_log_with_header(dir: &Path, header: &str) -> Result { + if header.len() > 1024 { + return Err("session log header exceeds 1024 bytes".into()); + } let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner); - sink.attach(file); + sink.attach_with_header(file, header); sink.path = Some(path.clone()); Ok(path) } @@ -264,8 +273,14 @@ impl Sink { } /// Start writing to `file`, first the lines kept so far. + #[cfg(test)] fn attach(&mut self, file: File) { + self.attach_with_header(file, ""); + } + + fn attach_with_header(&mut self, file: File, header: &str) { self.file = Some(file); + self.append(header); for line in std::mem::take(&mut self.pending) { self.append(&line); } @@ -333,6 +348,22 @@ mod tests { use super::*; use std::sync::atomic::AtomicUsize; + #[test] + fn identity_header_precedes_buffered_records_independent_of_log_filter() { + let dir = temp_dir("identity-header"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write(&"earlier startup record".repeat(1000)); + sink.attach_with_header(File::create(&path).unwrap(), "crash-report session 1-2\n"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("crash-report session 1-2\n")); + assert!(text.contains("earlier startup record")); + assert!(attach_session_log_with_header(&dir, &"x".repeat(1025)).is_err()); + drop(sink); + std::fs::remove_dir_all(dir).unwrap(); + } + /// `RUST_LOG` picks what start-up prints to stderr (#234). #[test] fn rust_log_filters_by_level_and_target() { diff --git a/docs/testing.md b/docs/testing.md index e84a9a23b..6c3d91626 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -112,3 +112,24 @@ and line instead of being discarded. `--fail-over PCT` requires the same run key successful latest runs, and at least one comparable resource metric per run. Failed latest runs, missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit `--fail-over` for an exploratory partial comparison; failed latest runs remain visible. + +## Unexpected desktop exit diagnostics + +The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU. +A normal exit (including a handled startup error) sends a clean marker only after desktop +teardown. An abort or other unexpected process termination closes the pipe without that marker. +The helper saves a local report under the session log folder's `Crash Reports` directory and +shows a native error dialog. Control-server launches save the report without a modal dialog. +Reports contain version/platform and at most the last 64 KiB of the matching session's log. +Logging filters and the session file size cap can omit later events from that tail. +They are never uploaded automatically. Review logs for private project paths before sharing. + +Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown, +actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper, +log rotation and report-write failure coverage. The three ignored tests are subprocess entry +points launched by these tests, not manual tests. + +This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure +or provide the process's exit code. System-wide exhaustion, killing the whole process tree, +or an unavailable native dialog service can also prevent the helper from reporting. Existing +auto-save recovery remains responsible for restoring previously saved work. From 59c41fee0a80c462a2443f9214ddf1035aa67b15 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 20:21:23 +0100 Subject: [PATCH 14/18] #3028: keep dedicated preview lanes waiting on GPU admission --- crates/ui-egui/src/frames.rs | 26 ++++++++-- crates/ui-egui/src/frames/tests.rs | 78 ++++++++++++++++++++++++++++++ docs/architecture.md | 3 +- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 0efcab351..32f0dd161 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -101,6 +101,12 @@ impl GpuRenderSlots { /// A Rayon helper may be executing a nested frame while an admitted render waits for /// its work. Blocking that helper on a permit would deadlock the render (#3028). fn for_render(&self, max: usize) -> Option> { + // Dedicated preview pools have one thread, protected from nested drains. They cannot + // be helpers of another admitted frame, so waiting preserves GPU preview throughput. + #[cfg(not(target_arch = "wasm32"))] + if DRAINING_LANE.get() { + return Some(self.acquire(max)); + } if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) } } @@ -1686,15 +1692,29 @@ struct Worker { thread_local! { /// This pool thread is inside [`Worker::drain`] (rendering a frame). static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Only the single thread of a dedicated preview lane may wait for GPU admission. + static DRAINING_LANE: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Clears [`DRAINING`] when the drain ends, even by a panic. #[cfg(not(target_arch = "wasm32"))] struct Draining; +#[cfg(not(target_arch = "wasm32"))] +impl Draining { + fn enter(lane: Option) -> Option { + if DRAINING.replace(true) { + return None; + } + DRAINING_LANE.set(lane.is_some()); + Some(Self) + } +} + #[cfg(not(target_arch = "wasm32"))] impl Drop for Draining { fn drop(&mut self) { + DRAINING_LANE.set(false); DRAINING.set(false); } } @@ -1723,11 +1743,7 @@ impl Worker { /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] fn drain(&self, lane: Option) { - if DRAINING.get() { - return; - } - DRAINING.set(true); - let _reset = Draining; + let Some(_reset) = Draining::enter(lane) else { return }; while self.run_next(lane) {} } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index cb3e41f21..0a148b83c 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3237,6 +3237,42 @@ fn gpu_worker_routes_admit_display_readback_and_auto_effects() { assert!(worker.gpu_ready()); assert!(gpu.context().check_health().is_ok()); + // The real dedicated-lane drain waits, then caches a GPU frame instead of silently + // switching a healthy preview to CPU rendering while the other lanes are busy. + frames.queue.lock().unwrap().jobs.push(Job { + key: job.key, + src: job.src.clone(), + comp, + t: job.t, + opts: RenderOpts { backend: Backend::Gpu, ..job.opts }, + urgent: false, + background: true, + seq: 1, + cancel: Arc::default(), + }); + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, finished) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + started.send(()).unwrap(); + worker.drain(Some(0)); + tx.send(()).unwrap(); + }) + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, finished) + }); + assert!(early.is_err(), "preview lane must wait for an available GPU permit"); + assert_eq!(finished, Ok(())); + assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu()); + // A retirement found inside the scope releases the permit before the fresh CPU retry. gpu.context().device_failure_notifier()("synthetic worker retirement".into()); let before = admissions(); @@ -3288,3 +3324,45 @@ fn gpu_render_slots_never_block_rayon_helpers() { assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits"); assert_eq!(*slots.busy.lock().unwrap(), 0); } + +/// Dedicated preview lanes have one protected thread and can wait without creating the +/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { + let slots = GpuRenderSlots::new(); + let first = slots.acquire(2); + let second = slots.acquire(2); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, admitted) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain"); + assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker"); + started.send(()).unwrap(); + tx.send(slots.for_render(2).is_some()).unwrap(); + }); + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, admitted) + }); + assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU"); + assert_eq!(admitted, Ok(true)); + pool.install(|| { + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + let panic = std::panic::catch_unwind(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + panic!("synthetic lane failure"); + }); + assert!(panic.is_err()); + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + }); +} diff --git a/docs/architecture.md b/docs/architecture.md index 32949351b..195634eb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -414,7 +414,8 @@ tests, device errors are logged rather than fatal. A viewer frame that runs out (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer runs, at most two of them start GPU render attempts at once. Shared-pool workers use the CPU -when both slots are busy rather than blocking Rayon helpers; other render threads wait, so +when both slots are busy rather than blocking Rayon helpers; dedicated single-thread preview +lanes wait for admission and remain on the GPU, so working-texture pressure is bounded by concurrent attempts rather than the thread count (#3028). A single large frame or retained frames can still exceed the available video memory. The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on From 47c9b4a99b01c53f76f2537f5d3ec656b33fd896 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:58:45 +0100 Subject: [PATCH 15/18] #3357: finish crash reporter after accepted macOS teardown --- apps/effectcraft/src/crash_report.rs | 80 +++++++++++++++++++++++++++- apps/effectcraft/src/main.rs | 42 +++++++++------ docs/testing.md | 5 +- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs index 1dd25c571..837161811 100644 --- a/apps/effectcraft/src/crash_report.rs +++ b/apps/effectcraft/src/crash_report.rs @@ -60,6 +60,32 @@ fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { Err(io::Error::other("crash reporter termination could not be confirmed")) } +/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path. +#[derive(Clone, Default)] +pub struct Shutdown(std::sync::Arc>>); + +impl Shutdown { + /// Install the one reporter created for this desktop launch. + pub fn install(&self, reporter: Reporter) -> io::Result<()> { + let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?; + *slot = Some(reporter); + Ok(()) + } + + /// Consume the reporter exactly once, after the applicable platform teardown boundary. + pub fn finish(&self) -> io::Result<()> { + let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take(); + reporter.map_or(Ok(()), Reporter::finish) + } +} + +/// A panic during teardown must leave the reporter armed. macOS terminates after this hook +/// without returning to main; other platforms pass None and finish after run_native returns. +pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> { + teardown(); + reporter.map_or(Ok(()), Shutdown::finish) +} + /// Each process/run has its own report, including simultaneous launches. pub fn run_id() -> String { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); @@ -363,6 +389,51 @@ mod tests { } } + #[test] + fn accepted_native_close_finishes_before_process_termination() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "native-close") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + wait_file(&dir.join("ready-8-1")); + assert!(!dir.join("crash-8-1.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_teardown_panic_does_not_mark_the_reporter_clean() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-2")).unwrap(); + let result = std::panic::catch_unwind(|| { + let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure")); + }); + assert!(result.is_err()); + drop(reporter); + wait_file(&dir.join("ready-8-2")); + assert!(dir.join("crash-8-2.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn platforms_returning_to_main_keep_the_reporter_armed_until_then() { + let dir = folder(); + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-3")).unwrap(); + after_teardown(None, || {}).unwrap(); + assert!(reporter.0.lock().unwrap().is_some()); + reporter.clone().finish().unwrap(); + reporter.finish().unwrap(); + wait_file(&dir.join("ready-8-3")); + assert!(!dir.join("crash-8-3.txt").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn an_aborting_parent_leaves_one_diagnostic_report() { let dir = folder(); @@ -475,8 +546,15 @@ mod tests { #[test] #[ignore] fn aborting_parent() { - assert_eq!(std::env::var(ROLE).unwrap(), "abort"); + let role = std::env::var(ROLE).unwrap(); let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + if role == "native-close" { + let reporter = Shutdown::default(); + reporter.install(helper(&dir, "8-1")).unwrap(); + after_teardown(Some(&reporter.clone()), || {}).unwrap(); + std::process::exit(0); // Models macOS not returning from its native event loop. + } + assert_eq!(role, "abort"); let _reporter = helper(&dir, "4-1"); std::process::abort(); } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index ce4bcdd84..3d9455c81 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -187,19 +187,17 @@ fn main() -> eframe::Result { }) { return Ok(()); } - let mut reporter = None; - let result = run_desktop(&mut reporter); + let reporter = crash_report::Shutdown::default(); + let result = run_desktop(&reporter); // run_desktop has dropped the app and handled any startup error with its existing dialog. // An unwinding panic or abort never reaches this marker. - if let Some(reporter) = reporter - && let Err(error) = reporter.finish() - { + if let Err(error) = reporter.finish() { log::warn!("could not mark crash reporter clean: {error}"); } result } -fn run_desktop(reporter: &mut Option) -> eframe::Result { +fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -235,7 +233,11 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result } let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { - Ok(started) => *reporter = Some(started), + Ok(started) => { + if let Err(error) = reporter.install(started) { + log::warn!("crash reporter could not be retained: {error}"); + } + } Err(error) => log::warn!("crash reporter could not start: {error}"), } // The font menus list the installed fonts: read their names while the window opens. @@ -272,6 +274,7 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; + let desktop_reporter = reporter.clone(); let result = eframe::run_native( "EffectCraft", options, @@ -425,6 +428,7 @@ fn run_desktop(reporter: &mut Option) -> eframe::Result window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, + reporter: desktop_reporter, })) }), ); @@ -518,6 +522,7 @@ struct Desktop { launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, + reporter: crash_report::Shutdown, } impl eframe::App for Desktop { @@ -552,15 +557,20 @@ impl eframe::App for Desktop { } fn on_exit(&mut self) { - // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends - // before `eframe::run_native` returns (#341). - log::info!("the window is closing"); - self.launch.exited(); - self.window.save(self.app.session.config.as_deref()); - self.app.on_exit(); - effectcraft_host::stop_helpers(); - // Scripts in Scripts/Shutdown run with the project closed. - let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + // macOS terminates after this hook without returning from run_native (#341). + // Other platforms keep the reporter armed until main sees complete event-loop teardown. + let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone()); + if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || { + log::info!("the window is closing"); + self.launch.exited(); + self.window.save(self.app.session.config.as_deref()); + self.app.on_exit(); + effectcraft_host::stop_helpers(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); + }) { + log::warn!("could not mark crash reporter clean after teardown: {error}"); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/docs/testing.md b/docs/testing.md index 6c3d91626..54152b9dc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -117,7 +117,10 @@ missing coverage, empty results and wall-time-only measurements cannot pass the The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU. A normal exit (including a handled startup error) sends a clean marker only after desktop -teardown. An abort or other unexpected process termination closes the pipe without that marker. +teardown. macOS finishes at the end of its successful exit hook because its native event loop +terminates without returning to main; other platforms finish after the event loop returns. +A teardown panic leaves reporting armed. An abort or other unexpected process termination +closes the pipe without that marker. The helper saves a local report under the session log folder's `Crash Reports` directory and shows a native error dialog. Control-server launches save the report without a modal dialog. Reports contain version/platform and at most the last 64 KiB of the matching session's log. From 8d9d9c446b994f39d56e7fffa53f34032acac52d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:04:08 +0100 Subject: [PATCH 16/18] #3357: preserve concurrent session log identity --- apps/effectcraft/src/crash_report.rs | 29 +++++++++++++++++++++++++--- crates/engine/src/system/logging.rs | 22 ++++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs index 837161811..32306aa5c 100644 --- a/apps/effectcraft/src/crash_report.rs +++ b/apps/effectcraft/src/crash_report.rs @@ -182,7 +182,7 @@ fn clean_exit(input: &mut impl Read) -> io::Result { } enum SessionLog { - Captured(File), + Captured { file: File, marker: String }, Unavailable(String), } @@ -201,7 +201,7 @@ impl SessionLog { Ok(file) })(); match opened { - Ok(file) => Self::Captured(file), + Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) }, Err(e) => Self::Unavailable(e.to_string()), } } @@ -209,7 +209,15 @@ impl SessionLog { fn write_tail(&mut self, report: &mut File) -> io::Result<()> { match self { Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), - Self::Captured(file) => { + Self::Captured { file, marker } => { + // An older concurrent launcher may have truncated the captured file. + // Recheck its identity before copying any content into this report. + file.seek(SeekFrom::Start(0))?; + let mut header = [0u8; HEADER_BYTES]; + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() { + return writeln!(report, "Recent log unavailable: session identity changed"); + } let size = file.metadata()?.len(); file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; @@ -350,6 +358,21 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn captured_log_rejects_a_session_overwritten_after_open() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap(); + let mut log = SessionLog::open(Some(&path), "1-2"); + std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap(); + let report = write_report(&dir, "1-2", &mut log, None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + assert!(!text.contains("session 3-4")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn session_identity_requires_the_exact_first_line() { let dir = folder(); diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 198aa3b46..2bb3518a1 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -214,10 +214,16 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } } let path = numbered(dir, 0); - let file = File::create(&path)?; + let file = create_session_file(&path)?; Ok((path, file)) } +// Another process may create the current log after our rotation. Never truncate its +// file: its writer and crash reporter may already hold the same inode open. +fn create_session_file(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new().write(true).create_new(true).open(path) +} + /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { @@ -428,6 +434,20 @@ mod tests { assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } + #[test] + fn concurrent_session_creation_does_not_truncate_the_winner() { + let dir = temp_dir("concurrent-create"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(SESSION_LOG); + let mut winner = create_session_file(&path).unwrap(); + winner.write_all(b"crash-report session 1-2\nwinner content\n").unwrap(); + assert_eq!(create_session_file(&path).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + winner.write_all(b"still the same session\n").unwrap(); + assert_eq!(read(&path), "crash-report session 1-2\nwinner content\nstill the same session\n"); + drop(winner); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { From 2d6d08f29fe440275eaa264d3d6a5d59049b47e8 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 22:54:40 +0100 Subject: [PATCH 17/18] #1388: retain whole text raster for animator blur --- crates/render/src/pipeline.rs | 2 +- crates/render/src/tests/raster_clip.rs | 26 ++++++++++++++++++++++++++ crates/render/src/text.rs | 10 ++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 9800b188d..6265cb5f7 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -488,7 +488,7 @@ impl<'a> Renderer<'a> { || styles::active(ctx, layer) || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) || self.is_adv3d(ctx) - || (matches!(layer.source, LayerSource::Text) && text::per_char_3d(ctx, layer)) + || (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer))) { return None; } diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs index f7227f634..c85176fca 100644 --- a/crates/render/src/tests/raster_clip.rs +++ b/crates/render/src/tests/raster_clip.rs @@ -332,3 +332,29 @@ fn fine_clip_cells_have_distinct_cache_keys() { assert_ne!(key(a), key(kurbo::Rect::ZERO)); assert_ne!(key(b), key(kurbo::Rect::ZERO)); } + +/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window. +#[test] +fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = build::text_anim_props(&mut ids, "blur", false); + let mut animator = build::text_animator(&mut ids, "Blur", props); + animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into()); + let opts = RenderOpts { scale: 0.125, ..Default::default() }; + let cache = crate::LayerCache::default(); + for dx in [-10.0, 0.0, 10.0] { + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0])); + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels"); + let mut whole = Renderer::new(&p, &NoFootage, opts); + whole.whole_raster = true; + assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3a2110e0d..1e95bbc11 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -509,6 +509,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool { layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d")) } +/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively +/// keep the whole raster even when the current selector happens to select no characters. +pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool { + layer.props.group("text/animators").is_some_and(|anims| { + anims.groups().filter(|anim| anim.enabled).any(|anim| { + anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0)) + }) + }) +} + /// One drawn character. #[derive(Clone, Debug)] pub struct PlacedGlyph { From 360e21f539658eb4545733f396f4cb190c45f3ae Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 23:36:18 +0100 Subject: [PATCH 18/18] Release: EffectCraft v0.23.1 --- Cargo.lock | 78 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f45bf8414..8a83ae444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.21.0" +version = "0.23.1" dependencies = [ "cpal", "craft-update", @@ -1446,7 +1446,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1460,7 +1460,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1474,14 +1474,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.21.0" +version = "0.23.1" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1508,7 +1508,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "effectcraft-color", @@ -1526,7 +1526,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "craft-docpath", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.21.0" +version = "0.23.1" dependencies = [ "boa_engine", "effectcraft-color", @@ -1613,7 +1613,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1621,7 +1621,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "effectcraft-color", @@ -1644,7 +1644,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1655,14 +1655,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1689,7 +1689,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1771,7 +1771,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "image", @@ -1784,14 +1784,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.21.0" +version = "0.23.1" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.21.0" +version = "0.23.1" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1845,14 +1845,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.21.0" +version = "0.23.1" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1862,7 +1862,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1885,7 +1885,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "candle-core", @@ -1900,7 +1900,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.21.0" +version = "0.23.1" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1918,7 +1918,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.21.0" +version = "0.23.1" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1939,7 +1939,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1951,7 +1951,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.21.0" +version = "0.23.1" dependencies = [ "proptest", "serde", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1971,7 +1971,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.21.0" +version = "0.23.1" dependencies = [ "bytemuck", "chrono", @@ -2012,11 +2012,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.21.0" +version = "0.23.1" [[package]] name = "effectcraft-web" -version = "0.21.0" +version = "0.23.1" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7711,7 +7711,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.21.0" +version = "0.23.1" dependencies = [ "chrono", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index b85c3c6b8..aa1df1a6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.21.0" +version = "0.23.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95"