400 lines
18 KiB
Rust
400 lines
18 KiB
Rust
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");
|
||
}
|
||
}
|