filmcraft/crates/gpu/src/tests.rs

400 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
use filmcraft_geom::{Affine, Vec2};
use filmcraft_render::Blend;
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()
}
#[test]
fn yuv_code_range_uniforms() {
let mut mismatches = Vec::new();
for bits in [8, 10, 12, 16] {
let levels = (1u32 << bits) as f32;
for range in [Range::Full, Range::Limited] {
let src = SrcInfo {
kind: 2,
code_scale: if bits == 8 { 255.0 } else { levels },
code_levels: levels,
alpha: 0.0,
chroma: (1, 1),
size: (1, 1),
color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 },
};
let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1));
// Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8).
let expected = match range {
Range::Full => [0.0, levels - 1.0, levels / 2.0, levels - 1.0],
Range::Limited => [16.0, 219.0, 128.0, 224.0].map(|v| v * (1u32 << (bits - 8)) as f32),
};
if u[16..20] != expected {
mismatches.push(format!("{bits}-bit {range:?}: {:?} != {expected:?}", &u[16..20]));
}
}
}
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
}
#[test]
fn yuv_code_range_gpu_matches_cpu() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let mut prepared_compositor = GpuCompositor::new(&dev, &q);
for bits in [8, 10, 12, 16] {
let scale = 1u32 << (bits - 8);
for range in [Range::Full, Range::Limited] {
// Constant chroma isolates range normalization from CPU/GPU chroma reconstruction.
for (u, v) in [(128, 128), (64, 192), (192, 64)] {
let y = (0..=255).map(|code| (code * scale) as u16).collect::<Vec<_>>();
let planes = [Arc::new(y), Arc::new(vec![(u * scale) as u16; 256]), Arc::new(vec![(v * scale) as u16; 256])];
let data = if bits == 8 {
PixelData::Yuv8 { planes: planes.map(|p| Arc::new(p.iter().map(|v| *v as u8).collect())), chroma: Chroma::C444, alpha: None }
} else {
PixelData::Yuv16 { planes, chroma: Chroma::C444, bits, alpha: None }
};
let frame = Arc::new(VideoFrame {
width: 256,
height: 1,
data,
color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 },
par: (1, 1),
pts: Default::default(),
});
let cpu = frame.to_rgba8();
let plan = FramePlan::Layers { width: 256, height: 1, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] };
c.composite(&plan);
let (w, h, gpu) = c.read_output().expect("readback");
assert_eq!((w, h, gpu.len()), (256, 1, cpu.len()));
let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().expect("pixels");
// One output-code tolerance: CPU f32 vs GPU f16 accumulator quantization.
assert!(error <= 1, "{bits}-bit {range:?} U={u} V={v}: max error {error}");
if u == 128 && v == 128 {
assert!(gpu.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "neutral tint: {bits}-bit {range:?}");
}
let prepared = prepare(&plan);
prepared_compositor.composite_prepared(&plan, Some(&prepared));
assert_eq!(prepared_compositor.read_output().expect("prepared readback").2, gpu, "prepared: {bits}-bit {range:?}");
eprintln!("range gate: {bits}-bit {range:?} U={u} V={v}, max error={error}, prepared=inline");
}
}
}
}
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(),
})
}
#[test]
fn gpu_matches_cpu_plan() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let pixels = filmcraft_media::generators::render(
&filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::OceanSunset),
320,
180,
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let (w, h) = (320usize, 180usize);
let plan = FramePlan::Layers {
width: w,
height: h,
layers: vec![
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer {
frame: rgba,
matrix: Affine::motion(Vec2::new(200.0, 100.0), Vec2::new(0.4, 0.4), 12.0, Vec2::new(160.0, 90.0)),
opacity: 0.7,
blend: Blend::Normal,
fx: None,
},
],
};
let cpu = execute_cpu(&plan).over_black_rgba8();
c.composite(&plan);
let (gw, gh, gpu) = c.read_output().expect("readback");
assert_eq!((gw as usize, gh as usize), (w, h));
// compare away from antialiased edges: mean abs error and 99th percentile
let mut diffs: Vec<u32> =
cpu.chunks(4).zip(gpu.chunks(4)).map(|(a, b)| (0..3).map(|k| (a[k] as i32 - b[k] as i32).unsigned_abs()).max().unwrap_or(0)).collect();
diffs.sort_unstable();
let p99 = diffs[diffs.len() * 99 / 100];
let mean = diffs.iter().sum::<u32>() as f64 / diffs.len() as f64;
assert!(p99 <= 6 && mean < 1.5, "p99 {p99}, mean {mean}");
// cache: compositing the same plan again uploads nothing
let before = c.uploaded_bytes;
c.composite(&plan);
assert_eq!(c.uploaded_bytes, before);
}
#[test]
fn half_float_conversion() {
for v in [0.0f32, 1.0, 0.5, 0.123, 65504.0, -2.0, 1e-5] {
let h = f32_to_f16(v);
// decode
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let e = ((h >> 10) & 0x1f) as i32;
let m = (h & 0x3ff) as f32;
let d = if e == 0 { s * m * 2f32.powi(-24) } else { s * (1.0 + m / 1024.0) * 2f32.powi(e - 15) };
assert!((d - v).abs() <= v.abs() * 1e-3 + 1e-6, "{v} → {d}");
}
}
#[test]
fn prepared_upload_matches_inline_conversion() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let (w, h) = (64u32, 36u32);
let yuv16 = Arc::new(VideoFrame {
width: w,
height: h,
data: PixelData::Yuv16 {
planes: [
Arc::new((0..w * h).map(|i| (64 + (i * 7) % 876) as u16).collect()),
Arc::new((0..w * h / 2).map(|i| (64 + (i * 13) % 896) as u16).collect()),
Arc::new((0..w * h / 2).map(|i| (960 - (i * 5) % 896) as u16).collect()),
],
chroma: Chroma::C422,
bits: 10,
alpha: None,
},
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
pts: Default::default(),
});
let f32_layer: Vec<f32> = (0..w * h * 4).map(|i| ((i * 37) % 1000) as f32 / 1000.0 * if i % 4 == 3 { 1.0 } else { 0.6 }).collect();
let rgbaf = Arc::new(VideoFrame::rgba_f32(w, h, f32_layer.clone()));
let layers = FramePlan::Layers {
width: w as usize,
height: h as usize,
layers: vec![
PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None },
],
};
let image = FramePlan::Image(filmcraft_render::Image { w: w as usize, h: h as usize, px: f32_layer });
for plan in [layers, image] {
let mut a = GpuCompositor::new(&dev, &q);
a.composite(&plan);
let inline = a.read_output().expect("readback");
let mut b = GpuCompositor::new(&dev, &q);
let prep = prepare(&plan);
assert!(prep.bytes() > 0);
b.composite_prepared(&plan, Some(&prep));
let prepared = b.read_output().expect("readback");
assert!(inline == prepared, "prepared upload differs");
}
}
/// The upload cache is keyed by pixel-buffer address, so it must keep the buffer alive: otherwise
/// a new frame allocated at a freed frame's address is drawn with the stale texture (seen as
/// whole frames from a previous composite in `crates/golden` GPU parity).
#[test]
fn upload_cache_keeps_buffers_alive() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let px = Arc::new(vec![200u8; 16 * 8 * 4]);
let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() });
let plan =
FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }] };
c.composite(&plan);
drop(plan);
assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer");
}
/// The WGSL tetrahedral LUT matches `Lut3d::apply` on the CPU.
#[test]
fn gpu_lut_matches_cpu_tetrahedral() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let g = GpuLut::new(&dev, &q);
for size in [2usize, 17, 33, 65] {
let mut lut = filmcraft_color::Lut3d::from_fn(size, |c| {
[(c[0] * c[1] + c[2] * c[2]).sin(), c[1].powf(0.45) * (1.0 - 0.3 * c[0]), (c[0] - c[2]).abs() + 0.1 * c[1]]
});
if size == 17 {
lut.domain_min = [-0.1, 0.0, 0.0];
lut.domain_max = [1.2, 1.0, 2.0];
}
let mut s = 99u64;
let mut px = Vec::new();
for _ in 0..70_000 {
for k in 0..4 {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
px.push(if k == 3 { 0.5 } else { (s % 10_001) as f32 / 10_000.0 * 1.2 - 0.1 });
}
}
let gpu = g.apply(&lut, &px).expect("gpu lut");
let mut worst = 0f32;
for (i, p) in px.as_chunks::<4>().0.iter().enumerate() {
let c = lut.apply([p[0], p[1], p[2]]);
for k in 0..3 {
worst = worst.max((c[k] - gpu[i * 4 + k]).abs());
}
assert_eq!(gpu[i * 4 + 3], 0.5);
}
eprintln!("LUT {size}³: max |cpu − gpu| = {worst:e}");
assert!(worst < 1e-4, "size {size}: max |cpu - gpu| = {worst}");
}
}
fn test_masks(scale: f32) -> Vec<filmcraft_render::mask::FlatMask> {
use filmcraft_project::{Mask, MaskMode, MaskPath, ParamValue};
let mut a = Mask::new("a", MaskPath::ellipse(Vec2::new(150.0, 90.0), Vec2::new(90.0, 55.0)));
a.feather.value = ParamValue::Float(24.0);
a.expansion.value = ParamValue::Float(6.0);
let mut b = Mask::new("b", MaskPath::polygon(&[Vec2::new(40.0, 20.0), Vec2::new(260.0, 50.0), Vec2::new(120.0, 170.0)]));
b.mode = MaskMode::Subtract;
b.feather.value = ParamValue::Float(0.0);
b.opacity.value = ParamValue::Float(70.0);
let mut c = Mask::new("c", MaskPath::rect(200.0, 100.0, 300.0, 175.0));
c.mode = MaskMode::Difference;
c.inverted = true;
c.feather.value = ParamValue::Float(3.5);
filmcraft_render::mask::prepare(&[a, b, c], filmcraft_time::Tick(0), scale)
}
#[test]
fn gpu_mask_coverage_matches_cpu() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let g = crate::GpuMask::new(&dev, &q);
for scale in [1.0f32, 0.5] {
let masks = test_masks(scale);
let (w, h) = ((320.0 * scale) as usize, (180.0 * scale) as usize);
let cpu = filmcraft_render::mask::coverage(&masks, w, h).unwrap();
let gpu = g.coverage(&masks, w, h).expect("gpu coverage");
let worst = cpu.iter().zip(&gpu).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
eprintln!("mask coverage @{scale}: max |cpu − gpu| = {worst:e}");
assert!(worst < 2e-4, "scale {scale}: {worst}");
assert!(cpu.iter().any(|v| *v > 0.99) && cpu.iter().any(|v| *v < 0.01) && cpu.iter().any(|v| *v > 0.2 && *v < 0.8));
}
}
#[test]
fn gpu_masked_mix_matches_cpu() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let g = crate::GpuMask::new(&dev, &q);
let masks = test_masks(1.0);
let (w, h) = (320usize, 180usize);
let original = filmcraft_render::Image { w, h, px: (0..w * h * 4).map(|i| ((i * 37) % 101) as f32 / 100.0).collect() };
let mut effected = original.clone();
effected.px.iter_mut().for_each(|v| *v = 1.0 - *v * 0.5);
let gpu = g.mix(&masks, w, h, &original.px, &effected.px).expect("gpu mix");
let cov = filmcraft_render::mask::coverage(&masks, w, h).unwrap();
let mut cpu = effected.clone();
filmcraft_render::mask::mix(&mut cpu, &original, &cov);
let worst = cpu.px.iter().zip(&gpu).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
eprintln!("masked mix: max |cpu − gpu| = {worst:e}");
assert!(worst < 2e-4, "{worst}");
}
/// A 4:4:4 grey picture with an alpha plane that ramps from transparent (left) to opaque (right).
fn yuv_alpha_frame(w: u32, h: u32, bits: u32) -> Arc<VideoFrame> {
let n = (w * h) as usize;
let max = (1u32 << bits) - 1;
let ramp = |i: usize| ((i % w as usize) as u32 * max / (w - 1)) as u16;
let data = if bits == 8 {
PixelData::Yuv8 {
planes: [Arc::new(vec![180u8; n]), Arc::new(vec![100u8; n]), Arc::new(vec![170u8; n])],
chroma: Chroma::C444,
alpha: Some(Arc::new((0..n).map(|i| ramp(i) as u8).collect())),
}
} else {
let s = |v: u32| (v << (bits - 8)) as u16;
PixelData::Yuv16 {
planes: [Arc::new(vec![s(180); n]), Arc::new(vec![s(100); n]), Arc::new(vec![s(170); n])],
chroma: Chroma::C444,
bits,
alpha: Some(Arc::new((0..n).map(ramp).collect())),
}
};
Arc::new(VideoFrame { width: w, height: h, data, color: filmcraft_color::ColorInfo::REC709, par: (1, 1), pts: Default::default() })
}
/// A Y'CbCr layer with an alpha plane (ProRes 4444) is drawn with its alpha: the GPU result matches
/// the CPU compositor over an opaque background. (It used to draw such a layer opaque.)
#[test]
fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let (w, h) = (320usize, 180usize);
let pixels = filmcraft_media::generators::render(
&filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::OceanSunset),
320,
180,
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
let background = Arc::new(VideoFrame::rgba8(320, 180, pixels));
for bits in [8, 10, 12] {
let mut c = GpuCompositor::new(&dev, &q);
let plan = FramePlan::Layers {
width: w,
height: h,
layers: vec![
PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None },
],
};
let cpu = execute_cpu(&plan).over_black_rgba8();
c.composite(&plan);
let (_, _, gpu) = c.read_output().expect("readback");
let mut diffs: Vec<u32> =
cpu.chunks(4).zip(gpu.chunks(4)).map(|(a, b)| (0..3).map(|k| (a[k] as i32 - b[k] as i32).unsigned_abs()).max().unwrap_or(0)).collect();
diffs.sort_unstable();
let (p99, max) = (diffs[diffs.len() * 99 / 100], diffs[diffs.len() - 1]);
assert!(p99 <= 3 && max <= 8, "{bits}-bit: p99 {p99}, max {max}");
// the transparent left edge shows the background, the opaque right edge the layer
let at = |img: &[u8], x: usize| (img[(90 * w + x) * 4], img[(90 * w + x) * 4 + 1], img[(90 * w + x) * 4 + 2]);
let bg = execute_cpu(&FramePlan::Layers {
width: w,
height: h,
layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }],
})
.over_black_rgba8();
let (l, r, b0) = (at(&gpu, 1), at(&gpu, w - 2), at(&bg, 1));
assert!((l.0 as i32 - b0.0 as i32).abs() <= 6 && (l.1 as i32 - b0.1 as i32).abs() <= 6, "{bits}-bit: left edge {l:?} vs background {b0:?}");
assert!(r != at(&bg, w - 2), "{bits}-bit: right edge {r:?} should differ from the background");
}
}