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.
This commit is contained in:
@@ -25,3 +25,31 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Cargo test
|
- name: Cargo test
|
||||||
run: cargo test --workspace --all-features -- --nocapture
|
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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
demo = ["dep:sdl2", "dep:glow"]
|
demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
msh-core = { path = "../msh-core" }
|
msh-core = { path = "../msh-core" }
|
||||||
@@ -13,6 +13,7 @@ nres = { path = "../nres" }
|
|||||||
render-core = { path = "../render-core" }
|
render-core = { path = "../render-core" }
|
||||||
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
glow = { version = "0.16", optional = true }
|
glow = { version = "0.16", optional = true }
|
||||||
|
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "parkan-render-demo"
|
name = "parkan-render-demo"
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ cargo run -p render-demo --features demo -- \
|
|||||||
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
|
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
|
||||||
- `--lod` (опционально, default `0`).
|
- `--lod` (опционально, default `0`).
|
||||||
- `--group` (опционально, 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"
|
||||||
|
```
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use glow::HasContext as _;
|
use glow::HasContext as _;
|
||||||
use render_core::{build_render_mesh, compute_bounds};
|
use render_core::{build_render_mesh, compute_bounds};
|
||||||
use render_demo::load_model_from_archive;
|
use render_demo::load_model_from_archive;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -9,6 +9,11 @@ struct Args {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
lod: usize,
|
lod: usize,
|
||||||
group: usize,
|
group: usize,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
capture: Option<PathBuf>,
|
||||||
|
angle: Option<f32>,
|
||||||
|
spin_rate: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Args, String> {
|
fn parse_args() -> Result<Args, String> {
|
||||||
@@ -16,6 +21,11 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
let mut model = None;
|
let mut model = None;
|
||||||
let mut lod = 0usize;
|
let mut lod = 0usize;
|
||||||
let mut group = 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);
|
let mut it = std::env::args().skip(1);
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
@@ -48,6 +58,52 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
.parse::<usize>()
|
.parse::<usize>()
|
||||||
.map_err(|_| String::from("invalid --group value"))?;
|
.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" => {
|
"--help" | "-h" => {
|
||||||
print_help();
|
print_help();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
@@ -64,11 +120,19 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
model,
|
model,
|
||||||
lod,
|
lod,
|
||||||
group,
|
group,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
capture,
|
||||||
|
angle,
|
||||||
|
spin_rate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
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() {
|
fn main() {
|
||||||
@@ -81,25 +145,29 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let model = match load_model_from_archive(&args.archive, args.model.as_deref()) {
|
if let Err(err) = run(args) {
|
||||||
Ok(v) => v,
|
eprintln!("{err}");
|
||||||
Err(err) => {
|
|
||||||
eprintln!("failed to load model: {err:?}");
|
|
||||||
std::process::exit(1);
|
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);
|
let mesh = build_render_mesh(&model, args.lod, args.group);
|
||||||
if mesh.vertices.is_empty() {
|
if mesh.vertices.is_empty() {
|
||||||
eprintln!(
|
return Err(format!(
|
||||||
"model has no renderable triangles for lod={} group={}",
|
"model has no renderable triangles for lod={} group={}",
|
||||||
args.lod, args.group
|
args.lod, args.group
|
||||||
);
|
));
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else {
|
let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else {
|
||||||
eprintln!("failed to compute mesh bounds");
|
return Err(String::from("failed to compute mesh bounds"));
|
||||||
std::process::exit(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let center = [
|
let center = [
|
||||||
@@ -116,8 +184,10 @@ fn main() {
|
|||||||
(extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
|
(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 camera_distance = (radius * 2.5).max(2.0);
|
||||||
|
|
||||||
let sdl = sdl2::init().expect("failed to init SDL2");
|
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||||
let video = sdl.video().expect("failed to init SDL2 video");
|
let video = sdl
|
||||||
|
.video()
|
||||||
|
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let gl_attr = video.gl_attr();
|
let gl_attr = video.gl_attr();
|
||||||
@@ -127,20 +197,32 @@ fn main() {
|
|||||||
gl_attr.set_double_buffer(true);
|
gl_attr.set_double_buffer(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let window = video
|
let mut window_builder = video.window(
|
||||||
.window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720)
|
"Parkan Render Demo (SDL2 + OpenGL ES 2.0)",
|
||||||
.opengl()
|
args.width,
|
||||||
.resizable()
|
args.height,
|
||||||
|
);
|
||||||
|
window_builder.opengl();
|
||||||
|
if args.capture.is_some() {
|
||||||
|
window_builder.hidden();
|
||||||
|
} else {
|
||||||
|
window_builder.resizable();
|
||||||
|
}
|
||||||
|
let window = window_builder
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to create window");
|
.map_err(|err| format!("failed to create window: {err}"))?;
|
||||||
|
|
||||||
let gl_ctx = window
|
let gl_ctx = window
|
||||||
.gl_create_context()
|
.gl_create_context()
|
||||||
.expect("failed to create OpenGL context");
|
.map_err(|err| format!("failed to create OpenGL context: {err}"))?;
|
||||||
window
|
window
|
||||||
.gl_make_current(&gl_ctx)
|
.gl_make_current(&gl_ctx)
|
||||||
.expect("failed to make GL context current");
|
.map_err(|err| format!("failed to make GL context current: {err}"))?;
|
||||||
let _ = video.gl_set_swap_interval(1);
|
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);
|
let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3);
|
||||||
for pos in &mesh.vertices {
|
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 _)
|
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 u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") };
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||||
let a_pos = a_pos.expect("shader attribute a_pos is missing");
|
.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 {
|
unsafe {
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.buffer_data_u8_slice(
|
gl.buffer_data_u8_slice(
|
||||||
@@ -167,7 +249,95 @@ fn main() {
|
|||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
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();
|
let start = Instant::now();
|
||||||
|
|
||||||
'main_loop: loop {
|
'main_loop: loop {
|
||||||
@@ -182,26 +352,62 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed = start.elapsed().as_secs_f32();
|
|
||||||
let (w, h) = window.size();
|
let (w, h) = window.size();
|
||||||
let aspect = (w as f32 / (h.max(1) as f32)).max(0.01);
|
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 {
|
||||||
|
draw_frame(gl, program, u_mvp, a_pos, vbo, vertex_count, w, h, &mvp);
|
||||||
|
}
|
||||||
|
window.gl_swap_window();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 view = mat4_translation(0.0, 0.0, -camera_distance);
|
||||||
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
||||||
let rot = mat4_rotation_y(elapsed * 0.35);
|
let rot = mat4_rotation_y(angle_rad);
|
||||||
let model_m = mat4_mul(&rot, ¢er_shift);
|
let model_m = mat4_mul(&rot, ¢er_shift);
|
||||||
let vp = mat4_mul(&view, &model_m);
|
let vp = mat4_mul(&view, &model_m);
|
||||||
let mvp = mat4_mul(&proj, &vp);
|
mat4_mul(&proj, &vp)
|
||||||
|
}
|
||||||
|
|
||||||
unsafe {
|
#[allow(clippy::too_many_arguments)]
|
||||||
gl.viewport(0, 0, w as i32, h as i32);
|
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.enable(glow::DEPTH_TEST);
|
||||||
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||||
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
gl.use_program(Some(program));
|
gl.use_program(Some(program));
|
||||||
gl.uniform_matrix_4_f32_slice(u_mvp.as_ref(), false, &mvp);
|
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||||
|
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.enable_vertex_attrib_array(a_pos);
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
@@ -209,20 +415,61 @@ fn main() {
|
|||||||
gl.draw_arrays(
|
gl.draw_arrays(
|
||||||
glow::TRIANGLES,
|
glow::TRIANGLES,
|
||||||
0,
|
0,
|
||||||
i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX),
|
vertex_count.min(i32::MAX as usize) as i32,
|
||||||
);
|
);
|
||||||
gl.disable_vertex_attrib_array(a_pos);
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
gl.use_program(None);
|
gl.use_program(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.gl_swap_window();
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
|
||||||
gl.delete_buffer(vbo);
|
let stride = width.saturating_mul(4);
|
||||||
gl.delete_program(program);
|
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> {
|
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {
|
||||||
|
|||||||
9
crates/render-parity/Cargo.toml
Normal file
9
crates/render-parity/Cargo.toml
Normal file
@@ -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"
|
||||||
16
crates/render-parity/README.md
Normal file
16
crates/render-parity/README.md
Normal file
@@ -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 <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
|
||||||
|
- `--keep-going`: continue all cases even after failures.
|
||||||
212
crates/render-parity/src/lib.rs
Normal file
212
crates/render-parity/src/lib.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct ManifestMeta {
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub lod: Option<usize>,
|
||||||
|
pub group: Option<usize>,
|
||||||
|
pub angle: Option<f32>,
|
||||||
|
pub diff_threshold: Option<u8>,
|
||||||
|
pub max_mean_abs: Option<f32>,
|
||||||
|
pub max_changed_ratio: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CaseSpec {
|
||||||
|
pub id: String,
|
||||||
|
pub archive: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub reference: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub lod: Option<usize>,
|
||||||
|
pub group: Option<usize>,
|
||||||
|
pub angle: Option<f32>,
|
||||||
|
pub diff_threshold: Option<u8>,
|
||||||
|
pub max_mean_abs: Option<f32>,
|
||||||
|
pub max_changed_ratio: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ParityManifest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub meta: ManifestMeta,
|
||||||
|
#[serde(rename = "case", default)]
|
||||||
|
pub cases: Vec<CaseSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DiffMetrics, String> {
|
||||||
|
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<RgbaImage, String> {
|
||||||
|
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<Rgba<u8>, Vec<u8>> = 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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
405
crates/render-parity/src/main.rs
Normal file
405
crates/render-parity/src/main.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
keep_going: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct EffectiveCase {
|
||||||
|
id: String,
|
||||||
|
archive: PathBuf,
|
||||||
|
model: Option<String>,
|
||||||
|
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<Args, String> {
|
||||||
|
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 <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--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<RgbaImage, String> {
|
||||||
|
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<EffectiveCase, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
docs/specs/render-parity.md
Normal file
77
docs/specs/render-parity.md
Normal file
@@ -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/<case>.png`
|
||||||
|
|
||||||
|
## CI-модель
|
||||||
|
|
||||||
|
CI запускает `render-parity` на каждом push/PR:
|
||||||
|
|
||||||
|
1. собирает `parkan-render-demo`;
|
||||||
|
2. прогоняет кейсы из `cases.toml`;
|
||||||
|
3. при падении публикует текущие кадры и diff как артефакт.
|
||||||
|
|
||||||
|
Важно: оригинальный движок в CI обычно не запускается.
|
||||||
|
Эталонные PNG снимаются офлайн и версионируются в репозитории.
|
||||||
@@ -39,6 +39,7 @@ nav:
|
|||||||
- Network system: specs/network.md
|
- Network system: specs/network.md
|
||||||
- NRes / RsLi: specs/nres.md
|
- NRes / RsLi: specs/nres.md
|
||||||
- Render pipeline: specs/render.md
|
- Render pipeline: specs/render.md
|
||||||
|
- Render parity: specs/render-parity.md
|
||||||
- Runtime pointer: specs/runtime-pipeline.md
|
- Runtime pointer: specs/runtime-pipeline.md
|
||||||
- Sound system: specs/sound.md
|
- Sound system: specs/sound.md
|
||||||
- Terrain + map loading: specs/terrain-map-loading.md
|
- Terrain + map loading: specs/terrain-map-loading.md
|
||||||
|
|||||||
20
parity/README.md
Normal file
20
parity/README.md
Normal file
@@ -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`.
|
||||||
27
parity/cases.toml
Normal file
27
parity/cases.toml
Normal file
@@ -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
|
||||||
0
parity/reference/.gitkeep
Normal file
0
parity/reference/.gitkeep
Normal file
Reference in New Issue
Block a user