From 18d4c6cf9fabc18282b29d103c8d30024f66e49b Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 05:02:26 +0400 Subject: [PATCH] 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. --- .gitea/workflows/test.yml | 28 +++ crates/render-demo/Cargo.toml | 3 +- crates/render-demo/README.md | 19 ++ crates/render-demo/src/main.rs | 371 +++++++++++++++++++++++----- crates/render-parity/Cargo.toml | 9 + crates/render-parity/README.md | 16 ++ crates/render-parity/src/lib.rs | 212 ++++++++++++++++ crates/render-parity/src/main.rs | 405 +++++++++++++++++++++++++++++++ docs/specs/render-parity.md | 77 ++++++ mkdocs.yml | 1 + parity/README.md | 20 ++ parity/cases.toml | 27 +++ parity/reference/.gitkeep | 0 13 files changed, 1125 insertions(+), 63 deletions(-) create mode 100644 crates/render-parity/Cargo.toml create mode 100644 crates/render-parity/README.md create mode 100644 crates/render-parity/src/lib.rs create mode 100644 crates/render-parity/src/main.rs create mode 100644 docs/specs/render-parity.md create mode 100644 parity/README.md create mode 100644 parity/cases.toml create mode 100644 parity/reference/.gitkeep diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index cf314cb..e9cfc2e 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -25,3 +25,31 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Cargo test run: cargo test --workspace --all-features -- --nocapture + + render-parity: + name: Render parity + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install headless GL runtime + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils + - name: Build render-demo binary + run: cargo build -p render-demo --features demo + - name: Run frame parity suite + run: | + xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \ + --manifest parity/cases.toml \ + --output-dir target/render-parity/current \ + --demo-bin target/debug/parkan-render-demo \ + --keep-going + - name: Upload parity artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: render-parity-artifacts + path: target/render-parity/current + if-no-files-found: ignore diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml index 376a25e..aab041d 100644 --- a/crates/render-demo/Cargo.toml +++ b/crates/render-demo/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [features] default = [] -demo = ["dep:sdl2", "dep:glow"] +demo = ["dep:sdl2", "dep:glow", "dep:image"] [dependencies] msh-core = { path = "../msh-core" } @@ -13,6 +13,7 @@ nres = { path = "../nres" } render-core = { path = "../render-core" } sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } glow = { version = "0.16", optional = true } +image = { version = "0.25", optional = true, default-features = false, features = ["png"] } [[bin]] name = "parkan-render-demo" diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md index b33b18c..0a1fb45 100644 --- a/crates/render-demo/README.md +++ b/crates/render-demo/README.md @@ -23,6 +23,25 @@ cargo run -p render-demo --features demo -- \ - `--model` (опционально): имя модели; если не задано, берётся первая `.msh`. - `--lod` (опционально, default `0`). - `--group` (опционально, default `0`). +- `--width`, `--height` (опционально, default `1280x720`). +- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах). +- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме. + +## Детерминированный снимок кадра + +Для parity-проверок используется headless-сценарий с фиксированными параметрами: + +```bash +cargo run -p render-demo --features demo -- \ + --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ + --model "A_L_01.msh" \ + --lod 0 \ + --group 0 \ + --width 1280 \ + --height 720 \ + --angle 0.0 \ + --capture "target/render-parity/current/animals_a_l_01.png" +``` ## Ограничения 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, lod: usize, group: usize, + width: u32, + height: u32, + capture: Option, + angle: Option, + spin_rate: f32, } fn parse_args() -> Result { @@ -16,6 +21,11 @@ fn parse_args() -> Result { 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 { .parse::() .map_err(|_| String::from("invalid --group value"))?; } + "--width" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --width"))?; + width = value + .parse::() + .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::() + .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::() + .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::() + .map_err(|_| String::from("invalid --spin-rate value"))?; + } "--help" | "-h" => { print_help(); std::process::exit(0); @@ -64,11 +120,19 @@ fn parse_args() -> Result { model, lod, group, + width, + height, + capture, + angle, + spin_rate, }) } fn print_help() { - eprintln!("parkan-render-demo --archive [--model ] [--lod N] [--group N]"); + eprintln!( + "parkan-render-demo --archive [--model ] [--lod N] [--group N] [--width W] [--height H]" + ); + eprintln!(" [--capture ] [--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, 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) -> 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 { diff --git a/crates/render-parity/Cargo.toml b/crates/render-parity/Cargo.toml new file mode 100644 index 0000000..70c7ac3 --- /dev/null +++ b/crates/render-parity/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "render-parity" +version = "0.1.0" +edition = "2021" + +[dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/crates/render-parity/README.md b/crates/render-parity/README.md new file mode 100644 index 0000000..a94520e --- /dev/null +++ b/crates/render-parity/README.md @@ -0,0 +1,16 @@ +# render-parity + +Deterministic frame-diff runner for `parkan-render-demo`. + +Usage: + +```bash +cargo run -p render-parity -- \ + --manifest parity/cases.toml \ + --output-dir target/render-parity/current +``` + +Options: + +- `--demo-bin `: use prebuilt `parkan-render-demo` binary instead of `cargo run`. +- `--keep-going`: continue all cases even after failures. diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs new file mode 100644 index 0000000..cb412e9 --- /dev/null +++ b/crates/render-parity/src/lib.rs @@ -0,0 +1,212 @@ +use image::{ImageBuffer, Rgba, RgbaImage}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ManifestMeta { + pub width: Option, + pub height: Option, + pub lod: Option, + pub group: Option, + pub angle: Option, + pub diff_threshold: Option, + pub max_mean_abs: Option, + pub max_changed_ratio: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CaseSpec { + pub id: String, + pub archive: String, + pub model: Option, + pub reference: String, + pub width: Option, + pub height: Option, + pub lod: Option, + pub group: Option, + pub angle: Option, + pub diff_threshold: Option, + pub max_mean_abs: Option, + pub max_changed_ratio: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ParityManifest { + #[serde(default)] + pub meta: ManifestMeta, + #[serde(rename = "case", default)] + pub cases: Vec, +} + +#[derive(Debug, Clone)] +pub struct DiffMetrics { + pub width: u32, + pub height: u32, + pub mean_abs: f32, + pub max_abs: u8, + pub changed_pixels: u64, + pub changed_ratio: f32, +} + +pub fn compare_images( + reference: &RgbaImage, + actual: &RgbaImage, + diff_threshold: u8, +) -> Result { + let (rw, rh) = reference.dimensions(); + let (aw, ah) = actual.dimensions(); + if rw != aw || rh != ah { + return Err(format!( + "image size mismatch: reference={}x{}, actual={}x{}", + rw, rh, aw, ah + )); + } + + let mut diff_sum = 0u64; + let mut max_abs = 0u8; + let mut changed_pixels = 0u64; + let pixel_count = u64::from(rw).saturating_mul(u64::from(rh)); + + for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) { + let mut pixel_changed = false; + for chan in 0..3 { + let a = i16::from(ref_px[chan]); + let b = i16::from(act_px[chan]); + let diff = (a - b).unsigned_abs() as u8; + diff_sum = diff_sum.saturating_add(u64::from(diff)); + if diff > max_abs { + max_abs = diff; + } + if diff > diff_threshold { + pixel_changed = true; + } + } + if pixel_changed { + changed_pixels = changed_pixels.saturating_add(1); + } + } + + let channels = pixel_count.saturating_mul(3); + let mean_abs = if channels == 0 { + 0.0 + } else { + diff_sum as f32 / channels as f32 + }; + let changed_ratio = if pixel_count == 0 { + 0.0 + } else { + changed_pixels as f32 / pixel_count as f32 + }; + + Ok(DiffMetrics { + width: rw, + height: rh, + mean_abs, + max_abs, + changed_pixels, + changed_ratio, + }) +} + +pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result { + let (rw, rh) = reference.dimensions(); + let (aw, ah) = actual.dimensions(); + if rw != aw || rh != ah { + return Err(format!( + "image size mismatch: reference={}x{}, actual={}x{}", + rw, rh, aw, ah + )); + } + + let mut out: ImageBuffer, Vec> = ImageBuffer::new(rw, rh); + for (dst, (ref_px, act_px)) in out + .pixels_mut() + .zip(reference.pixels().zip(actual.pixels())) + { + let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8; + let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8; + let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8; + *dst = Rgba([dr, dg, db, 255]); + } + Ok(out) +} + +pub fn evaluate_metrics( + metrics: &DiffMetrics, + max_mean_abs: f32, + max_changed_ratio: f32, +) -> Vec { + let mut violations = Vec::new(); + if metrics.mean_abs > max_mean_abs { + violations.push(format!( + "mean_abs {:.4} > allowed {:.4}", + metrics.mean_abs, max_mean_abs + )); + } + if metrics.changed_ratio > max_changed_ratio { + violations.push(format!( + "changed_ratio {:.4}% > allowed {:.4}%", + metrics.changed_ratio * 100.0, + max_changed_ratio * 100.0 + )); + } + violations +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage { + let mut img = RgbaImage::new(w, h); + for px in img.pixels_mut() { + *px = Rgba([r, g, b, 255]); + } + img + } + + #[test] + fn compare_identical_images() { + let ref_img = solid(4, 3, 10, 20, 30); + let act_img = solid(4, 3, 10, 20, 30); + let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed"); + assert_eq!(metrics.width, 4); + assert_eq!(metrics.height, 3); + assert_eq!(metrics.max_abs, 0); + assert_eq!(metrics.changed_pixels, 0); + assert_eq!(metrics.mean_abs, 0.0); + assert_eq!(metrics.changed_ratio, 0.0); + } + + #[test] + fn compare_detects_changes_and_thresholds() { + let mut ref_img = solid(2, 2, 100, 100, 100); + let mut act_img = solid(2, 2, 100, 100, 100); + ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255])); + act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255])); + + let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed"); + assert_eq!(metrics.max_abs, 20); + assert_eq!(metrics.changed_pixels, 1); + assert!((metrics.changed_ratio - 0.25).abs() < 1e-6); + assert!(metrics.mean_abs > 0.0); + + let violations = evaluate_metrics(&metrics, 2.0, 0.20); + assert_eq!(violations.len(), 1); + assert!(violations[0].contains("changed_ratio")); + } + + #[test] + fn build_diff_image_returns_per_channel_abs_diff() { + let mut ref_img = solid(1, 1, 100, 150, 200); + let mut act_img = solid(1, 1, 90, 180, 170); + ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255])); + act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255])); + + let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build"); + let px = diff.get_pixel(0, 0); + assert_eq!(px[0], 10); + assert_eq!(px[1], 30); + assert_eq!(px[2], 30); + assert_eq!(px[3], 255); + } +} diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs new file mode 100644 index 0000000..22795bc --- /dev/null +++ b/crates/render-parity/src/main.rs @@ -0,0 +1,405 @@ +use image::RgbaImage; +use render_parity::{ + build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const DEFAULT_MANIFEST: &str = "parity/cases.toml"; +const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current"; +const DEFAULT_WIDTH: u32 = 1280; +const DEFAULT_HEIGHT: u32 = 720; +const DEFAULT_LOD: usize = 0; +const DEFAULT_GROUP: usize = 0; +const DEFAULT_ANGLE: f32 = 0.0; +const DEFAULT_DIFF_THRESHOLD: u8 = 8; +const DEFAULT_MAX_MEAN_ABS: f32 = 2.0; +const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01; + +struct Args { + manifest: PathBuf, + output_dir: PathBuf, + demo_bin: Option, + keep_going: bool, +} + +#[derive(Debug, Clone)] +struct EffectiveCase { + id: String, + archive: PathBuf, + model: Option, + reference: PathBuf, + width: u32, + height: u32, + lod: usize, + group: usize, + angle: f32, + diff_threshold: u8, + max_mean_abs: f32, + max_changed_ratio: f32, +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + if let Err(err) = run(args) { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn parse_args() -> Result { + let mut manifest = PathBuf::from(DEFAULT_MANIFEST); + let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR); + let mut demo_bin = None; + let mut keep_going = false; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--manifest" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --manifest"))?; + manifest = PathBuf::from(value); + } + "--output-dir" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --output-dir"))?; + output_dir = PathBuf::from(value); + } + "--demo-bin" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --demo-bin"))?; + demo_bin = Some(PathBuf::from(value)); + } + "--keep-going" => { + keep_going = true; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + Ok(Args { + manifest, + output_dir, + demo_bin, + keep_going, + }) +} + +fn print_help() { + eprintln!( + "render-parity [--manifest ] [--output-dir ] [--demo-bin ] [--keep-going]" + ); + eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})"); + eprintln!(" --output-dir where current renders and diff images are written"); + eprintln!(" --demo-bin prebuilt parkan-render-demo binary path"); + eprintln!(" --keep-going continue all cases even after failures"); +} + +fn run(args: Args) -> Result<(), String> { + let workspace = workspace_root()?; + let manifest_path = resolve_path(&workspace, &args.manifest); + let output_dir = resolve_path(&workspace, &args.output_dir); + let demo_bin = args + .demo_bin + .as_ref() + .map(|path| resolve_path(&workspace, path)); + + let manifest_raw = fs::read_to_string(&manifest_path) + .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?; + let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| { + format!( + "failed to parse manifest {}: {err}", + manifest_path.display() + ) + })?; + + if manifest.cases.is_empty() { + println!( + "render-parity: no cases in {} (nothing to validate)", + manifest_path.display() + ); + return Ok(()); + } + + fs::create_dir_all(&output_dir).map_err(|err| { + format!( + "failed to create output directory {}: {err}", + output_dir.display() + ) + })?; + + let manifest_dir = manifest_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| workspace.clone()); + + let mut failed_cases = 0usize; + for case in &manifest.cases { + let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?; + let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id))); + let diff_file = output_dir + .join("diff") + .join(format!("{}.png", sanitize_case_id(&effective.id))); + + let run_res = run_single_case( + &workspace, // ensure `cargo run` executes from workspace root + demo_bin.as_deref(), + &effective, + &case_file, + &diff_file, + ); + + match run_res { + Ok(()) => {} + Err(err) => { + failed_cases = failed_cases.saturating_add(1); + eprintln!("[FAIL] {}: {}", effective.id, err); + if !args.keep_going { + break; + } + } + } + } + + if failed_cases > 0 { + return Err(format!( + "render-parity failed: {} case(s) did not match reference frames", + failed_cases + )); + } + + println!("render-parity: all cases passed"); + Ok(()) +} + +fn run_single_case( + workspace: &Path, + demo_bin: Option<&Path>, + case: &EffectiveCase, + case_file: &Path, + diff_file: &Path, +) -> Result<(), String> { + run_render_capture(workspace, demo_bin, case, case_file)?; + + let reference = load_rgba(&case.reference)?; + let actual = load_rgba(case_file)?; + let metrics = compare_images(&reference, &actual, case.diff_threshold)?; + let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio); + + if violations.is_empty() { + println!( + "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})", + case.id, + metrics.mean_abs, + metrics.changed_ratio * 100.0, + metrics.max_abs, + metrics.width, + metrics.height + ); + return Ok(()); + } + + if let Some(parent) = diff_file.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create diff output directory {}: {err}", + parent.display() + ) + })?; + } + let diff = build_diff_image(&reference, &actual)?; + diff.save(diff_file) + .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?; + + let mut details = String::new(); + for item in violations { + if !details.is_empty() { + details.push_str("; "); + } + details.push_str(&item); + } + Err(format!( + "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}", + details, + diff_file.display(), + metrics.mean_abs, + metrics.changed_ratio * 100.0, + metrics.changed_pixels, + metrics.max_abs + )) +} + +fn run_render_capture( + workspace: &Path, + demo_bin: Option<&Path>, + case: &EffectiveCase, + out_path: &Path, +) -> Result<(), String> { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create capture directory {}: {err}", + parent.display() + ) + })?; + } + + let mut cmd = if let Some(bin) = demo_bin { + Command::new(bin) + } else { + let mut command = Command::new("cargo"); + command.args(["run", "-p", "render-demo", "--features", "demo", "--"]); + command + }; + + cmd.current_dir(workspace) + .arg("--archive") + .arg(&case.archive) + .arg("--lod") + .arg(case.lod.to_string()) + .arg("--group") + .arg(case.group.to_string()) + .arg("--width") + .arg(case.width.to_string()) + .arg("--height") + .arg(case.height.to_string()) + .arg("--angle") + .arg(case.angle.to_string()) + .arg("--capture") + .arg(out_path); + + if let Some(model) = case.model.as_deref() { + cmd.arg("--model").arg(model); + } + + let output = cmd.output().map_err(|err| { + let mode = if demo_bin.is_some() { + "parkan-render-demo" + } else { + "cargo run -p render-demo" + }; + format!("failed to execute {} for case {}: {err}", mode, case.id) + })?; + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout, + stderr + )); + } + + Ok(()) +} + +fn load_rgba(path: &Path) -> Result { + image::open(path) + .map_err(|err| format!("failed to load image {}: {err}", path.display())) + .map(|img| img.to_rgba8()) +} + +fn make_effective_case( + meta: &ManifestMeta, + case: &CaseSpec, + manifest_dir: &Path, +) -> Result { + let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH); + let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT); + if width == 0 || height == 0 { + return Err(format!( + "case '{}' has invalid dimensions {}x{}", + case.id, width, height + )); + } + + let archive = resolve_path(manifest_dir, Path::new(&case.archive)); + let reference = resolve_path(manifest_dir, Path::new(&case.reference)); + if !archive.is_file() { + return Err(format!( + "case '{}' archive not found: {}", + case.id, + archive.display() + )); + } + if !reference.is_file() { + return Err(format!( + "case '{}' reference frame not found: {}", + case.id, + reference.display() + )); + } + + Ok(EffectiveCase { + id: case.id.clone(), + archive, + model: case.model.clone(), + reference, + width, + height, + lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD), + group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP), + angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE), + diff_threshold: case + .diff_threshold + .or(meta.diff_threshold) + .unwrap_or(DEFAULT_DIFF_THRESHOLD), + max_mean_abs: case + .max_mean_abs + .or(meta.max_mean_abs) + .unwrap_or(DEFAULT_MAX_MEAN_ABS), + max_changed_ratio: case + .max_changed_ratio + .or(meta.max_changed_ratio) + .unwrap_or(DEFAULT_MAX_CHANGED_RATIO), + }) +} + +fn sanitize_case_id(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn workspace_root() -> Result { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .map_err(|err| format!("failed to resolve workspace root: {err}"))?; + Ok(root) +} + +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} diff --git a/docs/specs/render-parity.md b/docs/specs/render-parity.md new file mode 100644 index 0000000..5c63c13 --- /dev/null +++ b/docs/specs/render-parity.md @@ -0,0 +1,77 @@ +# Рендер-паритет (кадровый diff) + +Документ описывает процесс проверки соответствия рендера: +`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`. + +## Цель + +- Зафиксировать объективный критерий "паритет достигнут / не достигнут". +- Убрать субъективную визуальную оценку "похоже/не похоже". +- Дать CI-проверку, которая ловит регрессии сразу после коммита. + +## Единица проверки + +Один тест-кейс = один объект (одна модель) + фиксированная конфигурация: + +- архив ресурса; +- имя модели; +- `lod`; +- `group`; +- размер кадра (`width`, `height`); +- угол камеры (`angle`); +- PNG-эталон из оригинального рендера. + +## Инварианты детерминизма + +Для корректного сравнения кадры должны быть сняты в одинаковых условиях: + +- одинаковый FOV и расстояние камеры до объекта; +- одинаковый clear-color/фон; +- одинаковые `lod/group`; +- фиксированный угол (`angle`), без анимации; +- фиксированное разрешение. + +## Метрики сравнения + +Сравнение выполняется по RGB-каналам: + +- `mean_abs`: средняя абсолютная разница канала (0..255); +- `max_abs`: максимальная разница канала; +- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`. + +Кейс считается пройденным, если: + +- `mean_abs <= max_mean_abs`; +- `changed_ratio <= max_changed_ratio`. + +## Конфигурация кейсов + +Файл: `parity/cases.toml`. + +- секция `[meta]`: глобальные дефолты; +- `[[case]]`: параметры конкретной модели и путь к эталонному PNG. + +Эталонные кадры хранятся в `parity/reference/`. + +## Локальный запуск + +```bash +cargo run -p render-parity -- \ + --manifest parity/cases.toml \ + --output-dir target/render-parity/current +``` + +При расхождении утилита пишет diff-изображение в: + +- `target/render-parity/current/diff/.png` + +## CI-модель + +CI запускает `render-parity` на каждом push/PR: + +1. собирает `parkan-render-demo`; +2. прогоняет кейсы из `cases.toml`; +3. при падении публикует текущие кадры и diff как артефакт. + +Важно: оригинальный движок в CI обычно не запускается. +Эталонные PNG снимаются офлайн и версионируются в репозитории. diff --git a/mkdocs.yml b/mkdocs.yml index cf0907b..c7bf965 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,6 +39,7 @@ nav: - Network system: specs/network.md - NRes / RsLi: specs/nres.md - Render pipeline: specs/render.md + - Render parity: specs/render-parity.md - Runtime pointer: specs/runtime-pipeline.md - Sound system: specs/sound.md - Terrain + map loading: specs/terrain-map-loading.md diff --git a/parity/README.md b/parity/README.md new file mode 100644 index 0000000..dd338bc --- /dev/null +++ b/parity/README.md @@ -0,0 +1,20 @@ +# Render Parity Dataset + +This folder stores parity-test input for `crates/render-parity`. + +- `cases.toml`: list of deterministic render cases. +- `reference/*.png`: baseline frames captured from the original renderer. + +Expected workflow: + +1. Capture baseline PNG frames from original game/editor for each case. +2. Add entries to `cases.toml`. +3. Run: + +```bash +cargo run -p render-parity -- \ + --manifest parity/cases.toml \ + --output-dir target/render-parity/current +``` + +On failure, diff images are saved to `target/render-parity/current/diff`. diff --git a/parity/cases.toml b/parity/cases.toml new file mode 100644 index 0000000..62bb0e3 --- /dev/null +++ b/parity/cases.toml @@ -0,0 +1,27 @@ +[meta] +# Global defaults for all cases. +width = 1280 +height = 720 +lod = 0 +group = 0 +angle = 0.0 + +# Per-pixel change threshold for the "changed pixel ratio" metric. +diff_threshold = 8 + +# Allowed thresholds (case fails if any limit is exceeded). +max_mean_abs = 2.0 +max_changed_ratio = 0.010 + +# Add one block per model. +# +# [[case]] +# id = "animals_a_l_01" +# archive = "../testdata/Parkan - Iron Strategy/animals.rlb" +# model = "A_L_01.msh" +# reference = "reference/animals_a_l_01.png" +# lod = 0 +# group = 0 +# angle = 0.0 +# max_mean_abs = 2.0 +# max_changed_ratio = 0.010 diff --git a/parity/reference/.gitkeep b/parity/reference/.gitkeep new file mode 100644 index 0000000..e69de29