diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 7b190b7d..dbb65cb6 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -73,10 +73,47 @@ impl Pipe { Conversion::new(ws, self.linear, ColorSpace::Srgb, false) } + /// One pixel clamped and quantised to the bit depth. + pub fn quantize_px(&self, mut p: [f32; 4]) -> [f32; 4] { + if let Some(l) = self.levels { + quantize_px(&mut p, l, 1.0 / l); + } + p + } + /// Clamp and quantise to the bit depth (no-op in 32 bpc). pub fn quantize(&self, img: &mut Image) { - if let Some(l) = self.levels { - quantize(img, l); + self.quantize_region(img, Region::Full); + } + + /// Clamp and quantise the pixels of `region` (no-op in 32 bpc). Pixels outside it must + /// already be quantised (checked in debug builds). + pub fn quantize_region(&self, img: &mut Image, region: Region) { + let Some(l) = self.levels else { return }; + let r = match region { + Region::Empty => None, + Region::Full => Some(PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }), + Region::Rect(r) => Some(r.clamp_to(img.width, img.height)), + }; + if let Some(r) = r { + quantize_rect(img, r, l); + } + #[cfg(debug_assertions)] + if img.data.len() <= 1 << 20 { + let inv = 1.0 / l; + let bad = img.data.iter().position(|p| { + let q = |v: f32, hi: f32| (v.clamp(0.0, hi) * l).round() * inv; + let a = q(p[3], 1.0); + (p[3] - a).abs() > 1e-5 || (0..3).any(|c| (p[c] - q(p[c], a)).abs() > 1e-5) + }); + if let Some(i) = bad { + panic!( + "pixel ({}, {}) outside the quantised region {region:?} is not quantised: {:?}", + i as u32 % img.width, + i as u32 / img.width, + img.data[i] + ); + } } } @@ -89,14 +126,67 @@ impl Pipe { } } +/// A pixel rectangle `[x0, x1) × [y0, y1)`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PxRect { + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, +} + +impl PxRect { + /// The pixels covering the real-valued box `[x0, x1] × [y0, y1]` grown by `margin`. + pub fn covering(x0: f64, y0: f64, x1: f64, y1: f64, margin: f64) -> Option { + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return None; + } + let c = |v: f64| v.clamp(0.0, u32::MAX as f64) as u32; + Some(PxRect { x0: c((x0 - margin).floor()), y0: c((y0 - margin).floor()), x1: c((x1 + margin).ceil()), y1: c((y1 + margin).ceil()) }) + } + fn clamp_to(self, w: u32, h: u32) -> PxRect { + let (x1, y1) = (self.x1.min(w), self.y1.min(h)); + PxRect { x0: self.x0.min(x1), y0: self.y0.min(y1), x1, y1 } + } +} + +/// The part of a canvas that a drawing step may have changed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Region { + Empty, + Rect(PxRect), + Full, +} + /// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps. pub fn quantize(img: &mut Image, levels: f32) { + quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels); +} + +/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the +/// clamp, so the integer conversion rounds like `round()` and vectorises. +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) { + let a = (p[3].max(0.0).min(1.0) * levels + 0.5) as u32 as f32 * inv; + let hi = [a, a, a, a]; + for c in 0..3 { + p[c] = (p[c].max(0.0).min(hi[c]) * levels + 0.5) as u32 as f32 * inv; + } + p[3] = a; +} + +/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel. +fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { + if r.x0 >= r.x1 || r.y0 >= r.y1 { + return; + } let inv = 1.0 / levels; - img.data.par_iter_mut().for_each(|p| { - let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv; - p[3] = a; - for c in 0..3 { - p[c] = (p[c].clamp(0.0, a) * levels).round() * inv; + let w = img.width as usize; + let (x0, x1) = (r.x0 as usize, r.x1 as usize); + img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| { + for p in &mut row[x0..x1] { + quantize_px(p, levels, inv); } }); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 05c05efa..dc184588 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ pub mod audio; pub mod cache; pub mod color; +use color::Region; pub mod eval; pub mod masks; pub mod shapes; @@ -265,22 +266,25 @@ impl<'a> Renderer<'a> { /// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their /// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. - fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { + /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` + /// quantise their own footprints). + fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { - return; + return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { - sub.draw_comp(&nctx, canvas); + sub.draw_comp(&nctx, canvas, blank); } - return; + return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); - sub.draw_comp(&nctx, &mut iso); + sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); + Region::Full } /// Rasterisation scale of a layer's source: the output scale, or — for Continuously @@ -347,11 +351,12 @@ impl<'a> Renderer<'a> { return canvas; } let ctx = self.ctx(comp_id, comp, t); - self.draw_comp(&ctx, &mut canvas); + self.draw_comp(&ctx, &mut canvas, true); + // The canvas is already quantised after every layer; only re-encoding changes it. if let Some(c) = self.pipe.from_blend() { color::convert(&mut canvas, &c); + self.pipe.quantize(&mut canvas); } - self.pipe.quantize(&mut canvas); // The top-level comp leaves the working space for the (sRGB) display / output. if self.depth == 0 && let Some(c) = self.pipe.output() @@ -363,7 +368,8 @@ impl<'a> Renderer<'a> { } /// Draw a comp's layers (bottom to top) into `canvas` (blending space). - fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) { + /// `blank`: the canvas is fully transparent (lets the first layer skip re-quantising). + fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image, mut blank: bool) { let (comp, t) = (ctx.comp, ctx.time); let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); // Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first). @@ -382,15 +388,19 @@ impl<'a> Renderer<'a> { } three_d::compose::draw_run(self, ctx, &visible[i..j], canvas); i = j; + self.pipe.quantize(canvas); + blank = false; } else { - match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas), - None => self.draw_layer(ctx, visible[i], canvas), - } + let changed = match self.collapsed(ctx, visible[i]) { + Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + None => self.draw_layer(ctx, visible[i], canvas, blank), + }; i += 1; + // 8/16 bpc: the comp is an integer buffer after every layer (only the pixels the + // layer touched need re-quantising). + self.pipe.quantize_region(canvas, changed); + blank = false; } - // 8/16 bpc: the comp is an integer buffer after every layer. - self.pipe.quantize(canvas); } } @@ -467,7 +477,8 @@ impl<'a> Renderer<'a> { if let Some(conv) = self.pipe.authored_in() { c = conv.apply(c); } - Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s }) + let px = self.pipe.quantize_px([c[0], c[1], c[2], 1.0]); + Some(Buf { img: Image::filled(w, h, px), offset: [0.0; 2], scale: s }) } LayerSource::Comp { item } => { if self.project.comp_contains(*item, ctx.comp_id) { @@ -562,8 +573,11 @@ impl<'a> Renderer<'a> { /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { let mut buf = self.source(ctx, layer)?; - masks::apply(ctx, layer, &mut buf); - self.pipe.quantize(&mut buf.img); + // A solid is filled with a quantised colour: only masks can take it off the grid. + let solid = matches!(layer.source, LayerSource::Solid { .. }); + if masks::apply(ctx, layer, &mut buf) || !solid { + self.pipe.quantize(&mut buf.img); + } Some(buf) } @@ -733,13 +747,57 @@ impl<'a> Renderer<'a> { Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } - /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. - fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { - let samples = if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { + /// Motion-blur sub-samples for a layer (1 = no motion blur). + fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize { + if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } } else { 1 - }; + } + } + + /// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's + /// bounds plus the resampling footprint. Stencil/silhouette modes, motion blur and + /// perspective fall back to the whole canvas. + /// + /// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank + /// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise. + fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region { + if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 { + return Region::Full; + } + let m = self.buf_matrix(ctx, layer, buf); + if !m.is_affine() { + return Region::Full; + } + let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); + let k = &m.0; + let integral = |v: f64| v == v.round(); + if blank + && layer.blend_mode == BlendMode::Normal + && opacity == 1.0 + && !matte + && !layer.preserve_transparency + && self.pipe.to_blend().is_none() + && k[0][0] == 1.0 + && k[1][1] == 1.0 + && k[0][1] == 0.0 + && k[1][0] == 0.0 + && integral(k[0][2]) + && integral(k[1][2]) + { + return Region::Empty; + } + let (w, h) = (buf.img.width as f64, buf.img.height as f64); + let c = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(x, y)| m.apply(vec2(x, y))); + let (x0, x1) = (c.iter().map(|p| p.x).fold(f64::INFINITY, f64::min), c.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max)); + let (y0, y1) = (c.iter().map(|p| p.y).fold(f64::INFINITY, f64::min), c.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max)); + color::PxRect::covering(x0, y0, x1, y1, 3.0).map_or(Region::Full, Region::Rect) + } + + /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. + fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { + let samples = self.mb_samples(ctx, layer); let opts = WarpOpts { sampling: self.sampling(layer), opacity, mode, seed: layer.id.0 as u32, clip: None }; if samples <= 1 { let m = self.buf_matrix(ctx, layer, buf); @@ -761,29 +819,32 @@ impl<'a> Renderer<'a> { target.blend_from(&acc, mode, opacity, layer.id.0 as u32); } - fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { + /// Draw a 2D layer into `canvas`; returns the region it may have changed. + fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment { self.draw_wireframe(ctx, layer, canvas); - return; + return Region::Full; } if opacity <= 0.0 && !layer.switches.adjustment { - return; + return Region::Empty; } if layer.switches.adjustment { self.draw_adjustment(ctx, layer, canvas, opacity); - return; + return Region::Full; } let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }); let t0 = web_time::Instant::now(); - let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return }; + let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return Region::Empty }; let st = self.styled_to_blend(st); let t1 = web_time::Instant::now(); - if st.plain { + let changed = if st.plain { self.composite_layer(ctx, layer, &st.body, canvas, opacity); + self.footprint(ctx, layer, &st.body, opacity, blank) } else { self.composite_styled(ctx, layer, &st, canvas, opacity); - } + Region::Full + }; if let (Some(prof), Some(mut timing)) = (self.profile, timing) { timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; @@ -791,6 +852,7 @@ impl<'a> Renderer<'a> { v.push(timing); } } + changed } /// Composite a styled layer: exterior passes with their own modes, then the body with the @@ -882,7 +944,7 @@ impl<'a> Renderer<'a> { /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint. fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) { let Some(mut foot) = self.source(ctx, layer) else { return }; - masks::apply(ctx, layer, &mut foot); + let _ = masks::apply(ctx, layer, &mut foot); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); let mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale }; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index b18a069f..1693e4d3 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -113,11 +113,12 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec bool { + let Some(masks) = layer.masks() else { return false }; let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect(); if list.is_empty() { - return; + return false; } // Masks may extend past the source: grow the buffer to cover them when feather/expansion pads. let n = (buf.img.width * buf.img.height) as usize; @@ -146,4 +147,5 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) { }); } buf.img.mul_mask(&acc); + true } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 53ba8cdb..1ef7412b 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -316,7 +316,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut // 3D adjustment layers act on everything below them in the stack (like 2D ones): split. if let Some(k) = run.iter().position(|l| l.switches.adjustment) { draw_run(r, ctx, &run[..k], canvas); - r.draw_layer(ctx, run[k], canvas); + r.draw_layer(ctx, run[k], canvas, false); draw_run(r, ctx, &run[k + 1..], canvas); return; } @@ -363,7 +363,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { - r.draw_layer(ctx, l, canvas); + r.draw_layer(ctx, l, canvas, false); } }