337 lines
15 KiB
Rust
337 lines
15 KiB
Rust
//! GPU-vs-CPU parity of the blend modes (`filmcraft_render::blend::composite` is the reference).
|
||
//!
|
||
//! Tolerances: the composited 8-bit output uses the compositor's parity criterion (99th percentile
|
||
//! of the per-pixel max channel difference ≤ 6, mean < 1.5; antialiased edges and half-float
|
||
//! textures differ slightly). Exact inputs drawn 1:1 (no resampling) must agree to 2e-3 in linear
|
||
//! premultiplied values (half-float accumulator), except Hard Mix, a step function, which may
|
||
//! flip a pixel whose blend lands on its 0.5 threshold.
|
||
|
||
use super::*;
|
||
use filmcraft_geom::{Affine, Vec2};
|
||
use filmcraft_render::plan::execute_cpu;
|
||
|
||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||
let instance = wgpu::Instance::default();
|
||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
|
||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
|
||
}
|
||
|
||
fn yuv_frame(w: u32, h: u32) -> Arc<VideoFrame> {
|
||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||
let y: Vec<u8> = (0..w * h).map(|i| (16 + ((i % w) * 219 / w)) as u8).collect();
|
||
let u: Vec<u8> = (0..cw * ch).map(|i| (64 + (i / cw) * 128 / ch) as u8).collect();
|
||
let v: Vec<u8> = (0..cw * ch).map(|i| (200 - (i % cw) * 100 / cw) as u8).collect();
|
||
Arc::new(VideoFrame {
|
||
width: w,
|
||
height: h,
|
||
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma: Chroma::C420, alpha: None },
|
||
color: filmcraft_color::ColorInfo::REC709,
|
||
par: (1, 1),
|
||
pts: Default::default(),
|
||
})
|
||
}
|
||
|
||
/// Decode an IEEE half.
|
||
fn f16_to_f32(h: u16) -> f32 {
|
||
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
|
||
let e = ((h >> 10) & 0x1f) as i32;
|
||
let m = (h & 0x3ff) as f32;
|
||
match e {
|
||
0 => s * m * 2f32.powi(-24),
|
||
31 => s * f32::INFINITY,
|
||
_ => s * (1.0 + m / 1024.0) * 2f32.powi(e - 15),
|
||
}
|
||
}
|
||
|
||
/// Read the linear premultiplied accumulator back as f32 RGBA (what the CPU plan executor returns).
|
||
fn read_accum(c: &GpuCompositor) -> Vec<f32> {
|
||
let (tex, _, (w, h)) = c.accum.as_ref().expect("accumulator");
|
||
let row = (w * 8).div_ceil(256) * 256;
|
||
let buf = c.device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("accum-readback"),
|
||
size: (row * h) as u64,
|
||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||
mapped_at_creation: false,
|
||
});
|
||
let mut enc = c.device.create_command_encoder(&Default::default());
|
||
enc.copy_texture_to_buffer(
|
||
wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(*h) } },
|
||
wgpu::Extent3d { width: *w, height: *h, depth_or_array_layers: 1 },
|
||
);
|
||
c.queue.submit([enc.finish()]);
|
||
let slice = buf.slice(..);
|
||
slice.map_async(wgpu::MapMode::Read, |_| {});
|
||
let _ = c.device.poll(wgpu::PollType::wait_indefinitely());
|
||
let data = slice.get_mapped_range().expect("mapped");
|
||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
||
for y in 0..*h {
|
||
let r = &data[(y * row) as usize..(y * row + w * 8) as usize];
|
||
out.extend(r.as_chunks::<2>().0.iter().map(|b| f16_to_f32(u16::from_le_bytes(*b))));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Per-pixel max channel difference of two RGBA8 images over the pixels `keep` selects: (p99,
|
||
/// mean, max).
|
||
fn stats8(a: &[u8], b: &[u8], keep: &[bool]) -> (u32, f64, u32) {
|
||
let mut d: Vec<u32> = a
|
||
.chunks(4)
|
||
.zip(b.chunks(4))
|
||
.zip(keep)
|
||
.filter(|(_, k)| **k)
|
||
.map(|((a, b), _)| (0..3).map(|k| (a[k] as i32 - b[k] as i32).unsigned_abs()).max().unwrap_or(0))
|
||
.collect();
|
||
d.sort_unstable();
|
||
(d[d.len() * 99 / 100], d.iter().sum::<u32>() as f64 / d.len() as f64, d[d.len() - 1])
|
||
}
|
||
|
||
/// Pixels at least 1.5 px away from every layer's quad outline (the CPU resampler fades a layer's
|
||
/// edge over a pixel beyond it, the GPU rasterises the quad: compared separately).
|
||
fn interior(plan: &FramePlan) -> Vec<bool> {
|
||
let FramePlan::Layers { width, height, layers } = plan else { return Vec::new() };
|
||
let inv: Vec<(Affine, f64, f64)> = layers.iter().filter_map(|l| Some((l.matrix.inverse()?, l.frame.width as f64, l.frame.height as f64))).collect();
|
||
let mut keep = vec![true; width * height];
|
||
for (i, k) in keep.iter_mut().enumerate() {
|
||
let (x, y) = ((i % width) as f64 + 0.5, (i / width) as f64 + 0.5);
|
||
*k = inv.iter().all(|(m, fw, fh)| {
|
||
let inside = |dx: f64, dy: f64| {
|
||
let p = m.apply(Vec2::new(x + dx, y + dy));
|
||
p.x >= 0.0 && p.y >= 0.0 && p.x <= *fw && p.y <= *fh
|
||
};
|
||
let c = [inside(-1.5, -1.5), inside(1.5, -1.5), inside(-1.5, 1.5), inside(1.5, 1.5)];
|
||
c.iter().all(|v| *v == c[0])
|
||
});
|
||
}
|
||
keep
|
||
}
|
||
|
||
/// Linear premultiplied RGBA f32 test frame: a colour ramp with black and white bands; alpha 0 in
|
||
/// the left tenth, 1 in the top third, partial below.
|
||
fn ramp_layer(w: u32, h: u32, phase: u32) -> Arc<VideoFrame> {
|
||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let fx = x as f32 / (w - 1) as f32;
|
||
let fy = y as f32 / (h - 1) as f32;
|
||
let c = match (x + phase) / 8 % 6 {
|
||
0 => [0.0, 0.0, 0.0],
|
||
1 => [1.0, 1.0, 1.0],
|
||
_ => [fx, (fy * 1.3 + phase as f32 * 0.1).fract(), ((fx + fy) * 0.7).fract()],
|
||
};
|
||
let a = if x < w / 10 {
|
||
0.0
|
||
} else if y < h / 3 {
|
||
1.0
|
||
} else {
|
||
0.25 + 0.75 * fy
|
||
};
|
||
px.extend_from_slice(&[c[0] * a, c[1] * a, c[2] * a, a]);
|
||
}
|
||
}
|
||
Arc::new(VideoFrame::rgba_f32(w, h, px))
|
||
}
|
||
|
||
/// Every blend mode over a backdrop with opaque, partially transparent and empty regions, on two
|
||
/// overlapping transformed layers (rotated, scaled, partly off the output) with partial opacity
|
||
/// and partial alpha: the GPU matches the CPU plan executor within the compositor's tolerance,
|
||
/// away from the layers' outlines (which are reported: the CPU fades them over an extra pixel).
|
||
#[test]
|
||
fn gpu_blend_modes_match_cpu() {
|
||
let Some((dev, q)) = device() else {
|
||
eprintln!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
let mut c = GpuCompositor::new(&dev, &q);
|
||
let (w, h) = (320usize, 180usize);
|
||
let pixels = filmcraft_media::generators::render(
|
||
&filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::Dunes),
|
||
320,
|
||
180,
|
||
1.0,
|
||
24,
|
||
filmcraft_time::FrameRate::FPS_24,
|
||
);
|
||
let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels));
|
||
let base = ramp_layer(200, 180, 3);
|
||
let top = ramp_layer(160, 120, 0);
|
||
for mode in Blend::ALL {
|
||
let plan = FramePlan::Layers {
|
||
width: w,
|
||
height: h,
|
||
layers: vec![
|
||
// opaque YUV on the left, a partially transparent ramp on the right; the top-right
|
||
// corner stays empty (destination alpha 0)
|
||
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal },
|
||
PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal },
|
||
PlanLayer {
|
||
frame: top.clone(),
|
||
matrix: Affine::motion(Vec2::new(170.0, 95.0), Vec2::new(1.1, 1.1), 17.0, Vec2::new(80.0, 60.0)),
|
||
opacity: 0.8,
|
||
blend: mode,
|
||
},
|
||
PlanLayer {
|
||
frame: demo.clone(),
|
||
matrix: Affine::motion(Vec2::new(250.0, 70.0), Vec2::new(0.45, 0.45), -9.0, Vec2::new(160.0, 90.0)),
|
||
opacity: 0.6,
|
||
blend: mode,
|
||
},
|
||
],
|
||
};
|
||
let cpu_img = execute_cpu(&plan);
|
||
let cpu = cpu_img.over_black_rgba8();
|
||
c.composite(&plan);
|
||
let (_, _, gpu) = c.read_output().expect("readback");
|
||
let keep = interior(&plan);
|
||
let (p99, mean, max) = stats8(&cpu, &gpu, &keep);
|
||
let all = vec![true; keep.len()];
|
||
let (ap99, amean, _) = stats8(&cpu, &gpu, &all);
|
||
// linear premultiplied accumulator, alpha included
|
||
let acc = read_accum(&c);
|
||
let mut fd: Vec<f32> =
|
||
cpu_img.px.chunks(4).zip(acc.chunks(4)).zip(&keep).filter(|(_, k)| **k).flat_map(|((a, b), _)| (0..4).map(|k| (a[k] - b[k]).abs())).collect();
|
||
fd.sort_unstable_by(f32::total_cmp);
|
||
let (fp99, fmax) = (fd[fd.len() * 99 / 100], fd[fd.len() - 1]);
|
||
eprintln!("{mode:?}: interior 8-bit p99 {p99} mean {mean:.3} max {max}; linear p99 {fp99:.2e} max {fmax:.2e}; with edges p99 {ap99} mean {amean:.3}");
|
||
assert!(p99 <= 6 && mean < 1.5, "{mode:?}: p99 {p99}, mean {mean}");
|
||
}
|
||
assert!(c.backdrop.is_some(), "blend modes use the backdrop copy");
|
||
}
|
||
|
||
/// Formula edge cases on exact (half-float representable) values, drawn 1:1 so no resampling is
|
||
/// involved: black / white / mid inputs, Divide by zero, Color Dodge and Burn at 0 and 1, zero and
|
||
/// partial alpha on either side.
|
||
#[test]
|
||
fn gpu_blend_edge_cases_match_cpu() {
|
||
let Some((dev, q)) = device() else {
|
||
eprintln!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
let vals = [0.0f32, 1.0, 0.5, 0.25, 0.75, 0.125, 0.875, 0.0625];
|
||
let alphas = [1.0f32, 0.5, 0.0, 0.25];
|
||
let n = vals.len() * alphas.len();
|
||
// backdrop varies along x, source along y: every (b, s, alpha) combination; channels are
|
||
// shifted along the other axis so greys, primaries and mixed colours all occur
|
||
let make = |along_x: bool| {
|
||
let mut px = Vec::with_capacity(n * n * 4);
|
||
for y in 0..n {
|
||
for x in 0..n {
|
||
let (i, o) = if along_x { (x, y) } else { (y, x) };
|
||
let (v, a) = (i % vals.len(), alphas[i / vals.len()]);
|
||
let j = o % 3;
|
||
let c = [vals[v], vals[(v + j) % vals.len()], vals[(v + 2 * j) % vals.len()]];
|
||
px.extend_from_slice(&[c[0] * a, c[1] * a, c[2] * a, a]);
|
||
}
|
||
}
|
||
Arc::new(VideoFrame::rgba_f32(n as u32, n as u32, px))
|
||
};
|
||
let (back, src) = (make(true), make(false));
|
||
let mut c = GpuCompositor::new(&dev, &q);
|
||
for mode in Blend::ALL {
|
||
for opacity in [1.0f32, 0.5] {
|
||
let plan = FramePlan::Layers {
|
||
width: n,
|
||
height: n,
|
||
layers: vec![
|
||
PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal },
|
||
PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode },
|
||
],
|
||
};
|
||
let cpu = execute_cpu(&plan);
|
||
c.composite(&plan);
|
||
let gpu = read_accum(&c);
|
||
let mut worst = (0f32, 0usize);
|
||
let mut steps = 0;
|
||
for (i, (a, b)) in cpu.px.iter().zip(&gpu).enumerate() {
|
||
assert!(b.is_finite(), "{mode:?}: non-finite GPU value at {i}");
|
||
let d = (a - b).abs();
|
||
if d > 0.05 {
|
||
steps += 1;
|
||
}
|
||
if d > worst.0 {
|
||
worst = (d, i);
|
||
}
|
||
}
|
||
eprintln!("{mode:?} @ {opacity}: max |cpu − gpu| = {:.2e} (value {} of {}), {steps} step flips", worst.0, worst.1, gpu.len());
|
||
assert!(worst.0 < 2e-3 || mode == Blend::HardMix && steps <= 2, "{mode:?} @ {opacity}: {worst:?}, {steps}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dissolve on the GPU reproduces the CPU's pixel pattern exactly (opaque layer at partial opacity).
|
||
#[test]
|
||
fn gpu_dissolve_pattern_is_exact() {
|
||
let Some((dev, q)) = device() else {
|
||
eprintln!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
let (w, h) = (257usize, 131usize);
|
||
let white = Arc::new(VideoFrame::rgba_f32(1, 1, vec![1.0; 4]));
|
||
let red = Arc::new(VideoFrame::rgba_f32(1, 1, vec![1.0, 0.0, 0.0, 1.0]));
|
||
let mut c = GpuCompositor::new(&dev, &q);
|
||
for op in [0.1f32, 0.5, 0.9] {
|
||
let plan = FramePlan::Layers {
|
||
width: w,
|
||
height: h,
|
||
layers: vec![
|
||
PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal },
|
||
PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve },
|
||
],
|
||
};
|
||
let cpu = execute_cpu(&plan).over_black_rgba8();
|
||
c.composite(&plan);
|
||
let (_, _, gpu) = c.read_output().expect("readback");
|
||
let differ = cpu.chunks(4).zip(gpu.chunks(4)).filter(|(a, b)| a[..3] != b[..3]).count();
|
||
let kept = gpu.chunks(4).filter(|p| p[1] > 128).count() as f32 / (w * h) as f32;
|
||
eprintln!("Dissolve @ {op}: {differ} differing pixels, {kept:.3} kept");
|
||
assert_eq!(differ, 0, "opacity {op}");
|
||
assert!((kept - op).abs() < 0.02);
|
||
}
|
||
// fixed-function only: no backdrop copy
|
||
assert!(c.backdrop.is_none());
|
||
}
|
||
|
||
/// Normal-only plans keep the single fixed-function pass (no backdrop texture), and blend-mode
|
||
/// layers entirely off the output draw nothing.
|
||
#[test]
|
||
fn normal_fast_path_and_blend_layers_off_output() {
|
||
let Some((dev, q)) = device() else {
|
||
eprintln!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
let mut c = GpuCompositor::new(&dev, &q);
|
||
let grey = Arc::new(VideoFrame::rgba_f32(4, 4, vec![0.2; 64]));
|
||
let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend };
|
||
let normal = FramePlan::Layers { width: 32, height: 16, layers: vec![layer(Affine::scale(8.0, 4.0), Blend::Normal)] };
|
||
c.composite(&normal);
|
||
assert!(c.backdrop.is_none());
|
||
let (_, _, before) = c.read_output().expect("readback");
|
||
let off = FramePlan::Layers {
|
||
width: 32,
|
||
height: 16,
|
||
layers: vec![
|
||
layer(Affine::scale(8.0, 4.0), Blend::Normal),
|
||
layer(Affine::translate(100.0, 0.0), Blend::Multiply),
|
||
layer(Affine::translate(-10.0, -10.0), Blend::Screen),
|
||
],
|
||
};
|
||
c.composite(&off);
|
||
let (_, _, after) = c.read_output().expect("readback");
|
||
assert_eq!(before, after);
|
||
// only blend-mode layers first: the accumulator is still cleared
|
||
let only = FramePlan::Layers { width: 32, height: 16, layers: vec![layer(Affine::translate(100.0, 0.0), Blend::Multiply)] };
|
||
c.composite(&only);
|
||
let (_, _, empty) = c.read_output().expect("readback");
|
||
assert!(empty.chunks(4).all(|p| p[..3] == [0, 0, 0]));
|
||
}
|
||
|
||
#[test]
|
||
fn quad_bounds_clamps_and_handles_non_finite() {
|
||
let l = |m: Affine| PlanLayer { frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), matrix: m, opacity: 1.0, blend: Blend::Screen };
|
||
assert_eq!(quad_bounds(&l(Affine::translate(5.0, 5.0)), 100, 100), Some((4, 4, 12, 12)));
|
||
assert_eq!(quad_bounds(&l(Affine::translate(-5.0, 95.0)), 100, 100), Some((0, 94, 6, 6)));
|
||
assert_eq!(quad_bounds(&l(Affine::translate(f64::NAN, 0.0)), 100, 100), Some((0, 0, 100, 100)));
|
||
assert_eq!(quad_bounds(&l(Affine::scale(f64::INFINITY, 1.0)), 100, 100), Some((0, 0, 100, 100)));
|
||
assert_eq!(quad_bounds(&l(Affine::translate(-50.0, 0.0)), 100, 100), None);
|
||
}
|