diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
| commit | 18d4c6cf9fabc18282b29d103c8d30024f66e49b (patch) | |
| tree | 7f9adfa0dcae5653f378c14b32a238c6771c1602 /crates/render-demo/src/main.rs | |
| parent | 0e19660eb5122c8c52d5e909927884ad5c50b813 (diff) | |
| download | fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.tar.xz fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.zip | |
feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
Diffstat (limited to 'crates/render-demo/src/main.rs')
| -rw-r--r-- | crates/render-demo/src/main.rs | 371 |
1 files changed, 309 insertions, 62 deletions
diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs index c991c80..5bb0a58 100644 --- a/crates/render-demo/src/main.rs +++ b/crates/render-demo/src/main.rs @@ -1,7 +1,7 @@ use glow::HasContext as _; use render_core::{build_render_mesh, compute_bounds}; use render_demo::load_model_from_archive; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Instant; struct Args { @@ -9,6 +9,11 @@ struct Args { model: Option<String>, lod: usize, group: usize, + width: u32, + height: u32, + capture: Option<PathBuf>, + angle: Option<f32>, + spin_rate: f32, } fn parse_args() -> Result<Args, String> { @@ -16,6 +21,11 @@ fn parse_args() -> Result<Args, String> { let mut model = None; let mut lod = 0usize; let mut group = 0usize; + let mut width = 1280u32; + let mut height = 720u32; + let mut capture = None; + let mut angle = None; + let mut spin_rate = 0.35f32; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { @@ -48,6 +58,52 @@ fn parse_args() -> Result<Args, String> { .parse::<usize>() .map_err(|_| String::from("invalid --group value"))?; } + "--width" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --width"))?; + width = value + .parse::<u32>() + .map_err(|_| String::from("invalid --width value"))?; + if width == 0 { + return Err(String::from("--width must be > 0")); + } + } + "--height" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --height"))?; + height = value + .parse::<u32>() + .map_err(|_| String::from("invalid --height value"))?; + if height == 0 { + return Err(String::from("--height must be > 0")); + } + } + "--capture" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --capture"))?; + capture = Some(PathBuf::from(value)); + } + "--angle" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --angle"))?; + angle = Some( + value + .parse::<f32>() + .map_err(|_| String::from("invalid --angle value"))?, + ); + } + "--spin-rate" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --spin-rate"))?; + spin_rate = value + .parse::<f32>() + .map_err(|_| String::from("invalid --spin-rate value"))?; + } "--help" | "-h" => { print_help(); std::process::exit(0); @@ -64,11 +120,19 @@ fn parse_args() -> Result<Args, String> { model, lod, group, + width, + height, + capture, + angle, + spin_rate, }) } fn print_help() { - eprintln!("parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N]"); + eprintln!( + "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]" + ); + eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]"); } fn main() { @@ -81,25 +145,29 @@ fn main() { } }; - let model = match load_model_from_archive(&args.archive, args.model.as_deref()) { - Ok(v) => v, - Err(err) => { - eprintln!("failed to load model: {err:?}"); - std::process::exit(1); - } - }; + if let Err(err) = run(args) { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn run(args: Args) -> Result<(), String> { + let model = load_model_from_archive(&args.archive, args.model.as_deref()).map_err(|err| { + format!( + "failed to load model from archive {}: {err:?}", + args.archive.display() + ) + })?; let mesh = build_render_mesh(&model, args.lod, args.group); if mesh.vertices.is_empty() { - eprintln!( + return Err(format!( "model has no renderable triangles for lod={} group={}", args.lod, args.group - ); - std::process::exit(1); + )); } let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else { - eprintln!("failed to compute mesh bounds"); - std::process::exit(1); + return Err(String::from("failed to compute mesh bounds")); }; let center = [ @@ -116,8 +184,10 @@ fn main() { (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5; let camera_distance = (radius * 2.5).max(2.0); - let sdl = sdl2::init().expect("failed to init SDL2"); - let video = sdl.video().expect("failed to init SDL2 video"); + let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?; + let video = sdl + .video() + .map_err(|err| format!("failed to init SDL2 video: {err}"))?; { let gl_attr = video.gl_attr(); @@ -127,20 +197,32 @@ fn main() { gl_attr.set_double_buffer(true); } - let window = video - .window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720) - .opengl() - .resizable() + let mut window_builder = video.window( + "Parkan Render Demo (SDL2 + OpenGL ES 2.0)", + args.width, + args.height, + ); + window_builder.opengl(); + if args.capture.is_some() { + window_builder.hidden(); + } else { + window_builder.resizable(); + } + let window = window_builder .build() - .expect("failed to create window"); + .map_err(|err| format!("failed to create window: {err}"))?; let gl_ctx = window .gl_create_context() - .expect("failed to create OpenGL context"); + .map_err(|err| format!("failed to create OpenGL context: {err}"))?; window .gl_make_current(&gl_ctx) - .expect("failed to make GL context current"); - let _ = video.gl_set_swap_interval(1); + .map_err(|err| format!("failed to make GL context current: {err}"))?; + let _ = if args.capture.is_some() { + video.gl_set_swap_interval(0) + } else { + video.gl_set_swap_interval(1) + }; let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3); for pos in &mesh.vertices { @@ -151,12 +233,12 @@ fn main() { glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) }; - let program = unsafe { create_program(&gl).expect("failed to create shader program") }; + let program = unsafe { create_program(&gl)? }; let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; - let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }; - let a_pos = a_pos.expect("shader attribute a_pos is missing"); + let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") } + .ok_or_else(|| String::from("shader attribute a_pos is missing"))?; - let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") }; + let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; unsafe { gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); gl.buffer_data_u8_slice( @@ -167,7 +249,95 @@ fn main() { gl.bind_buffer(glow::ARRAY_BUFFER, None); } - let mut events = sdl.event_pump().expect("failed to get SDL event pump"); + let result = if let Some(capture_path) = args.capture.as_ref() { + run_capture( + &gl, + program, + u_mvp.as_ref(), + a_pos, + vbo, + mesh.vertices.len(), + &args, + center, + camera_distance, + capture_path, + ) + } else { + run_interactive( + &sdl, + &window, + &gl, + program, + u_mvp.as_ref(), + a_pos, + vbo, + mesh.vertices.len(), + &args, + center, + camera_distance, + ) + }; + + unsafe { + gl.delete_buffer(vbo); + gl.delete_program(program); + } + + result +} + +#[allow(clippy::too_many_arguments)] +fn run_capture( + gl: &glow::Context, + program: glow::NativeProgram, + u_mvp: Option<&glow::NativeUniformLocation>, + a_pos: u32, + vbo: glow::NativeBuffer, + vertex_count: usize, + args: &Args, + center: [f32; 3], + camera_distance: f32, + capture_path: &Path, +) -> Result<(), String> { + let angle = args.angle.unwrap_or(0.0); + let mvp = compute_mvp(args.width, args.height, center, camera_distance, angle); + unsafe { + draw_frame( + gl, + program, + u_mvp, + a_pos, + vbo, + vertex_count, + args.width, + args.height, + &mvp, + ); + } + let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? }; + flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize); + save_png(capture_path, args.width, args.height, rgba)?; + println!("captured frame to {}", capture_path.display()); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn run_interactive( + sdl: &sdl2::Sdl, + window: &sdl2::video::Window, + gl: &glow::Context, + program: glow::NativeProgram, + u_mvp: Option<&glow::NativeUniformLocation>, + a_pos: u32, + vbo: glow::NativeBuffer, + vertex_count: usize, + args: &Args, + center: [f32; 3], + camera_distance: f32, +) -> Result<(), String> { + let mut events = sdl + .event_pump() + .map_err(|err| format!("failed to get SDL event pump: {err}"))?; let start = Instant::now(); 'main_loop: loop { @@ -182,47 +352,124 @@ fn main() { } } - let elapsed = start.elapsed().as_secs_f32(); let (w, h) = window.size(); - let aspect = (w as f32 / (h.max(1) as f32)).max(0.01); - - let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); - let view = mat4_translation(0.0, 0.0, -camera_distance); - let center_shift = mat4_translation(-center[0], -center[1], -center[2]); - let rot = mat4_rotation_y(elapsed * 0.35); - let model_m = mat4_mul(&rot, ¢er_shift); - let vp = mat4_mul(&view, &model_m); - let mvp = mat4_mul(&proj, &vp); + let angle = args + .angle + .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate); + let mvp = compute_mvp(w, h, center, camera_distance, angle); unsafe { - gl.viewport(0, 0, w as i32, h as i32); - gl.enable(glow::DEPTH_TEST); - gl.clear_color(0.06, 0.08, 0.12, 1.0); - gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); - - gl.use_program(Some(program)); - gl.uniform_matrix_4_f32_slice(u_mvp.as_ref(), false, &mvp); - - gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); - gl.enable_vertex_attrib_array(a_pos); - gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); - gl.draw_arrays( - glow::TRIANGLES, - 0, - i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX), - ); - gl.disable_vertex_attrib_array(a_pos); - gl.bind_buffer(glow::ARRAY_BUFFER, None); - gl.use_program(None); + draw_frame(gl, program, u_mvp, a_pos, vbo, vertex_count, w, h, &mvp); } - window.gl_swap_window(); } - unsafe { - gl.delete_buffer(vbo); - gl.delete_program(program); + Ok(()) +} + +fn compute_mvp( + width: u32, + height: u32, + center: [f32; 3], + camera_distance: f32, + angle_rad: f32, +) -> [f32; 16] { + let aspect = (width as f32 / (height.max(1) as f32)).max(0.01); + let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); + let view = mat4_translation(0.0, 0.0, -camera_distance); + let center_shift = mat4_translation(-center[0], -center[1], -center[2]); + let rot = mat4_rotation_y(angle_rad); + let model_m = mat4_mul(&rot, ¢er_shift); + let vp = mat4_mul(&view, &model_m); + mat4_mul(&proj, &vp) +} + +#[allow(clippy::too_many_arguments)] +unsafe fn draw_frame( + gl: &glow::Context, + program: glow::NativeProgram, + u_mvp: Option<&glow::NativeUniformLocation>, + a_pos: u32, + vbo: glow::NativeBuffer, + vertex_count: usize, + width: u32, + height: u32, + mvp: &[f32; 16], +) { + gl.viewport( + 0, + 0, + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, + ); + gl.enable(glow::DEPTH_TEST); + gl.clear_color(0.06, 0.08, 0.12, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); + + gl.use_program(Some(program)); + gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.enable_vertex_attrib_array(a_pos); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); + gl.draw_arrays( + glow::TRIANGLES, + 0, + vertex_count.min(i32::MAX as usize) as i32, + ); + gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.use_program(None); +} + +unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> { + let pixel_count = usize::try_from(width) + .ok() + .and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h))) + .ok_or_else(|| String::from("frame dimensions are too large"))?; + let mut pixels = vec![0u8; pixel_count.saturating_mul(4)]; + gl.read_pixels( + 0, + 0, + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelPackData::Slice(Some(pixels.as_mut_slice())), + ); + Ok(pixels) +} + +fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) { + let stride = width.saturating_mul(4); + if stride == 0 { + return; + } + for y in 0..(height / 2) { + let top = y * stride; + let bottom = (height - 1 - y) * stride; + for i in 0..stride { + rgba.swap(top + i, bottom + i); + } + } +} + +fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create output directory {}: {err}", + parent.display() + ) + })?; + } } + let image = image::RgbaImage::from_raw(width, height, rgba) + .ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?; + image + .save(path) + .map_err(|err| format!("failed to save PNG {}: {err}", path.display())) } unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> { |
