From ba04ab70a0274c022c5139dd08b554f83f387235 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 17:47:39 +0300 Subject: [PATCH] 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) {