Merge remote-tracking branch 'origin/main' into fix/sec-roto-span

This commit is contained in:
Dylan M. Taylor
2026-11-11 09:27:19 +00:00
6 changed files with 53 additions and 9 deletions
+3 -1
View File
@@ -274,7 +274,9 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize]
let mut cur = img.clone();
let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0);
for (vertical, r) in passes {
let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32;
// Vertical sums restart every `block` rows as the CPU's do; a row is one running sum, as
// on the CPU (restarting it rounds differently).
let block = if vertical { (4 * r).clamp(32, 256) as u32 } else { cur.width.max(1) };
let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) };
let mut p = Params::default();
p.u[0] = [r as u32, repeat as u32, block, 0];
+1 -1
View File
@@ -270,7 +270,7 @@ fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4<f
}
// One box pass of radius r along rows (horizontal) or columns, as running sums over blocks of
// `block` pixels (each block restarts its sum, as the CPU's vertical pass does).
// `block` pixels (each block restarts its sum, as the CPU's vertical pass does; rows are one block).
fn box_pass(gid: vec3<u32>, horizontal: bool) {
let dims = out_dims();
let r = i32(P.u[0].x);
+29
View File
@@ -731,6 +731,35 @@ fn small_blurs_fall_back_to_the_cpu() {
}
}
/// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum
/// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path.
/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters use the usual
/// Bpc32 tolerance because a tighter bound can fail on unmeasured hardware and does not catch
/// the regression (Metal still differs by ~1e-6 with or without the fix).
#[test]
fn box_blur_horizontal_rows_match_cpu_beyond_256() {
let Some(g) = gpu() else { return };
let img = pattern(11, 320, 48);
let up = g.ctx.upload_image(&img).expect("upload");
let tol = if on_llvmpipe() { 0.0 } else { tolerance(BitDepth::Bpc32) };
let check_case = |name: &str, want: Image, got: Image| {
assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size");
let d = diff(&want, &got, tol);
assert_eq!(d.over, 0, "{name}: max {} at {:?} on {}", d.max, d.worst, g.ctx.name);
};
for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] {
let mut e = crate::context::Enc::new(&g.ctx);
let rx = [r, r, r];
let out = crate::effects::box_passes(&mut e, &up, &rx, &rx, false);
check_case(name, effectcraft_raster::box_blur(&img, r, r, 3, false), e.download(&out).expect("download"));
}
for (name, sigma) in [("gauss σ10", 10.0f64), ("gauss σ40", 40.0)] {
let mut e = crate::context::Enc::new(&g.ctx);
let out = crate::effects::gaussian_blur(&mut e, &up, sigma, sigma, false);
check_case(name, effectcraft_raster::gaussian_blur(&img, sigma, sigma, false), e.download(&out).expect("download"));
}
}
#[test]
fn gpu_effects_match_cpu() {
// Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939).
+6 -3
View File
@@ -340,10 +340,13 @@ whichever compositor measured faster for it (`render::auto::AutoPick`, kept by t
per comp, output scale and path (readback or viewer display) it averages CPU and GPU frame times
(warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped),
picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames.
Render Queue jobs don't use Auto: every frame of a job renders on the project's renderer (the
GPU compositor with GPU Acceleration), so runs of a render don't differ by timing.
Light comps (a few small layers, which the CPU composites only within their bounds) then stay on
the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like
the CPU instead of paying for full-frame passes and a readback. Render Queue jobs don't use
Auto per frame: a job renders every frame on one compositor, never a mix (#3335). A GPU
Acceleration job with an accelerator times both on its first frames
(`export::pipeline::Cx::probe`, jobs of 12 frames or more) and keeps the faster, the CPU only
when clearly faster; `EFFECTCRAFT_RQ_RENDERER=gpu|cpu` fixes the choice, and readback-deferred
accelerators (the web) render on the project's renderer. A GPU frame walks the comp like
`draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's
sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur
sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes,
+11 -1
View File
@@ -99,7 +99,17 @@ Commands (CLI, MCP, control channel):
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.
`EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one
frame per thread in flight, so their memory grows with the core count. Set
`EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in
flight of every Render Queue render and export, `effectcraft-cli render` included
([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One
exception: while a job on GPU Acceleration times both compositors on its first frames, it
renders them under the cap but keeps them all (up to 12) until it has chosen. That timing
picks the compositor that a job of 12 frames or more renders every frame on (the render log
names it and why). `EFFECTCRAFT_RQ_RENDERER` set to `gpu` or `cpu` skips it and renders on
that compositor; `auto` (the default) measures. A queue item's later output modules reuse its
first one's choice, and Software Rendering projects always render on the CPU.
- `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
+3 -3
View File
@@ -173,9 +173,9 @@ the same futures with `passes::block_on`; they never wait there).
- **Render Queue**: `effectcraft_export::export_async` renders each frame in passes (frames
with deferred readbacks render one by one instead of in parallel batches) and encodes as
before (`Exporter::export_async`). As on the desktop, a job renders every frame on the
project's renderer (the worker's GPU with GPU Acceleration), not on whichever
compositor measured faster, so runs don't differ by timing.
before (`Exporter::export_async`). Unlike the desktop, which times both compositors on a
job's first frames and keeps the faster, a job renders every frame on the project's renderer
(the worker's GPU with GPU Acceleration), so runs don't differ by timing.
- **Analyses** (Warp Stabilizer, 3D Camera Tracker, Track Motion, mask and face tracking, Roto
Brush freeze and propagation): their work functions are `async`; started with `wait` inside
a job they are queued and awaited (`offload::run_or_queue`), and each input frame renders in