mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
Skip transparent pixels and quantise while compositing
Much of a layer buffer is often transparent (a stroked ring, a glow-padded text layer, a precomp canvas), yet every destination pixel sampled it bilinearly, and 8/16 bpc comps then re-quantised the layer's whole rotated bounding box. - Bilinear warps skip 16x16 source tiles whose pixels (and a one-pixel apron) all have zero alpha: their samples are transparent and were never drawn. - The fast affine warp reports the rectangle it changed and, given the comp's levels, quantises each pixel as it blends it, so draw_comp has nothing left to re-quantise for that layer. quantize_px moves to effectcraft-raster for this. - quantize_rect skips runs of four all-zero (+0.0) pixels, which quantise to themselves. - to_rgba8 / to_rgba8_over convert row by row instead of per 4-byte item. Every pixel stays bit-identical: tests compare the warp against the reference path, fused quantising against quantising after blending, and the zero-run skip against per-pixel quantising. With the previous commit, demo comp to H.264 (`cargo xtask bench --scenario demo --format h264 --frames 300`, 11 interleaved runs on a 16-thread Ryzen): wall 9.26 s -> 6.97 s median (-24.8%), 8.55 s -> 6.55 s min (-23.3%); CPU time -23%. Output byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d8bca3cdd9
commit
db41fa7980
+45
-23
@@ -21,7 +21,7 @@
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{Image, quantize_px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The colour pipeline of a project (see the module docs).
|
||||
@@ -218,27 +218,6 @@ 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 = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
|
||||
let hi = [a, a, a, a];
|
||||
for c in 0..3 {
|
||||
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
|
||||
}
|
||||
p[3] = a;
|
||||
}
|
||||
|
||||
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
|
||||
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
|
||||
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
|
||||
#[inline(always)]
|
||||
fn round_half_up(v: f32) -> f32 {
|
||||
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
|
||||
}
|
||||
|
||||
/// [`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 {
|
||||
@@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
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] {
|
||||
// Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a
|
||||
// cache line at a time (sparse shape, precomp and glow buffers are mostly empty).
|
||||
let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>();
|
||||
for b in blocks {
|
||||
if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 {
|
||||
for p in b {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in rest {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
});
|
||||
@@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit
|
||||
/// (negative zeros and lone pixels in empty runs included).
|
||||
#[test]
|
||||
fn quantize_skips_only_zero_runs() {
|
||||
let mut img = Image::new(23, 7);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = match i % 11 {
|
||||
0 => [0.3, -0.2, 0.71, 0.9],
|
||||
3 => [-0.0, 0.0, 0.0, 0.0],
|
||||
5 => [0.0, 0.0, 0.0, -0.0],
|
||||
7 => [1.7, 0.4, f32::NAN, 0.5],
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
}
|
||||
for levels in [255.0f32, 32_768.0] {
|
||||
let mut want = img.clone();
|
||||
for p in &mut want.data {
|
||||
quantize_px(p, levels, 1.0 / levels);
|
||||
}
|
||||
for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] {
|
||||
let mut got = img.clone();
|
||||
quantize_rect(&mut got, r, levels);
|
||||
for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() {
|
||||
let (x, y) = (i as u32 % 23, i as u32 / 23);
|
||||
let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p };
|
||||
assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
|
||||
/// 0..=levels, exact halves included.
|
||||
#[test]
|
||||
|
||||
@@ -342,11 +342,26 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
|
||||
pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
|
||||
self.place_changed(ctx, layer, buf, target, mode, opacity, None);
|
||||
}
|
||||
|
||||
/// [`Self::place`], returning the target pixels it changed when the warp tracked them (then
|
||||
/// also quantised to `quantize` levels; see [`composite_warp_changed`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place_changed(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
buf: &Buf,
|
||||
target: &mut Image,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
quantize: Option<f32>,
|
||||
) -> Option<[u32; 4]> {
|
||||
let pl = self.placement(ctx, layer, buf);
|
||||
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
|
||||
if let [m] = pl.matrices.as_slice() {
|
||||
composite_warp(target, &buf.img, m, &opts);
|
||||
return;
|
||||
return composite_warp_changed(target, &buf.img, m, &opts, quantize);
|
||||
}
|
||||
// Sub-samples sum straight into one buffer (each warp is row-parallel).
|
||||
let mut acc = Image::new(target.width, target.height);
|
||||
@@ -355,6 +370,7 @@ impl<'a> Renderer<'a> {
|
||||
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
|
||||
}
|
||||
target.blend_from(&acc, mode, opacity, pl.seed);
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
|
||||
@@ -377,8 +393,14 @@ impl<'a> Renderer<'a> {
|
||||
let st = self.styled_to_blend(st);
|
||||
let t1 = web_time::Instant::now();
|
||||
let changed = if st.plain {
|
||||
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
|
||||
self.footprint(ctx, layer, &st.body, opacity, blank)
|
||||
// Pixels the warp tracks are quantised as it blends them (nothing left to do); the
|
||||
// footprint is still needed when it doesn't, or when no pixel needs quantising.
|
||||
let footprint = self.footprint(ctx, layer, &st.body, opacity, blank);
|
||||
let quantize = if footprint == Region::Empty { None } else { self.pipe.levels };
|
||||
match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) {
|
||||
Some(_) if quantize.is_some() => Region::Empty,
|
||||
drawn => narrow(footprint, drawn),
|
||||
}
|
||||
} else {
|
||||
self.composite_styled(ctx, layer, &st, canvas, opacity);
|
||||
Region::Full
|
||||
@@ -432,16 +454,18 @@ impl<'a> Renderer<'a> {
|
||||
});
|
||||
}
|
||||
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
|
||||
/// Composite a plain layer; returns the canvas pixels it changed when they are known (then
|
||||
/// also quantised to `quantize` levels).
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option<f32>) -> Option<[u32; 4]> {
|
||||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||||
if !matte && !layer.preserve_transparency {
|
||||
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
|
||||
return;
|
||||
return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize);
|
||||
}
|
||||
// Render in isolation, then matte / preserve transparency, then blend.
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
|
||||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
|
||||
@@ -556,3 +580,21 @@ pub struct Placement {
|
||||
/// Dissolve noise seed.
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]):
|
||||
/// pixels it left alone are still quantised.
|
||||
fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region {
|
||||
let Some([x0, y0, x1, y1]) = drawn else { return footprint };
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return Region::Empty;
|
||||
}
|
||||
let r = color::PxRect { x0, y0, x1, y1 };
|
||||
match footprint {
|
||||
Region::Empty => Region::Empty,
|
||||
Region::Full => Region::Rect(r),
|
||||
Region::Rect(f) => {
|
||||
let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) };
|
||||
if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa
|
||||
use effectcraft_geom::{Mat3, Mat4, vec2};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
|
||||
pub use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{WarpOpts, composite_warp};
|
||||
use effectcraft_raster::{WarpOpts, composite_warp_changed};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
|
||||
use rayon::prelude::*;
|
||||
|
||||
Reference in New Issue
Block a user