blob: b31fbfd885ac49fb36fef8b4c182bd9be34ded43 [file] [log] [blame]
/// End to end rendering test for forma GPU and CPU.
/// A report is written to ${TARGET}/tmp/tests/report.html
use std::{
cell::RefCell,
env,
fmt::Debug,
fs,
fs::{create_dir_all, remove_dir_all},
num::NonZeroU32,
path,
path::PathBuf,
sync::Mutex,
};
use anyhow::{anyhow, Context};
use forma::{
buffer::{layout::LinearLayout, BufferBuilder},
Color, Composition, CpuRenderer, GpuRenderer, RGBA,
};
use image::RgbaImage;
use once_cell::sync::OnceCell;
use serde::Serialize;
pub const WIDTH: f32 = 64.0;
pub const HEIGHT: f32 = 64.0;
pub const PADDING: f32 = 8.0;
fn cpu_render(composition: &mut Composition, width: usize, height: usize) -> RgbaImage {
let mut data = vec![0; width * 4 * height];
let mut layout = LinearLayout::new(width, width * 4, height);
let mut buffer = BufferBuilder::new(&mut data, &mut layout).build();
let mut renderer = CpuRenderer::new();
renderer.render(composition, &mut buffer, RGBA, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.0 }, None);
RgbaImage::from_raw(width as u32, height as u32, data).unwrap()
}
fn gpu_render(composition: &mut Composition, width: usize, height: usize) -> RgbaImage {
let instance = wgpu::Instance::new(wgpu::Backends::PRIMARY);
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
..Default::default()
}))
.expect("failed to find an appropriate adapter");
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: None,
features: Default::default(),
limits: wgpu::Limits {
max_texture_dimension_2d: 8192,
max_storage_buffer_binding_size: 1 << 30,
..wgpu::Limits::downlevel_defaults()
},
},
None,
))
.expect("failed to get device");
let texture_desc = wgpu::TextureDescriptor {
size: wgpu::Extent3d {
width: width as u32,
height: width as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::RENDER_ATTACHMENT,
label: None,
};
let texture = device.create_texture(&texture_desc);
let mut renderer = GpuRenderer::new(&device, texture_desc.format, false);
renderer.render_to_texture(
composition,
&device,
&queue,
&texture,
width as u32,
height as u32,
Color { r: 1.0, g: 1.0, b: 1.0, a: 0.0 },
);
let output_buffer_size = (4 * width * height) as wgpu::BufferAddress;
let output_buffer_desc = wgpu::BufferDescriptor {
size: output_buffer_size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
label: None,
mapped_at_creation: false,
};
let output_buffer = device.create_buffer(&output_buffer_desc);
let mut encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
encoder.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
aspect: wgpu::TextureAspect::All,
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
},
wgpu::ImageCopyBuffer {
buffer: &output_buffer,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: NonZeroU32::new(4 * width as u32),
rows_per_image: NonZeroU32::new(width as u32),
},
},
texture_desc.size,
);
queue.submit(Some(encoder.finish()));
// We need to scope the mapping variables so that we can
// unmap the buffer
let image = {
let buffer_slice = output_buffer.slice(..);
buffer_slice.map_async(wgpu::MapMode::Read, |_| ());
device.poll(wgpu::Maintain::Wait);
let data = buffer_slice.get_mapped_range();
RgbaImage::from_raw(width as u32, height as u32, data.to_vec()).unwrap()
};
output_buffer.unmap();
image
}
fn compare_images(expected: &RgbaImage, actual: &RgbaImage, tolerance: u8) -> anyhow::Result<()> {
let offenders = expected
.pixels()
.zip(actual.pixels())
.filter(|(e, a)| e.0.iter().zip(a.0.iter()).any(|(e, a)| e.abs_diff(*a) > tolerance))
.count();
if offenders > 0 {
Err(anyhow!(
"{} pixels differs have a channel that differ by more than {} units",
offenders,
tolerance
))
} else {
Ok(())
}
}
#[derive(Debug)]
enum RendererType {
Cpu,
Gpu,
}
#[derive(Serialize)]
struct TestReportEntry {
// Unique name for this test.
name: String,
// Actual and expected strings contains the image encoded as
// valid `src` attribute for an HTML `img` element.
cpu_actual: String,
// `None` when no reference image file exist.
// CPU reference is required for test success.
// It is typically missing when a new test is created.
cpu_expected: Option<String>,
gpu_actual: String,
// `None` when no reference exist.
gpu_expected: Option<String>,
// "OK" or "KO".
status: String,
// Test status message. Empty for successful tests.
message: String,
}
// Report collecting all information about all tests that is used for
// HTML report generation.
struct TestReport {
output_dir: PathBuf,
entries: Mutex<Vec<TestReportEntry>>,
}
// Object implementing soft failure so that we can collect
pub struct TestEnv {
test_name: String,
failures: RefCell<Vec<String>>,
}
impl TestEnv {
pub fn new(test_name: &str) -> TestEnv {
TestEnv { test_name: test_name.to_string(), failures: RefCell::new(vec![]) }
}
pub fn test_render<F>(self, compose: F)
where
F: Fn(&mut Composition),
{
if let Err(err) = test_render(compose, &self.test_name) {
self.failures.borrow_mut().push(format!("{:?}", err));
}
}
pub fn test_render_param<F, T: Debug>(&self, compose: F, t: T)
where
F: Fn(&mut Composition),
{
let t = format!("{:?}", t).replace('\"', "");
if let Err(err) = test_render(compose, &format!("{}::{}", self.test_name, t)) {
self.failures.borrow_mut().push(format!("{:?}", err));
}
}
}
// Implements soft failure so that all test can run.
impl Drop for TestEnv {
fn drop(&mut self) {
if self.failures.borrow().is_empty() {
return;
}
println!("Test {}:", self.test_name);
self.failures
.borrow()
.iter()
.enumerate()
.for_each(|(i, e)| println!(" - Error [{}]: {}", i, e));
println!("Report generated at target/tmp/tests/report.html");
panic!();
}
}
fn test_render<F>(compose: F, test_name: &str) -> anyhow::Result<()>
where
F: Fn(&mut Composition),
{
let cpu_actual = {
let mut composition = Composition::new();
compose(&mut composition);
cpu_render(&mut composition, WIDTH as usize, HEIGHT as usize)
};
let gpu_actual = {
let mut composition = Composition::new();
compose(&mut composition);
gpu_render(&mut composition, WIDTH as usize, HEIGHT as usize)
};
let tolerance = 8;
let result = (|| {
let expected_cpu = expected_image(test_name, RendererType::Cpu)
.context("CPU reference image is missing.")?;
compare_images(&expected_cpu, &cpu_actual, tolerance)
.context("CPU result differs from the reference image.")?;
let expected_gpu = expected_image(test_name, RendererType::Gpu).unwrap_or(expected_cpu);
compare_images(&expected_gpu, &gpu_actual, tolerance)
.context("GPU result differs from the reference image.")
})();
add_result_to_report(test_name, &cpu_actual, &gpu_actual, tolerance, &result);
result
}
/// Path to the reference image to which the rendering is compared to.
fn expected_image_path(test_name: &str, renderer: RendererType) -> PathBuf {
let renderer = format!("{:?}", renderer).to_lowercase();
env::current_dir().unwrap().join("expected").join(format!("{}::{}.png", test_name, renderer))
}
/// Returns the reference image buffer if the such image exist.
fn expected_image(test_name: &str, renderer: RendererType) -> anyhow::Result<RgbaImage> {
let path = expected_image_path(test_name, renderer);
if !path.exists() {
return Err(anyhow!("Unable to open file {:?}", path));
}
// Panic if the file exists but is not valid.
Ok(image::io::Reader::open(path)
.context("Unable to open file")?
.decode()
.context("Unable to open file")?
.into_rgba8())
}
fn add_result_to_report(
test_name: &str,
cpu_actual: &RgbaImage,
gpu_actual: &RgbaImage,
tolerance: u8,
status: &anyhow::Result<()>,
) {
static REPORT: OnceCell<Mutex<TestReport>> = OnceCell::new();
let lock = REPORT
.get_or_init(|| {
let output_dir =
path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(env!("CARGO_CRATE_NAME"));
let _ = remove_dir_all(&output_dir);
create_dir_all(&output_dir).unwrap();
Mutex::new(TestReport::new(output_dir))
})
.lock();
let report = lock.unwrap();
let cpu_expected = expected_image_path(test_name, RendererType::Cpu);
let gpu_expected = expected_image_path(test_name, RendererType::Gpu);
report.add_result(
test_name,
cpu_expected.exists().then_some(cpu_expected),
cpu_actual,
gpu_expected.exists().then_some(gpu_expected),
gpu_actual,
tolerance,
status,
);
}
impl TestReport {
fn new(output_dir: path::PathBuf) -> TestReport {
TestReport { output_dir, entries: Mutex::new(vec![]) }
}
#[allow(clippy::too_many_arguments)]
fn add_result(
&self,
test_name: &str,
cpu_expected: Option<PathBuf>,
cpu_actual: &RgbaImage,
gpu_expected: Option<PathBuf>,
gpu_actual: &RgbaImage,
tolerance: u8,
status: &anyhow::Result<()>,
) {
let path =
|dir, suffix| self.output_dir.join(dir).join(format!("{}::{}.png", test_name, suffix));
let to_base64_img_src = |path: &path::PathBuf| {
format!("data:image/png;base64, {}", base64::encode(fs::read(path).unwrap()))
};
let save_actual = |image: &RgbaImage, suffix| {
let path = path("actual", suffix);
fs::create_dir_all(&path.parent().unwrap()).unwrap();
image.save(&path).unwrap();
to_base64_img_src(&path)
};
let cpu_actual_b64 = save_actual(cpu_actual, "cpu");
let gpu_actual_b64 = if compare_images(cpu_actual, gpu_actual, tolerance).is_ok() {
// No need for a GPU reference to be written. This will ease the maintenance of
// expected results directory by copying files from the `/actual` directory with:
// rsync --delete target/tmp/tests/actual/ e2e_tests/expected/
cpu_actual_b64.clone()
} else {
save_actual(gpu_actual, "gpu")
};
let entry = TestReportEntry {
name: test_name.to_string(),
cpu_actual: cpu_actual_b64,
cpu_expected: cpu_expected.as_ref().map(to_base64_img_src),
gpu_actual: gpu_actual_b64,
gpu_expected: gpu_expected.or(cpu_expected).as_ref().map(to_base64_img_src),
status: match status {
Ok(_) => "OK".to_owned(),
Err(_) => "KO".to_owned(),
},
message: match status {
Ok(_) => "".to_owned(),
Err(e) => format!("{:?}", e),
},
};
let mut entries = self.entries.lock().unwrap();
entries.push(entry);
// Update the report every time.
// The is no hook in the test framework to generate this report at the end.
let report = include_str!("report.html").replace(
"[/* generated */]",
&serde_json::to_string_pretty(entries.as_slice()).unwrap(),
);
fs::write(&self.output_dir.join("report.html"), report).unwrap();
}
}